搞分布式系统的人,都会撞上一个经典场景:多个服务实例同时要操作同一份资源。库存扣减、订单防重、定时任务抢占、优惠券领取,这类问题的背后都需要一个基础组件——分布式锁。而说到分布式锁,Redis 往往是第一个被想起的答案:快、简单、基础设施里本来就有。但用过的朋友都清楚,Redis 分布式锁属于"入口容易、精通难"的典型:SET NX 一条命令写起来很爽,上线后却可能被锁过期、误删锁、主从切换这些暗坑轮流教育。
这篇博文我想把 Redis 分布式锁从实现原理到坑点清单一口气讲透,包括最基础的 SET NX EX 组合、Lua 脚本原子解锁、看门狗续期机制,以及很多教科书不会展开的主从切换丢锁、时钟跳变、Redlock 争议这些硬伤。内容适合两种读者:一是刚接触分布式锁、想知道怎么写才能不出事的后端开发;二是在生产环境已经在用 Redis 锁、想对照排查线上问题的同学。
1. 先厘清:分布式锁到底在解决什么问题
1.1 一把合格的锁至少要满足三个条件
在动手写代码之前,我建议先把"分布式锁需要保证什么"这个问题想清楚。没有这份认知,后面写出的代码很容易变成"能跑但不敢上线"的定时炸弹。
我把核心诉求总结成三点:
- 互斥性:同一时刻,只能有一个客户端持有锁。这是底线,也是大家做锁的第一直觉。
- 防死锁:持有锁的客户端崩溃或网络断开时,锁不能被永久占用。这意味着锁必须有超时机制,即使进程消失,锁也能最终自动释放。
- 对称性:只有锁的持有者才能释放锁。很多人写分布式锁时忽略了这点,结果出现"自己把自己锁解开了"甚至"把别人的锁解开了"的诡异问题。
用一个生活化例子来类比:想象公司茶水间只有一把"微波炉使用牌"。谁要热饭就把牌子放自己工位上,热完再放回去。如果某位同事捧着饭突然被叫去开会,牌子可能会一直放在他桌上——所以要约定一个最长占用时间,到点没还就必须把牌子放回原位,这就是防死锁。另一方面,如果你拿了牌子,结果隔壁同事趁你接电话把牌子从你桌上拿走去用了,那你俩的饭菜就冲突了——对应到代码里,就是"锁被误解开"或者"锁被抢走"。
1.2 为什么大家第一反应都是选 Redis
市面上能做分布式锁的组件其实不少:数据库唯一索引、ZooKeeper、etcd,都能实现类似效果。但绝大多数团队的首选还是 Redis,原因很简单:
- 性能兜底:Redis 内存操作、单线程执行模型天然串行化,配合 SET 命令的原子性,完成一次加锁可能只要零点几毫秒。
- 现成可用:绝大多数公司的技术栈里早已有 Redis 集群,不需要为"锁"这个需求单独引入一套高可用组件。
- 接入轻量:客户端 SDK 遍地都是,一个 API 调用就能加锁解锁,不像 ZooKeeper 或 etcd 那样要处理会话、节点、监听等复杂概念。
不过,Redis 锁性能极好,却有一个绕不开的标签:它不是强一致方案。举个最典型的例子:Redis 的主从复制是异步的,如果锁写入主节点后、还没同步到从节点时主节点宕机,从节点被提升为主,那么新的主节点里根本没有这把锁的记录。这种情况下,另一个客户端来加锁,就会成功——两个"主人"同时出现。
这不是稀有故障,而是架构层面必然存在的窗口。很多团队把 Redis 锁当成了"绝对互斥"的保证,其实它更适合被理解成"低概率重复下的第一道防线"。这句话理解不到位,后面所有缺陷都会演变成线上事故。我在第 4 节会重点展开这个问题。
2. 从零实现一把 Redis 分布式锁
2.1 SETNX + EXPIRE 是最经典的"错误示范"
很多老项目的分布式锁代码长这样:先执行 SETNX 命令去占位,如果返回 1 说明抢到了,再单独执行一条 EXPIRE 命令设置过期时间。逻辑上好像没毛病,但这是一个经典的"非原子两步操作"。
问题出在哪?想象一条时间线:
- 客户端 A 执行
SETNX lock 1,成功获得锁。 - 客户端 A 还没来得及执行
EXPIRE lock 30,进程突然被强杀,或者所在实例发生极端阻塞。 - Redis 中的这条 lock 记录没有任何过期时间,永远占住。
于是这套系统里的锁死锁了。后续所有客户端都加锁失败,服务处于半瘫痪状态,只能靠人肉去 Redis 里手动删 key 恢复。这个事故在业界并不罕见,几乎每隔一段时间就有团队因为这种写法翻车。
所以正确做法从一开始就必须强调:加锁和设置过期时间,必须是一条原子命令。
2.2 加锁:一条 SET 命令原子完成
Redis 从某个版本开始(具体来说,合并了 SETNX 和 EXPIRE 的能力之后),一条 SET 命令就能完成"加锁 + 设置超时":
SET lock:order:2024001 a21f9b0f-3c2d-4e5f-9a8b-7c6d5e4f3a2b NX EX 30关键参数拆解:
NX表示"Only set if Not eXists",key 不存在时才写入,这是互斥性的来源。EX 30表示以秒为单位设置过期时间,30 秒后自动释放。也可以用PX 30000设置毫秒。- value 用一个全局唯一的随机串,比如 UUID、雪花ID、请求流水号。它的作用是后面解锁时候的"身份证"。
命令返回OK表示加锁成功,返回nil(在客户端 SDK 里通常是 null 或 false)表示 key 已存在、锁被别人持有。
用常见的 Java 客户端写出来大概是这种感觉:
String lockKey = "lock:order:2024001"; String requestId = UUID.randomUUID().toString(); String result = jedis.set(lockKey, requestId, "NX", "EX", 30); if ("OK".equals(result)) { // 恭喜,锁到手 } else { // 别人先了一步 }这里有几个细节值得公司经验总结:
EX和PX不要写反。秒和毫秒差 1000 倍,曾经有人把EX 30理解成毫秒,以为锁只保留了 30 毫秒,结果所有并发请求几乎全部互相顶掉。- value 必须是全局唯一的,不能图省事写
"1"或线程名。原因待会儿解锁部分详说。 - 30 秒只是示例值。TTL 的合理区间取决于业务的最长执行时间,我一般会按业务耗时的 P99 再留一个缓冲带,比如业务平均 200ms、P99 1 秒,那 TTL 至少给 5~10 秒。太短锁会提前释放,太长会有锁悬挂风险。
2.3 解锁:为什么必须用 Lua 脚本
加锁容易,解锁才是翻车高发地。
很多人的第一版解锁代码会这样写:先去GET一下锁的 value,如果等于自己的 requestId,就执行DEL删除 key。看起来逻辑正确,但它不是原子操作——中间隔着两次网络往返。
考虑这个可怕的时间线:
- 客户端 A 拿到锁,value 是
tokenA,业务执行耗时超过了 30 秒,锁自动过期。 - 客户端 B 立刻加锁成功,value 是
tokenB。 - 客户端 A 业务终于跑完,执行
GET,发现当前 value 是tokenA(其实这已经是一个过期的异常状态?不对,此刻 key 的 value 应该是tokenB了,所以 GET 结果应该是tokenB,A 不会删除)。
等等,重新推演一下真正的竞态窗口:
- A 执行业务,30 秒后锁过期。
- B 加锁成功,Redis 中锁的 value 是
tokenB。 - A 在 finally 块中执行
GET lockKey,拿到tokenB,发现不等于自己的tokenA,于是不删除——看起来没问题?
但竞态窗口存在于另一种场景里:
- A 的锁还没过期,业务执行完,准备解锁。
- A 执行
GET,拿到了tokenA,判断相等。 - 就在 A 准备执行
DEL的一瞬间,锁因为某种原因过期了(比如 TTL 设置极短),B 立刻拿到锁,value 是tokenB。 - A 的
DEL命令此刻才发出——删掉的是 B 的锁。
这种窗口极小,但分布式场景下这种极小窗口在流量放大后就是真实事故:A 把 B 的锁解了,B 的临界区还没有执行完,C 又进来拿锁,三份并发一起跑。
解决办法就是让"比较 value + 删除 key"变成一条不可分割的原子操作。Redis 生态里最顺手的工具就是 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end调用时传入 key 和的期望 value:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:2024001 a21f9b0f-3c2d-4e5f-9a8b-7c6d5e4f3a2b在业务代码里,解锁放在 finally 块中执行,保证无论业务成功失败都会释放锁:
try { // 业务逻辑 } finally { jedis.eval(UNLOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(requestId)); }这里背后为什么有效,说破也简单:Redis 执行 Lua 脚本是单线程串行的,脚本中间不会被其他命令插入。因此"检查身份 + 删除 key"这个组合不可能被别的客户端插入任何 SET 或 DEL,原子性就有了。
3. 进阶能力:续期与可重入是怎么做的
3.1 锁超时的两难与"看门狗"思路
第 2 节的基础实现能解决大部分问题,但很快你会遇到一个更现实的烦恼:锁过期时间设多少合适?
如果设得太短,业务还没执行完锁就没了,另一个客户端趁虚而入;如果设得太长,持有锁的实例一旦僵死,所有等待锁的请求都要干等到超时结束。
有些团队试图用"不断调大 TTL"来捂问题,把锁设成 5 分钟、10 分钟。这只能缓解症状,不能根治——线上业务谁会保证自己的慢接口一定在 10 分钟内结束?
真正的解决套路叫续期机制,行业里形象地叫它"看门狗"。它做的事情是:客户端在持有锁期间,后台一个守护任务每隔一段时间检查一下,如果锁还存在且还是自己持有的,就自动把过期时间往后推。业务一旦结束或者进程中断,后台续期也就停掉了。
实现思路用文字描述大概是这样:
- 加锁成功后,启动一个定时任务,每隔 TTL 的三分之一时间执行一次"续约"命令。
- 续约命令同样用 Lua 脚本保证原子性:检查 value 是否是自己,是的话就用 EXPIRE 重新设置 TTL。
- 解锁时,不仅要 DEL 锁,还要取消这个定时任务。
这个机制的巨大价值在于:锁的 TTL 可以设得相对保守,比如 30 秒,实际业务跑 50 秒也没关系,因为后台一直在续期;而真正出故障时,进程死亡导致守护任务不在,锁最迟 30 秒后自动释放。相当于把"持锁时间"从静态变成了动态。
值得一提的是,很多主流 Redis 客户端框架已经内置了这个能力。比如 Java 生态里大家常说的 Redisson,它的分布式锁默认就带着看门狗逻辑,锁没有手动释放时会自动续期,默认续约时间是 30 秒左右。所以在实际项目里我不建议裸写 Jedis 去手动实现续期,除非你想彻底弄懂原理。
3.2 可重入:哈希结构藏着重入计数
另外一个容易忽略的需求叫"可重入"。什么是可重入?同一个线程已经持有一把锁,它再次去获取同一把锁时,不应该把自己挡在门外。
举个例子,你的服务里有一段公共方法updateUser(),方法内部为防并发加了一把锁;这个方法又被updateUserAndDoSomethingElse()调用,后者也加了同一把锁。如果锁不支持可重入,第二次加锁就会失败——你以为自己在防止并发,结果先把串行业务自己锁死了。
低级的"可重入"解决方案是在客户端维护一个 ThreadLocal 计数器:加锁时判断当前线程是否已经持有该锁,是则计数加一,不真正执行 Redis 命令;解锁时计数减一,减到零才真正 DEL。这个方案在同 JVM 内有效,但跨进程、跨线程时容易露馅。
更规范的做法是用 Redis 的哈希结构来实现。锁的 key 不变,哈希里的 field 存"客户端标识 + 线程标识",value 存重入次数。加锁时先判断当前线程是否已经持有锁:
-- 加锁脚本(伪代码级别,真正用还需细调) if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[1], 1) redis.call('pexpire', KEYS[1], ARGV[2]) return 1 end if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('hincrby', KEYS[1], ARGV[1], 1) -- 刷新过期时间 return 1 end return 0解锁时对应的做 decrement,减到 0 才删除整个 key。这样一来,可重入性从客户端的小聪明变成了 Redis 端的真实状态,多个客户端、多个线程之间都能正确感知。
当然,如果你的业务逻辑足够清爽、不存在层层加锁的嵌套情况,可重入未必是重点。但了解它的常见实现方式,能帮你避免看到别人代码里的 HINCRBY 时一头雾水。
4. 这套方案的四个缺陷,一个都别小看
4.1 TTL 与业务超时的错位
第一个缺陷,也是我见过上线事故最多的一类:业务执行时间超过锁的过期时间。这时候锁先被释放,另一个客户端乘虚而入,而第一个客户端还毫不知情地继续写数据,最终两个写操作同时在临界区里执行。
有一种流传很广的"土办法":在业务代码里不断注释 TTL,从 10 秒改到 60 秒,从 60 秒改到 5 分钟,最后一顿操作猛如虎,问题没有根治,反而引入一个新问题——如果持有锁的实例卡死了,其他实例等锁的时间变成 5 分钟,相当于把单点故障的冲击时间放大到一个不可接受的长度。
我自己的经验是三个组合拳一起上:
- 合理预估业务 P99 耗时,以此为基准设定 TTL,再留 20%~50% 的余量。
- 用看门狗续期机制,让锁的存活时间跟随业务实际耗时动态延展。
- 最关键的是,业务层要加幂等兜底。分布式锁只是概率上拦住并发,不是数学上保证唯一。库存扣减可以用"扣前校验+日志唯一键";支付回调可以用状态机判断是否已处理。锁的保护是一条防线,幂等是最后一条防线。
4.2 主从切换:锁在复制链路中丢得无声无息
这是 Redis 分布式锁最出名的系统性缺陷。正常的 Redis 高可用架构是主从复制加哨兵或集群:写操作都打在 master 上,master 异步把数据复制到 slave。问题就在于"异步复制"这四个字。
完整的事故链是这样的:
- 客户端 A 向 master 发送
SET lock:order:1 tokenA NX EX 30,master 返回 OK,A 拿到了锁。 - 这条数据还没来得及同步到 slave,master 所在机器突然宕机或者被强制重启。
- 哨兵或者集群完成故障转移,某个原本的 slave 被提升为新的 master。而它上面并没有
lock:order:1这条数据。 - 客户端 B 向新的 master 发送同样的加锁命令,成功——这代表 A 和 B 同时认为自己持锁。
注意,这不是"极端特殊才会发生"的网络故障,而是主从架构下每次主从切换都有概率触碰的窗口。窗口有多小取决于同步延迟,但只要不是零,在高频锁请求下就会成为真实风险。
如果只是偶尔的定时任务抢占,这个窗口也许可以忍;但如果锁保护的是扣库存、转账等强一致场景,那就不能在纯 Redis 上裸奔。这也是为什么很多架构师在处理资金类业务时宁愿牺牲一点性能去选 ZooKeeper 或 etcd——它们的分布式一致性协议保证数据写入大多数节点才算成功,不会出现"刚写入主节点就丢"的情况。
4.3 Redlock 好但不够好:一场值得品味的争论
为了对抗主从切换丢锁,一位业界大神提出了 Redlock 算法:部署 N 个(通常奇数个)完全独立的 Redis 节点,客户端必须对大多数节点成功加锁,才算获得分布式锁;释放锁时向所有节点都发送释放命令。这个思路看起来很美——一个节点挂了,其他节点上还有锁记录,大多数节点都同意才算数,主从切换丢锁的窗口被大幅缩小。
但分布式系统领域另一位大佬公开撰文提出了强烈质疑,在圈子里掀起过一场旷日持久的"神仙打架"。质疑的核心逻辑非常值得每个人认真咀嚼:
分布式锁要真正安全,必须在网络延迟、进程 GC 暂停、时钟跳变这些现实条件下依然成立。而 Redlock 依赖大多数节点达成一致,看起来像分布式一致性协议,却没有真正的一致性保证。特别是当客户端 A 加锁后发生一次长时间的 GC 停顿,锁在其他节点上过期了,客户端 B 趁机加锁成功;然后 A 从 GC 中苏醒,它仍然记录着自己持有锁,可此时锁其实已经易主。
这场争论里最出名的解决方案概念叫"隔离令牌":分布式锁的每次成功获取,都应该返回一个单调递增的版本号;客户端写数据时必须携带这个版本号,存储系统对版本号做校验,旧的版本号一律拒绝。这样锁本身即便偶发失效,数据层还有一道守卫能拦住过期的写请求。
我在实际项目里很少完全站队某一方。我的体感是:如果你的系统能接受"极小概率的并发重复",Redis 锁(包括 Redlock 思想)够用;如果绝对不能接受,那就别在锁的算法上死磕,去引入 etcd/ZooKeeper,或者给写操作加上版本号做防重。Redlock 不是银弹,但它把"主从丢锁"变成了"多数节点都背叛"这种更难发生的事件,在很多业务场景里也确实够硬。
4.4 时间、淘汰策略与集群问题也能补一刀
除了上面三个大坑,还有一些容易被忽略的细节:
- 过期与删除策略。Redis 删除过期 key 是惰性删除 + 周期删除结合。如果锁 key 在过期后、还没被删除时,恰好赶上内存淘汰策略触发,可能发生 key 提前被淘汰的情况——锁被"意外释放",并发进入。所以生产环境要仔细规划
maxmemory-policy,不要把 Redis 当纯粹的缓存容器和锁容器混在一个压力锅里。 - 时间跳变。如果对锁设置了绝对过期时间(某些实现习惯用
expireAt),系统时钟一旦发生回拨,锁的存活时间会异常拉长。更稳的做法是统一用相对过期时间(比如 EX 30 秒),让 Redis 按自己内部的时间基准去计算,不要把客户端时钟卷进来。 - 网络超时语义。加锁命令发出后,如果客户端和 Redis 之间的网络出现超时,你无法确认这条命令到底执行了没有。此时如果客户端直接认为"加锁失败"并重试,可能产生两个客户端同时持锁。严谨的做法是为加锁操作引入一个短的 unique operation ID,配合幂等检查,但这已经比较接近自研中间件了。
5. 实战中的问题排查与快速定位
5.1 高频问题:拿不到锁、锁被误删、看门狗失效
- 高频问题之一:明明只有一个客户端在跑,却频繁加锁失败。先检查你选的 key 是否被别人复用了。分布式锁的 key 格式建议用
lock:业务域:资源ID这样结构化的命名,避免不同的业务逻辑互相踩到同一把锁。其次检查 TTL 是否短到连本业务的正常耗时都无法覆盖。 - 高频问题之二:A 完成后 B 的锁被删了。这多半是 value 没有做唯一标识,或者解锁没有走 Lua 脚本。如果发现一批线程共用同一个 value,那基本可以断定误删锁已经在悄悄发生。
- 高频问题之三:日志里明明调用了释放锁,下一秒别的新客户端还是拿不到锁。可能原因:框架的看门狗还在异步续期,你手动释放后它又立刻续了一手;或者你用的是
unlink/del之外的奇怪命令。遇到这种问题,建议打开 Redis 的MONITOR模式,把针对这个锁 key 的所有命令打出来,一眼就能看出是否有多余的续期命令。
5.2 速查表:异常现象与排查方向
| 异常现象 | 可能原因 | 优先排查动作 |
|---|---|---|
| 加锁频繁返回失败 | key 设计碰撞、TTL 过短、业务热点集中 | 检查 key 前缀、业务 P99 耗时、压测冲突率 |
| 锁释放后对端仍处于临界区 | 业务执行超时、锁已自动过期 | 检查业务耗时监控、考虑续期机制 |
| 两个客户端同时进入临界区 | 主从切换丢锁、GC 停顿导致锁过期 | 检查集群故障转移记录、应用 GC 日志 |
| Redis 中锁 key 被提前删除 | 内存淘汰策略、手动误删 | 检查 maxmemory-policy、审计操作记录 |
| 解锁时报错或返回 0 | Lua 脚本版本不匹配、value 不匹配 | 核对脚本内容、查看客户端异常堆栈 |
| 锁 TTL 持续不变或越来越长 | 看门狗线程未停止、重复加锁 | 检查线程池、释放逻辑 |
5.3 怎么验证你写的锁是可靠的
很多团队写完锁只测功能和并发,完全没有故障演练。这里分享一套我实践下来性价比很高的验证方式:
- 并发互斥测试:用 20 个线程并发抢同一把锁,每个线程持锁后 sleep 一个随机时间,用一个全局 AtomicInteger 记录"同时在线持锁数量",跑 10 万次,断言最大并发持锁数始终为 1。这个测试能把最简单的误删锁问题暴露出来。
- 主从切换演练:在测试集群里手动触发一次 failover,同时并发抢锁,观察是否有两个客户端同时成功。这个测试能直观验证你的锁在高可用切换时是否安全。
- 业务超时测试:把业务逻辑人为拖到超过 TTL,观察第二个客户端是否能够进场,第一个客户端的最终行为是什么。这个测试决定你需不需要上幂等。
6. Redis 锁到底能不能上生产:一条判断标准
6.1 放心用 Redis 锁的场景特征
不少人对 Redis 分布式锁的态度从一个极端走到另一个极端:要么不管三七二十一全用,要么听说了主从丢锁就一概封杀。我更愿意给一个判断标准:
如果"锁失效导致两个客户端同时执行"的后果是你业务可以接受、且能通过幂等兜底处理的,那么 Redis 分布式锁完全够用;如果后果是资损级别的,那无论如何都要再拉一道闸。
具体来说,适合用 Redis 锁的场景通常是:定时任务防重复执行、本地缓存防击穿、活动资格领取防超发、订单状态机的并发更新保护。这些业务的特点是并发重复执行多一次,最多多算一个无用功、多一条无副作用日志,不会造成账实不符。
6.2 强一致场景的替代方案
如果业务对互斥性要求极高,比如库存扣减、账户扣款、订单唯一回调,我的建议排序大致是这样的:
- 数据库唯一约束或悲观锁。如果用关系型数据库,直接利用表的唯一索引或
SELECT ... FOR UPDATE。缺点是吞吐量有限、对数据库连接占用多,但正确性最有保障。 - etcd / ZooKeeper 分布式锁。它们的一致性协议能保证锁状态在多数节点上落盘后才算成功,主节点故障后不会出现锁数据丢失。代价是性能比 Redis 低,架构上多引入一套组件,不适合高并发短平快的场景。
- 数据库乐观锁(版本号)。适合更新场景,用
UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?保证并发控制。 - 如果只能用 Redis,至少做到:业务幂等 + 唯一键兜底 + 锁的 value 唯一 + Lua 原子解锁 + 关键写操作验证版本号。也就是说,把"锁失效"当成一个预期事件来设计,而不是假装它不存在。
6.3 我沉淀下来的几条实操建议
最后分享几条我在不同项目里踩坑后沉淀出的心得,不一定惊艳,但每一句都有血泪背景:
- 先从裸命令开始,再上封装框架。如果你不了解 SET NX、Lua、续期,直接引入再强大的分布式锁 SDK,出问题仍然无从排查。自己手写一次最小实现,再换成框架封装,心里完全两种底气。
- 锁的 value 永远用全局唯一 ID。不要在短命名上省事,因为解锁脚本依赖 value 做身份校验。
- 锁的 key 不要设计得太粗,也不要太细。太粗会把无关联的单都锁在一把 key 上,拖垮吞吐;太细则可能因为一个资源拆成几十个 key 失去互联互斥的意义。
- 解锁和续期逻辑一定要有日志打点。线上排查很多问题,最终靠的就是这两条日志。锁的获取、续期、释放、抢锁失败,每一个环节至少一行日志。
我在实际操盘分布式系统的几年里,对 Redis 分布式锁最终的态度可以浓缩成一句话:它是把很好用的小刀,但别拿它当手术刀。锁解决的是"低概率并发重复"问题,业务幂等和存储层约束才负责"绝对唯一"。代码评审的时候,多问一句"这个锁过期了会怎样",多问一句"主节点宕机了会怎样"。如果答案都是"最多多执行一次无妨,有幂等兜底",那放心用 Redis;如果答案是"会造成资损",那它就不是你的第一选择。工具箱里永远要有锁,但也永远要清楚,什么场景该拎出哪把刀。