☰
Redis分布式锁服务从原理到实战:哨兵、看门狗与高频面试题
2026/10/6 12:53:27 网站建设 项目流程

凌晨两点,手机震动把我吵醒,客服群里已经炸了锅——秒杀活动超卖了,后台订单数比实际库存多了几十件。这种事故我相信做后端的人都遇到过,问题根源并不复杂:库存扣减逻辑里只加了本地锁,服务部署了多个实例,单机锁根本管不住其他机器上的线程。那一刻你就知道,需要一个能在多个进程之间生效的互斥机制,也就是分布式锁。

我当时面临的任务,就是为团队搭建一套分布式锁服务实现方案。开头我先把结论摆出来:分布式锁不是一个纯理论概念,它在抢购、定时任务、幂等控制这些场景里天天都在用,也是 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 机制无过期时间问题,节点释放机制可靠,有公平排队引入额外中间件,部署维护成本高,性能低于 Redis
Redis 锁SET key value NX PX+ Lua 脚本性能高,社区方案成熟(Redisson),接入成本低过期时间需要合理设计,极端情况有锁丢失风险
etcd 锁Lease + Revision 机制可靠性强,适合云原生环境和 ZK 类似,有额外组件和运维成本

我最终选择 Redis,其实就是看中它的性能和成熟度。大多数业务场景的并发量并没有高到需要压榨极致可靠性,Redis 单实例的锁性能完全够用,而 Redisson 这个客户端把加锁、续期、重入、解锁都封装好了,项目可以直接依赖,不需要从零造轮子。说白了,在"性能够用、方案成熟、风险可控"三者里,Redis 是最均衡的选择。

需要特别说明的是,如果你的业务处于强一致场景,比如资金结算、核心账务,Redis 锁的"极端情况锁丢失"可能没法接受,那就应该考虑 ZK 或者 etcd。分布式锁的选型没有绝对的好坏,只有适合不适合。

2.2 Redis 分布式锁的原子性原理

Redis 实现分布式锁的核心命令就是一条SET:

SET 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 + 删除 key":

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

Java 调用这段脚本很简单:

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 表达式,方便业务方动态拼接锁 key:

@Target(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 PX,value 用唯一标识,解锁用 Lua 脚本,不要只会背命令
为什么要用SET k v NX PX,而不是SETNX+EXPIRE?两条命令非原子,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 的封装里翻一翻源码,看一眼看门狗是怎么续期的,你答出来的深度和背出来的完全是两个档次。

分布式锁服务就是这样,原理不复杂,但每一步细节都可能成为事故的源头。希望上面这些实战内容和踩坑记录,能帮你在自己落地的时候少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询