ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis分布式锁服务从原理到实战:哨兵、看门狗与高频面试题

Redis分布式锁服务从原理到实战:哨兵、看门狗与高频面试题 凌晨两点手机震动把我吵醒客服群里已经炸了锅——秒杀活动超卖了后台订单数比实际库存多了几十件。这种事故我相信做后端的人都遇到过问题根源并不复杂库存扣减逻辑里只加了本地锁服务部署了多个实例单机锁根本管不住其他机器上的线程。那一刻你就知道需要一个能在多个进程之间生效的互斥机制也就是分布式锁。我当时面临的任务就是为团队搭建一套分布式锁服务实现方案。开头我先把结论摆出来分布式锁不是一个纯理论概念它在抢购、定时任务、幂等控制这些场景里天天都在用也是 Redis 高频知识点和面试题常客。这篇内容我会从使用场景出发讲清楚为什么选择 Redis、核心代码怎么写、封装成公共服务要注意什么、生产环境有哪些坑最后把高频面试题也一并梳理了适合正在做分布式改造的后端工程师参考也适合准备跳槽面试的同学当作复习提纲。1. 分布式锁使用场景梳理先搞清楚锁在锁什么很多人一听到分布式锁第一反应就是多个机器抢同一份资源时保证互斥。这个说法没错但太笼统。我在实际设计过程中发现更重要的一个问题是你的业务到底需不需要一把锁以及这把锁锁住的究竟是哪段逻辑。判断错了后面写得再花哨也是白搭。1.1 三类最常见的分布式锁应用场景第一类是库存类操作最典型的代表就是秒杀和优惠券抢购。库存数据放在 Redis 或者数据库里多个服务实例同时收到请求各自执行查询库存、扣减库存、写回库存的流程。这里每个步骤单独看都很快但合起来是一个非原子的读改写过程两个线程并发执行就可能出现超卖。分布式锁在这里的作用是让同一商品 ID 的扣减流程在同一时间只能被一个线程执行压住超卖的根源。第二类是幂等控制最容易出问题的是支付回调和订单状态流转。举个例子用户支付成功后支付平台会发回调通知为了保证送达回调可能推好几遍同时用户端前端也可能主动轮询订单状态。如果两个请求同时到达订单服务都读到订单是待支付一个把它改成已支付另一个又改成已支付或者做了重复的发货动作业务状态就乱了。加一把锁后同一订单 ID 的状态变更被串行化后到的请求要么等前面完成要么直接识别到当前状态已是最新就结束。第三类是分布式定时任务。业务里常常用 xxl-job 或者 elatic-job 做定时调度在 Kubernetes 多副本部署或者传统多节点部署的情况下同一个任务会被多个节点同时触发。如果没有锁每个节点各跑一遍用户就可能收到多条短信、多张优惠券。通过在任务执行入口加锁保证整个集群里同一时刻只有一个节点真正执行这就是分布式锁最常见的多实例互斥用法。1.2 判断一个锁是否合格的四个维度在落地任何锁方案之前我都会先用下面四个维度把需求和风险过一遍这也是后面技术选型和代码实现的依据。第一个维度是互斥性也就是同一时刻只能有一个客户端持有锁。这是分布式锁最基本的底线做不到这条后面全免谈。第二个维度是死锁规避。持锁进程在运行过程中可能宕机、被 kill、网络闪断如果锁不自动释放其他线程会永远等下去。所以锁必须要有过期时间或者类似的兜底机制。第三个维度是重入性与公平性。你的业务代码里同一个线程可能嵌套调用同一个加锁方法如果锁不可重入自己就把自己锁死了。公平性则关系到是先到先得还是大家一起抢有些场景需要排队避免饥饿。第四个维度是容错性。Redis 主从切换、网络分区、客户端连接异常这些情况会不会导致锁丢失或者锁生效失败在核心交易链路里这个失效率能接受多大直接影响你要不要上 RedLock 这类更复杂的方案。这四个维度看似简单却是分布式锁面试题的高频考察点。面试官问分布式锁怎么实现其实就是在考察你是否想过过期时间、误删锁、Redis 挂掉这些具体问题而不仅仅是背一个 SETNX。2. 技术选型分布式锁服务为什么选 Redis锁方案摆出来无非就那么几种数据库锁、ZooKeeper 锁、Redis 锁以及一些企业里自己用 etcd 实现的方案。我在做技术选型的时候把每种方案的核心优缺点列了一张表然后对着业务需求过了一遍。2.1 主流方案横向对比方案实现方式优点明显短板数据库锁SELECT FOR UPDATE或者乐观锁版本号实现简单不引入新组件性能上限低长事务持锁会拖垮数据库连接数容易被占满ZooKeeper 锁临时顺序节点 Watch 机制无过期时间问题节点释放机制可靠有公平排队引入额外中间件部署维护成本高性能低于 RedisRedis 锁SET key value NX PX Lua 脚本性能高社区方案成熟Redisson接入成本低过期时间需要合理设计极端情况有锁丢失风险etcd 锁Lease Revision 机制可靠性强适合云原生环境和 ZK 类似有额外组件和运维成本我最终选择 Redis其实就是看中它的性能和成熟度。大多数业务场景的并发量并没有高到需要压榨极致可靠性Redis 单实例的锁性能完全够用而 Redisson 这个客户端把加锁、续期、重入、解锁都封装好了项目可以直接依赖不需要从零造轮子。说白了在性能够用、方案成熟、风险可控三者里Redis 是最均衡的选择。需要特别说明的是如果你的业务处于强一致场景比如资金结算、核心账务Redis 锁的极端情况锁丢失可能没法接受那就应该考虑 ZK 或者 etcd。分布式锁的选型没有绝对的好坏只有适合不适合。2.2 Redis 分布式锁的原子性原理Redis 实现分布式锁的核心命令就是一条SETSET lock:product:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000这条命令包含三个关键参数缺一不可。NX表示只有 key 不存在时才设置成功。如果lock:product:1001没有被任何客户端持有当前请求就能设置成功相当于获得了锁如果 key 已经存在这条命令返回失败相当于拿锁失败。这就是互斥性的来源。PX 30000表示锁的过期时间是 30 秒。设置过期时间是为了防止持锁者宕机后锁变成死锁这是分布式锁的兜底机制。这里还要强调一个经典坑点不能用SETNX和EXPIRE两条命令分开执行。因为它们是两步操作一旦客户端在执行完SETNX后、在执行EXPIRE前崩溃锁就永远无法释放整个系统直接进入死锁状态。只有把设置锁 设置过期时间合成一条原子命令才能避免这个问题。第三个要点藏在 value 里也就是这段 UUID 格式的唯一标识。为什么需要它因为解锁的时候要判断这把锁是不是我加的。如果没有带唯一标识任何客户端都可以直接DEL这个 key就会出现 A 线程加的锁被 B 线程误删的问题。正确的解锁逻辑是用 Lua 脚本比对 value 再删除下面会给出具体代码。分布式锁的原子性就是靠 Redis 单线程执行命令来保证的SET和 Lua 脚本都是原子操作不会被并发命令穿插。这一点和后面要讲的 Redisson 实现、看门狗机制都有直接关系。3. 核心实现细节与实操要点理论讲完就要写代码了。我在实际项目里见过两种方式一种是自己基于 Jedis 手写加锁解锁逻辑另一种是直接用 Redisson。两种我都用过这里分别讲清楚。3.1 从原生命令到 Redisson加锁解锁的完整代码如果你是出于学习目的或者项目里不方便引入 Redisson可以用 Jedis Lua 脚本手写一个最小实现。加锁逻辑比较简单核心就是一条SET命令String lockValue UUID.randomUUID().toString(); String result jedis.set(lock:product:1001, lockValue, NX, PX, 30000); if (OK.equals(result)) { // 拿到锁执行业务逻辑 }解锁逻辑就必须用 Lua 脚本了。不能先GET再DEL因为两步操作中间有可能被其他线程插入导致误删新锁。正确做法是原子地完成比对 value 删除 keyif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 调用这段脚本很简单String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(script, Collections.singletonList(lock:product:1001), Collections.singletonList(lockValue)); // result 1 表示释放成功result 0 表示锁已过期或归属不是自己这里每条代码你都能看懂但你马上会发现问题过期时间 30 秒到了业务还没执行完怎么办下次再获取锁的时候线程 A 刚释放线程 B 已经重入两个线程同时在跑锁形同虚设。所以生产环境我几乎不用上面这套原生实现而是直接用 Redisson因为它自带看门狗机制。Redisson 的代码简洁得多RLock lock redissonClient.getLock(lock:product:1001); // 最多等待3秒租约默认30秒看门狗自动续期 boolean locked lock.tryLock(3, TimeUnit.SECONDS); if (locked) { try { // 执行临界区业务 } finally { lock.unlock(); } }这里有两处细节要特别注意。第一tryLock的waitTime指的是获取锁时的等待时间如果 3 秒内没拿到锁就返回 false不是一直阻塞。第二unlock()必须放在finally里避免业务抛异常导致锁泄漏同时 Redisson 再次强调只有持有锁的线程才能解锁进一步防止误删。3.2 过期时间、看门狗与续期机制的博弈锁过期时间的设计是分布式锁里最矛盾的地方。设太短业务还没跑完锁就自动释放会有两个线程同时进临界区设太长持锁者宕机后其他线程要等太久才能恢复。这不只是技术问题更是业务时长和故障恢复速度之间的权衡。Redisson 的看门狗机制就是为了平衡这个问题。当你调用lock()或者tryLock()而不指定 leaseTime 时Redisson 会默认锁的有效期是 30 秒然后启动一个定时任务每隔 10 秒检查一次锁是否还在持有中如果业务没结束就自动把过期时间重置为 30 秒。相当于一个保姆在旁不断帮你续期避免锁提前过期。这里有一个非常容易被忽略的坑如果你的代码显式传入了 leaseTime比如lock(10, TimeUnit.SECONDS)Redisson 就不会启动看门狗自动续期。为什么因为它认为你已经明确告诉它我只要锁 10 秒它就不再兜底。假如你的业务实际跑了 15 秒锁在第 10 秒就释放了另一个线程就可以进来两个线程同时执行临界区这就是线上事故的温床。所以我的实践经验是除非你很清楚业务耗时且留有余量否则不要手动传 leaseTime宁可让看门狗机制帮你管。为了保险还可以在业务入口加一个监控埋点一旦发现锁持有时间接近过期时间就告警强迫自己关注慢业务逻辑。3.3 把分布式锁封装成公共服务的 AOP 落地自己手写 RLock 实现的代码并不难难的是每个业务方都自己写一遍很容易出现锁 key 命名不统一忘记解锁异常处理缺失的问题。所以我当时做了一个核心决定把分布式锁封装成一个公共服务组件对业务方只暴露一个注解方法上标注一下锁就生效。注解定义很简单支持 SpEL 表达式方便业务方动态拼接锁 keyTarget(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DistributedLock { // 锁 key支持 SpEL比如 #orderId String key(); // 等待获取锁的时间默认0表示不等待 long waitTimeSeconds() default 0; // 锁的过期时间默认30配合看门狗机制 long leaseTimeSeconds() default 30; // 拿不到锁时的提示信息 String message() default 系统繁忙请稍后重试; }对应的 AOP 切面核心逻辑分四步。第一步根据方法参数解析出真正的锁 key拼上业务前缀比如distributed:lock:order:pay:123456。第二步调用 Redisson 的tryLock获取锁。第三步拿到锁就执行目标方法拿不到就抛出带message的异常。第四步finally中释放锁。这里有一个非常关键的点锁持有的生命周期要能覆盖整个事务周期。如果目标方法上有Transactional方法内的数据库事务是在方法返回前才提交的而 AOP 切面在方法返回后、最终提交事务之前如果提前释放锁其他线程就会在事务提交前看到旧状态出现脏读问题。解决办法是把锁的释放时机调整到事务提交之后比如把加锁逻辑放在事务控制的外层 Service 方法上让锁方法直接包裹事务方法这样一个线程里的顺序就是加锁 - 开启事务 - 执行业务 - 提交事务 - 解锁。这部分是一个分布式锁服务真正从能用到好用的分水岭。业务方不再关心 NX、PX、Lua、看门狗这些细节只要在方法上加一个注解就行接入成本极低维护成本也集中在你一个人或者一个小组身上。4. 生产环境落地高可用、性能与可观测性一个锁组件写完之后离服务化还有很长一段路。我在生产环境里踩过的问题大多不是加锁本身的逻辑而是高可用、性能、可观测性这三个横向问题。4.1 主从切换导致锁丢失怎么办Redis 高可用部署通常采用主从结构主节点负责写从节点负责同步。这里存在一个分布式锁经典缺陷客户端 A 在主节点上执行SET加锁成功但这条数据还没同步到从节点主节点突然宕机从节点被提升为新的主节点。此时旧的锁数据丢失了客户端 B 就能对同一个 key 加锁成功。两个客户端同时持有锁互斥被打破。业界对这个问题有几种解法。一种是用 Redis 官方之前推的 RedLock 算法向多个独立的 Redis 节点同时加锁只有大多数节点加锁成功才算真正持有锁。RedLock 的原理听起来很美但在实际工程中争议很大不少大牛对它的安全性提出过质疑而且部署多个独立 Redis 实例的成本也高。另一种是在客户端层面做补偿比如 Redisson 提供的主从模式检测和看门狗结合方案尽量缩短锁丢失的窗口期。还有一种思路是从根本上规避对锁丢失特别敏感的业务不要用 Redis 锁改用 ZK 或者 etcd。我给出的实际建议是大多数业务场景比如秒杀、定时任务主从切换那几毫秒的窗口期造成的影响很小用 Redisson 就足够了不需要为了极端事件把架构复杂度拉满如果是资金级别的强一致场景别纠结 Redis 了直接换 etcd 或者 ZK把可靠性做到极致。分布式锁的价值是用合适的资源解决合适的问题过度设计一样是成本。4.2 锁粒度设计与并发性能优化锁的互斥性越强并发度就越低。如果所有资源共用一把锁整个系统就变成一个纯串行处理系统吞吐量会非常难看。所以锁粒度设计是分布式锁服务里非常核心的一环。我见过一个失败案例某团队做订单发货的分布式锁锁 key 直接写成了order:lock所有订单共用一把锁结果大量请求排队接口吞吐量断崖式下跌。正确做法是按业务维度拆分锁 key比如order:lock:orderId每个订单只有自己的请求在竞争再比如秒杀场景如果按用户维度user:lock:userId加锁不同用户之间完全不冲突并发度瞬间就上来了。打比方说这就是从一条单行道变成每个方向都有自己的车道。除了拆分锁 key另一个思路是减少临界区的范围。尽量只把真正需要互斥的那几行代码放入锁内查询、组装数据、远程调用都应放在锁外锁内的时间越短锁的等待率和竞争率就越低。这在性能参数上会直接反映为锁的等待时间下降后续压测时你也可以用这个指标来验证优化效果。4.3 可观测性与监控告警分布式锁服务一旦被多个业务方使用就不再只是你本地的工具代码而是一个基础设施。基础设施就不能黑盒运行必须让使用者和管理者都能看到它的运行状态。我在封装时可以埋几个核心指标加锁成功次数、加锁失败次数、锁等待时间、锁持有时间、解锁异常次数。这些指标可以接入 Prometheus 这类监控系统后续配置出对应的告警规则比如锁等待时间超过 5 秒、解锁失败率超过 0.1%一旦阈值被突破就报警。日志也同样重要。每个加锁和解锁的关键路径都应该输出包含业务标识的日志比如lockKey、requestId、线程 ID、耗时方便在排查问题时串起整个调用链。不要小看这一步线上出事故时没有日志记录的锁服务就是一座黑盒子排查起来非常痛苦。5. 常见问题与分布式锁面试题速查写完代码咱们聊聊分布式锁面试题。现在很多后端岗位面试都会问分布式锁而且不只是问怎么实现更爱问你这个方案有什么问题、极端情况下怎么办。这类问题的回答质量直接体现你对这个技术点的理解深度。5.1 高频面试题解析面试题得分要点Redis 分布式锁怎么实现讲清SET key value NX PXvalue 用唯一标识解锁用 Lua 脚本不要只会背命令为什么要用SET k v NX PX而不是SETNXEXPIRE两条命令非原子EXPIRE前宕机就会死锁锁过期时间小于业务执行时间怎么办用 Redisson 看门狗自动续期或者业务内手动续期前者更省心如何防止误删别人的锁value 用 UUID解锁 Lua 中先比对再删除Redis 主从切换时锁丢了怎么办简述 RedLock 思想及争议给出实践建议对一致性要求极高时换 etcd/ZK分布式锁可重入吗原生 SET 命令不可重入Redisson 的 RLock 基于 Redis hash 结构 计数实现可重入tryLock(3, 30, TimeUnit.SECONDS)三个参数各代表什么waitTime 是等待锁的时间leaseTime 是锁持有时间后者传非 -1 时看门狗不续期这里展开说一下可重入问题。SET key value NX PX天然不可重入同一个线程在同一个 key 上先加锁再调用另一个加锁方法第二次会失败。Redisson 的 RLock 之所以能重入是因为它在 Redis 里存了一个 hash 结构field 是客户端唯一标识value 是重入计数。加锁时计数加一解锁时计数减一只有计数归零才真正删除 key。这个机制也是很多面试官喜欢深挖的点讲清楚能明显加分。5.2 我踩过的几个典型坑第一个坑锁 key 设计成动态值。我曾经见过有人在方法注解里写key#requestId结果每个请求都生成一个随机 requestId每个请求都拿到不同的锁加锁形同虚设。锁 key 必须是业务维度上需要互斥的资源标识比如订单号、商品 ID、用户 ID而不是请求本身的标识。第二个坑同一个锁在 A 服务加锁、B 服务解锁。每个服务的 Redis 连接池不同Redisson 的锁归属于加锁的客户端跨客户端解锁大概率失败甚至可能把别人的锁解掉。锁的加锁和解锁必须成对出现在同一段代码路径里不能在两个服务里分别处理。第三个坑方法内部自调用导致注解切面失效。Spring 的 AOP 本质是代理本类内部方法调用走的是this引用不经过代理所以DistributedLock要使必须在不同类之间调用或者通过注入自身代理来触发切面。这个问题在分布式锁注解中非常隐蔽不实际排查很难发现。第四个坑拿不到锁时的处理策略。有些代码拿不到锁就抛异常有些代码就重试几次有些干脆静默跳过。不同业务要有不同策略。比如秒杀场景拿不到锁应该快速失败给用户提示手慢了而定时任务场景拿不到锁说明别人已经在执行了静默跳过即可。把这些策略沉淀到组件里让业务方按需选择比让每个业务方自己写判断逻辑要干净得多。6. 写在最后几点个人体会分布式锁看着简单真正做好服务化却需要操心很多细节。我个人在实际操作中有几个很深的感受在这里多说两句。第一能用幂等设计解决的不要硬靠锁。很多所谓的并发问题业务上通过唯一索引、状态机、幂等表就能兜住锁反而变成了一个有单点隐患的依赖。分布式锁应该是最后一道防线而不是唯一的防线。第二锁服务一定要预留开关和降级路径。线上如果 Redis 抖动所有依赖锁的接口可能同时超时或阻塞这时如果能快速把锁组件降级为放行至少保证业务可用性再通过日志和监控事后发现潜在冲突比硬扛锁故障强得多。这个开关我在组件里默认就有配置中心改个值就能生效。第三关于分布式锁面试题最有效的准备方式不是背八股而是真的在自己的项目里写过一遍哪怕只是从 Redisson 的封装里翻一翻源码看一眼看门狗是怎么续期的你答出来的深度和背出来的完全是两个档次。分布式锁服务就是这样原理不复杂但每一步细节都可能成为事故的源头。希望上面这些实战内容和踩坑记录能帮你在自己落地的时候少走一些弯路。
返回列表