Redis分布式锁实战:三个真实事故与Lua脚本原子性改造
开门见山,先说结论:Redis 分布式锁这东西,网上教程一抓一大把,但真正拿到项目里用,能一次写对不出问题的,说实话不多。我最近在维护一个订单调度系统,表面上看就是一个普通的后端服务,结果在分布式锁上接连栽了三个跟头,每次都是线上告警把我叫醒的那种。今天不聊理论,就着这三个真实场景,把 Redis 分布式锁和 Lua 脚本的实战细节、踩坑过程原原本本扒一遍。
先交代一下背景,这套系统是 Spring Boot 架构,注册中心用的 Nacos,服务部署了三个节点,Redis 用的 6.2 集群版。业务本身不算复杂,但涉及定时任务、库存扣减和跨服务状态同步三条链路,刚好把分布式锁的典型问题都踩了个遍:锁的原子性、锁的误删、锁超时与业务时长不匹配。三个问题听着是同一个东西,但每个的坑法都不一样,排查起来一个比一个隐蔽。
这篇文章适合谁看?如果你的项目里已经用了 RedisTemplate 做分布式锁,或者正准备引入、只是停留在 setnx 加 expire 这种最简单写法,那这篇文章应该能帮你省下不少半夜爬起来看日志的时间。我会把每个场景的代码、Redis 命令、异常现象和修复方案都贴出来,你可以直接照着改。
1. 三个真实场景的完整复盘
1.1 场景一:定时任务重复执行,value 用的当前日期
这个场景最早出现。系统里有一个每小时跑一次的结算任务,负责把上一个小时的订单数据做汇总。当时为了确保三个节点只有一个节点执行,团队成员用 RedisTemplate 写了个最原始的分布式锁,核心逻辑就是 setIfAbsent。
这里有个细节,value 存的是当前日期字符串,比如 2025-01-12。为什么这么设计?因为我们的业务结算按天隔离,如果今天是 1 月 12 日,锁的 value 就是 1 月 12 日,任务执行完删除锁之前,会再校验一次 value 是不是当天的值。
这个场景正常跑了好几个月,一直没出问题。真正出问题是在一次发布窗口:运维同学把一个节点优雅停机,但停机前这个节点正在执行结算任务。锁还没释放,另外两个节点已经启动了,其中一个节点拿到锁之后开始执行新一轮结算,而老节点因为 GC 停顿,finally 块里的 delete 延迟了大概 3 秒才执行。结果就是,新节点刚拿到锁还没跑完,老节点的 delete 直接把新节点的锁删了。第三个节点瞬间也拿到锁进来执行,三个节点同时跑结算任务,数据重复汇总,账单金额翻了三倍。
这个场景暴露的第一层问题是:释放锁之前没有校验 value 是否还是自己的。网上所有教程都强调过这一点,但实际项目里因为加锁和释放锁的逻辑分散在两个方法里,写着写着就漏了。而这恰恰是标题里提到的“value 为当前日期”这个设计的初衷——拿日期做 value,本来是想在释放前做一次持有者校验的,但代码里只校验了日期等于当天,根本没有校验这个锁是不是自己这个节点加的。
1.2 场景二:库存扣减,先查库存再扣减的非原子问题
第二个场景来自一个秒杀活动。业务逻辑很简单:用户下单时先查 Redis 里的库存 key,如果大于 0 就执行扣减,然后落库。当时用的是 get 加 decrement 两步操作。
这个逻辑从单机角度看完全没问题,但并发一上来就崩了。三个节点同时收到请求,同时 get 到库存为 1,同时判断大于 0,然后同时 decrement。虽然 Redis 是单线程执行 decrement,最终库存变成 0 不会变负数,但三个请求都走入了 createOrder,生成了三笔订单,实际库存只有 1 件。这就是典型的“先查后改”竞态条件。
当时第一个想法是加分布式锁,把 get 和 decrement 包在锁里。但仔细一想,库存扣减这种操作如果每次都走分布式锁,锁的获取和释放开销太大,而且一旦锁服务抖动,整个下单链路就全堵住了。后来改成用 Lua 脚本把“查库存 + 扣库存”两步合并在 Redis 服务端原子执行,才从根本上解决了问题。
1.3 场景三:业务执行时间超过锁过期时间,锁被提前释放
第三个场景是最隐蔽的。系统里有一个订单状态同步任务,A 服务生成订单后通过 MQ 通知 B 服务处理,B 服务处理完需要回调 A 服务更新状态。为了保证同一笔订单不会被重复处理,在 B 服务收到消息后先抢一把锁,锁的 key 是订单号,过期时间设了 10 秒。
问题出在一笔异常订单上。B 服务在处理这笔订单时,需要调用一个外部仓储接口,结果那个接口超时了,B 服务内部重试了 3 次,总共耗时 25 秒。10 秒锁过期后,MQ 重试机制又投递了一条相同消息,另一个节点顺利拿到锁进来处理。此时两个节点在同时处理同一笔订单,一个在等外部接口返回,一个已经开始写订单状态。最终结果是订单状态被后完成的那个节点覆盖,数据不一致。
这个场景和前两个不一样,前两个是“锁没用好导致并发进入”,这个场景是“锁本身过期了,导致锁形同虚设”。后面我会专门讲怎么处理锁超时和业务执行时间不匹配的问题。
2. 从 setnx 到 Lua 脚本:原子性是王道
2.1 setnx 加 expire 的两步陷阱
第一个场景里用的 setIfAbsent,对应 Redis 命令就是 SET key value NX,这个命令本身是原子的,没问题。问题出在很多老代码还在用 SETNX 加 EXPIRE 两条命令组合。SETNX 执行完还没执行 EXPIRE,进程就挂了,锁变成了永不过期的死锁。Redis 官方早就推荐直接用 SET key value NX EX seconds 一条命令搞定,Spring 的 setIfAbsent 就是这个封装。
但这个场景真正的问题不在加锁,在释放锁。如果删除锁的逻辑不校验持有者,任何节点都可以删掉别人的锁。通用的校验方式是:删除前先 get 锁的 value,如果是自己的唯一标识才执行 delete。但在 Redis 里,get 和 delete 是两条命令,非原子,中间依然有被其他节点干扰的可能。要真正做到原子删除,必须用 Lua 脚本。
当时我们先把释放锁的逻辑改成了这样:
这段脚本的意思是:比较锁的 value 是否为当前持有者,是才删除。Spring 里可以用 DefaultRedisScript 封装执行。
2.2 原子性在并发场景里意味着什么
要深入理解 Lua 脚本的作用,得先想明白一个问题:为什么 Redis 单线程模型下还会有并发问题?
我的理解是,Redis 的原子性保证的是单条命令执行的原子性,而不是一组业务操作的原子性。你用 GET 查库存是原子的,用 DECR 扣库存也是原子的,但“查到库存大于 0”和“执行扣减”之间的间隙,Redis 并不会帮你阻塞其他客户端。这时两个客户端都完成了 GET,都看到库存是 1,然后先后执行 DECR,结果就是超卖。
Lua 脚本之所以能解决这个问题,是因为 Redis 从 2.6 版本开始就把 Lua 脚本当作一条命令来执行。脚本里的所有 Redis 操作在脚本执行期间不会被其他命令插入,Redis 服务端会保证整段脚本的原子性。这就像你把“检查库存”和“扣减库存”两件事打成一个大包,一次性提交给 Redis,Redis 要么不干,要么一口气干完,中间不接其他活。
一个生活化的类比:你去 ATM 取钱,插卡、输入密码、查询余额、取款、扣减余额,这些步骤如果是分开的命令,中间可能被插队或者中断。如果 ATM 把整个取款流程封装成一个“取款事务”,一步到位执行完,别人就没办法在你操作到一半的时候动你的账户。
2.3 Lua 脚本在分布式锁中的两种核心用法
分布式锁场景里,Lua 脚本主要干两件事。
第一件就是上面说的原子释放锁。为什么必须原子?因为“先 get 校验再 del”的两步操作存在时间窗口。极端情况如下:节点 A 持有锁,值是一个 UUID,执行 get 校验,确认锁是自己的;但在执行 del 之前,锁因为过期时间到了被 Redis 自动清除,节点 B 立刻 set 成功拿到锁;此时节点 A 才执行 del,把 B 的锁删了。这个窗口极小,但线上就是这么邪门,越是极端情况越容易触发。Lua 脚本把“get 比较 + del”放到服务端原子执行,就不会给其他节点插队的机会。
第二件是组合业务操作,比如库存扣减。我们把“判断库存是否充足 + 扣减库存 + 返回结果”整体写进 Lua 脚本,整个过程原子完成,单机 Redis 下彻底消除超卖。脚本很简单:
注意几个细节:get 返回的是字符串,必须用 tonumber 转数字;第一个参数返回 -1 表示 key 不存在,0 表示库存不足,1 表示扣减成功。调用方根据返回值决定是否创建订单。
3. 三个场景逐一拆解,内含完整代码和命令
3.1 定时任务锁的最终改造:唯一标识 value 加 Lua 释放
回到第一个场景。我们当时的 value 用的是当前日期字符串,这个设计有个致命问题:三个节点同一天加的锁,value 完全相同,光靠比较 value 无法区分是不是自己的锁。改成用 UUID 作为每个加锁操作唯一标识是必须的。加锁时生成 UUID,释放时带上 UUID 走 Lua 脚本校验。
这是最终版的加锁工具类核心逻辑:
注意两个地方。第一,DefaultRedisScript 的 resultType 声明为 Long.class,因为脚本返回的是 del 的结果或者 0。第二,redisTemplate.execute 的泛型参数,老版本 Spring Data Redis 会有序列化问题,这个我在第四章会详细说。
改造完成之后,第一场景的问题就彻底解决了。即使老节点的 finally 块延迟执行,魔改后的释放逻辑比对发现锁的 value 不是自己的 UUID,直接返回 0 不删除,不会误伤新节点的锁。
3.2 库存扣减 Lua 脚本的完整实现与参数传递
库存场景的 Lua 脚本,上面给了核心代码。这里补全 Java 调用层面完整的写法,因为参数传递的坑也不少。
调用时传一个 KEYS,ARGV 不需要。如果脚本里需要参数,比如扣减数量、最大可扣数量,可以把它们放在 ARGV 列表里传进去。这里有个容易犯的错:execute 方法的最后一个参数是可变参数 Object...,如果脚本用了 ARGV,务必把 ARGV 的值按顺序传进去,别把 KEYS 和 ARGV 搞混了。
tonumber 这个转换为什么不能省?因为 Redis 的 get 命令返回的一定是字符串,即使你存进去的时候是 Integer,取出来也是字符串。Lua 里字符串和数字做比较,类型不一致会直接报错或者结果不符合预期。所以第一步必须是 tonumber。
如果 key 不存在,redis.call('get', KEYS[1]) 返回的是 false,在 Lua 里 not false 为 true,所以进入第一个分支返回 -1。这个分支很重要,否则脚本会在 tonumber(false) 上报错。
3.3 锁续期:用看门狗思路解决业务超时问题
第三个场景,业务执行时间超过锁过期时间,有两个改造方向。
方向一:把锁的过期时间调大,比如从 10 秒调成 60 秒。这能解决一部分问题,但治标不治本。外部接口超时可能是分钟级的,调再大也扛不住。而且锁过期时间越长,节点挂掉后锁被占用的时间就越长,对系统可用性影响越大。
方向二:给锁加续期机制,类似 Redisson 的看门狗(Watchdog)思路。锁快过期时,如果业务还没执行完,自动给锁续期。续期不是无限续,可以设置一个合理的上限,比如最多续到 5 分钟,超过这个时间直接放弃处理并告警。
我们不引 Redisson 的话,自己写续期也不复杂。核心是用一个定时任务周期性地检查锁的剩余过期时间,如果小于阈值,就执行 EXPIRE 命令给锁续期。但这里有个前提,必须确认锁还是自己的,所以续期操作也要用 Lua 脚本。
写一个简单的续期守护线程:
这个方案有几个细节需要打磨。续期线程什么时候关闭?业务执行完释放锁之后一定要 shutdown 掉,否则线程一直跑着,还会给已经释放的锁续期——当然 Lua 脚本里的持有者校验会拦掉这种情况,但线程本身不能泄露。其次,续期线程是单机线程,如果执行续期的这个节点本身就卡死了,续期自然停止,锁会在过期后被其他节点获取,这种极端情况只能靠告警兜底。
考虑到我们当时的业务场景,最终没有引入完整的看门狗,而是选了一个折中方案:锁过期时间从 10 秒调到 30 秒,同时给外部接口调用加了一个 5 秒的熔断,让业务最长执行时间不超过 20 秒。这样即使最坏情况发生,30 秒的过期时间也足够覆盖。但这只是针对那个特定接口的优化,如果业务时间不可控,还是老老实实上续期机制。
4. 代码之外的坑:序列化、超时重试、环境差异
4.1 RedisTemplate 序列化问题导致 Lua 脚本值比对失败
这个坑藏得很深,排查了我一个下午。现象是:加锁成功,但释放锁时 Lua 脚本里的 get 比对始终不相等,锁一直删不掉,直到过期时间到了才自动释放。
原因出在 RedisTemplate 的序列化器上。项目中 RedisTemplate 默认用的是 JdkSerializationRedisSerializer,value 会以二进制形式存储。当你 setIfAbsent("lock:key", "uuid", ...) 存进去的是一个序列化后的二进制对象,但你在 Lua 脚本里执行 redis.call("get", KEYS[1]),返回的是字符串。拿"uuid"这个字符串去和二进制序列化后的内容做比较,当然不相等。
解决办法有两个。一个是改 RedisTemplate 的 value 序列化为 StringRedisSerializer:
另一个是直接用 StringRedisTemplate 来操作锁相关 key,因为它的 value 序列化器默认就是 StringRedisSerializer,不需要额外配置。我推荐后者,因为改动小,不会影响系统里其他用默认序列化器缓存的业务数据。
4.2 Lua 脚本执行超时与 Redis 阻塞风险
Lua 脚本在 Redis 里是原子执行的,这意味着脚本执行期间,Redis 不会处理任何其他命令。如果脚本写得有性能问题,比如在脚本里循环遍历一个大集合,或者执行了 KEYS 这种全量匹配命令,整个 Redis 实例会被卡住,所有读写请求全部阻塞。
我们当时在调试脚本时不小心敲过一个 redis.call("keys", "*"),本地测试没感觉,一上测试环境整个 Redis CPU 就飙到 100%,所有业务接口全部超时。吸取教训之后,所有线上脚本都遵循两个原则:第一,脚本内的操作数量控制在个位数,不写循环;第二,脚本涉及的 key 必须是确定的、通过传入 KEYS 指定的,不作范围查找。
另外,Spring 执行 Lua 脚本默认走 EVAL 命令。有些 Redis 集群版本对 Lua 脚本的限制严格一些,比如要求所有 key 在同一个 slot,跨 slot 的脚本会直接报错。这在我们用的 Redis 集群版上没遇到,但如果是自建分片集群,需要确认 key 的设计是否满足这个约束。
4.3 加锁超时和重试策略
分布式锁不是只有加锁和释放锁,业务代码里拿不到锁的情况怎么处理,同样重要。很多项目里的写法是拿不到锁直接抛异常,或者静默跳过。这两种都太粗暴。
更合理的做法是设置一个合理的等待时间。比如定时任务场景,如果拿不到锁说明其他节点正在执行,这时候直接退出本次任务就行,不需要重试。但库存扣减场景不太一样,用户下单等不起,拿到锁失败应该快速返回失败结果,或者走一个短暂的重试逻辑,比如 100 毫秒间隔重试 3 次。
这里还有一个细节,重试前可以用 getExpire 查一下锁的剩余时间,如果剩余时间还很长,说明持有者可能正在执行一个长任务,这时候重试大概率还是失败,不如多等一会儿。如果剩余时间很短,说明锁马上就过期了,可以快速重试。
4.4 Docker 部署和环境差异带来的坑
项目里 Redis 是用 Docker Compose 部署的,测试环境和生产环境的 Redis 版本不一致,也踩过一次锁不生效的坑。原因是测试环境的 Redis 版本是 5.x,生产是 6.x,有些命令在 5.x 上行为有细微差异。具体表现在 SET key value NX EX seconds 这条命令上,5.x 的行为没有问题,但当时项目里有一段老代码用的 SETNX 配合 EXPIRE,在 5.x 上偶尔会出现锁不生效的现象,升级到 6.x 后稳定很多。
这不是我负责的模块,但排查时顺手整理了生产环境 Redis 部署的几个要点:开启持久化避免重启丢数据、设置 maxmemory 防止内存爆掉、给锁相关的 key 加上统一前缀方便排查。用 Docker 部署时注意,redis.conf 里的 appendonly yes 必须配上,否则 Redis 容器重启后所有锁数据凭空消失,瞬间可能出现多个节点同时持有“同名锁”的假象。
拉取 Redis 镜像时也注意一下,官方镜像名称就是 redis,tag 最好明确到小版本,比如 redis:6.2.5。不要用默认的 latest,这个坑很多人踩过:本地是 6.2,线上是 7.0,命令行为差异导致的诡异问题排查起来非常耗时。
5. 常见问题速查表与排障思路
5.1 分布式锁高频问题一次说清
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 锁删除不掉,直到过期才释放 | value 序列化方式不一致 | 检查 RedisTemplate 序列化器,对比 Lua 脚本中 get 返回的字符串 | 改用 StringRedisTemplate 或统一 String 序列化 |
| 多个节点同时进入临界区 | 释放锁时未校验持有者,或 get+del 非原子 | 查看锁释放代码是否有 Lua 校验 | 使用带持有者校验的 Lua 脚本释放锁 |
| 锁提前释放导致并发问题 | 业务执行时间超过锁过期时间 | 查看业务耗时和锁过期时间配置 | 调大过期时间或实现锁续期机制 |
| Redis 实例 CPU 飙升 | Lua 脚本内执行了 keys 或大循环 | 检查慢日志和脚本内容 | 重写脚本,避免范围查找和循环 |
| 锁 key 残留导致后续加锁失败 | 未设置过期时间或节点宕机 | 检查 Redis 中锁 key 的 TTL | 统一使用 set ex nx 原子加锁 |
| Redis 重启后锁全部丢失 | 未开启持久化 | 检查 appendonly 配置 | 开启 AOF 持久化 |
5.2 从告警到定位的三个排查步骤
如果线上出现疑似分布式锁问题,我的排查顺序是这样。
第一步,看 Redis 里锁 key 的当前状态。用 GET lock:xxx 看 value 是谁的,用 TTL lock:xxx 看还剩多少秒过期。如果 value 对应的节点已经不存在了,但 TTL 还很长,说明是有节点持锁后异常退出,锁没释放,等待过期即可。如果 TTL 已经过了但业务还在跑,那就是锁过期时间和业务时长不匹配。
第二步,查业务日志里加锁和释放锁的时间戳。正常的流程应该是先加锁、执行业务、释放锁,时间线是直的。如果释放锁的时间点明显早于业务结束时间点,说明锁被提前释放了,要么是误删,要么是过期。
第三步,统计并发进入的业务日志数量。如果同一个业务 key 在同一时间窗口内出现多份执行日志,基本可以确认锁失效。
5.3 三道分布式锁面试题背后的真实考点
顺带聊一下,这个项目做完之后,我对网上常见的几道分布式锁面试题有了新的理解。
第一题:“Redis 分布式锁怎么实现?”很多答案能背出 setnx、expire、UUID 这些关键词,但面试官真正想看的是你有没有处理过释放锁的原子性问题。把 Lua 脚本拿出来讲,再结合项目中误删锁的实际案例,说服力完全不同。
第二题:“Redisson 的看门狗机制了解吗?”这个问题的核心是考你对锁过期时间和业务执行时间不匹配的理解深度。没有在真实项目里踩过这个坑,大概率只能复述概念,说不出为什么要续期、续期失败怎么办。
第三题:“Redis 分布式锁和 Zookeeper 分布式锁有什么区别?”这题表面考技术对比,实际考的是你对 CAP 理论的理解。Redis 是 AP 模型,主从切换时可能丢失锁;Zookeeper 是 CP 模型,锁的可靠性更高但性能略差。如果项目对数据一致性要求极高,Redis 锁确实不是最优解。
这些问题都能在这三个实战场景中找到对应的影子。面试的时候不是背题,而是把你踩坑、排查、修复的过程讲清楚,这比任何标准答案都有说服力。
6. 我对 Redis 分布式锁最终的使用心得
写了这么多,最后聊点我自己的体会。
分布式锁这个东西,代码量很少,但每个细节都能挖出问题。从最初的 setnx 加 expire,到 set ex nx 原子加锁,再到 Lua 脚本原子释放,最后到锁续期机制,每一步都是在真实线上事故里逼出来的。如果你还在用最原始的写法,我建议至少先把释放锁的 Lua 校验补上,这一步能堵住大多数误删锁的问题。
另外,Lua 脚本在 Redis 里的原子性是一把双刃剑。它确实能解决竞态问题,但也意味着脚本不能太复杂,不能做耗时的操作。我的建议是,脚本里只放必要的 Redis 操作,业务逻辑尽量留在 Java 层,这样既保证了关键操作的原子性,又不会因为脚本阻塞 Redis 影响其他业务。
最后再分享一个小技巧:调试分布式锁相关问题,打开 Redis 的 monitor 命令行,实时观察锁 key 的读写时序,比看日志直观得多。在测试环境执行 redis-cli monitor,然后触发一次业务请求,你就能看到加锁、查锁、释放锁的完整命令流,任何异常的先后顺序都会暴露出来。这个方法我用了很多次,每次都能迅速定位问题。
Redis 分布式锁不是银弹,但它确实是目前最简单、最经济、可落地性最强的分布式互斥方案。只要理解了它的边界,知道它在极端情况下的失效模式,并且做好兜底,完全可以在生产环境放心使用。