ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从SETNX到Redisson:分布式锁演进与生产实践

2026/9/15 20:40:09 拓冰建站 浏览量
从SETNX到Redisson:分布式锁演进与生产实践 在微服务的世界里只要遇到多实例抢着修改同一个资源分布式锁几乎就是绕不开的话题。很多人第一次接触分布式锁是从 Redis 的 SETNX 命令开始的踩过几个坑之后又投向了 Redisson 的怀抱我也是这样过来的。这篇文章把我这些年踩过的坑、看过的源码、排查过的问题从最初 SETNX 的野路子写法到最终用 Redisson 固化出的一套完整方案按演进路线全部整理出来。如果你正在写定时任务、订单服务或库存服务那这篇内容值得你花十分钟看完。1. 为什么需要分布式锁以及它要解决的问题在单体应用里用 synchronized 或者 JUC 的 Lock 就能锁住线程。但一旦应用部署成多实例每台机器上的锁是各自独立的JVM 锁就管不到另一台机器上的线程了。这时候就需要一个大家都能看得见的公共协调者Redis 因为读写快、结构简单成了最常用的锁存储载体。典型场景是定时任务。我遇到过很多次线上定时任务重复执行比如凌晨的数据结算任务同时起了两个实例每个实例的 cron 都触发了任务结果同一批数据被处理了两遍账单对不上、短信重复发。还有一种场景是秒杀扣库存多个请求同时扣减同一个商品的库存如果没有锁或者锁的粒度不对超卖几乎是必然的。分布式锁要解决的就是让分布在不同节点的多个进程在访问同一临界资源时互斥生效。它要满足几个基本条件第一互斥性同一时刻只有一个客户端能持有锁第二安全性锁必然能被释放不能被死锁第三可重入性同一个客户端在持锁后还能再次加锁第四容错性当持有锁的节点挂了锁不应该永久失效。这个演进过程其实就是在不断给这把锁补上这几层能力。在实际生产里分布式锁不只是“加锁-解锁”这么简单的语义。真正难处理的是异常情况客户端拿到锁之后宕机了、锁过期了但业务还在跑、释放锁的时候误删了别人的锁。这些坑下面逐个展开。2. 第一代实现Redis SETNX 实现锁以及那些隐蔽的坑2.1 最原始的 SETNX 用法加锁、释放、问题老代码里经常见到这种写法// 加锁 Boolean result redisTemplate.opsForValue().setIfAbsent(lock:order:123, 1); if (Boolean.TRUE.equals(result)) { try { // 业务逻辑 } finally { // 解锁 redisTemplate.delete(lock:order:123); } }这段代码在单实例、演示场景下能跑通但一旦上了生产就会暴露问题。第一个问题就是没有过期时间如果业务里抛了异常或者线程被 kill 掉finally 里的 delete 没有执行这把锁就会永远留在 Redis 里后续所有请求都会认为锁被占着形成死锁。我在刚写分布式锁的头一年就因为这个原因凌晨三点被叫起来过。诊断过程其实挺简单业务代码里某个分支抛了 NullPointerExceptionfinally 还没来得及跑进程直接被 OOM Killer 干掉了锁就没释放。等到第二天全局检查发现 Redis 里躺着几十把这样的死锁 key。所以说从最开始的加锁方案锁就一定要有过期时间作为兜底这是分布式锁的第一条底线。这里顺带解释一下 SETNX 的全称它是 SET if Not eXists 的缩写也就是只有当 key 不存在时才设置成功。在现代 Redis 中更推荐直接用SET key value EX seconds NX这样一个原子指令而不是单独调 SETNX 再调 EXPIRE。2.2 为什么要给锁设置过期时间死锁问题给锁加上过期时间是为了防止持有锁的客户端意外崩溃导致锁得不到释放。就好比你在图书馆占了个座位但你人突然走了座位应该在一个时间窗口后被其他人自动释放而不是永远空着。如果在旧版 Redis 里直接写 setnx key value然后再写 expire key seconds这两步并不是原子操作。假设 setnx 执行成功了但在 expire 执行前 Redis 宕机或者网络闪断过期时间没有设置上去锁还是会变成永久锁。这个问题在 Redis 2.6.12 之前只能用 Lua 脚本把两步合并而在 Redis 2.6.12 之后官方提供了SET key value EX seconds NX这样一个原子指令来解决。所以正经的加锁应该写成// 直接在一条命令里同时指定 NX 和 EX Boolean locked redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(30));或者在 Lua 里if redis.call(set, KEYS[1], ARGV[1], NX, EX, ARGV[2]) then return 1 else return 0 end这里要注意一个容易踩的细节很多人会用redisTemplate.opsForValue().setIfAbsent(key, value)然后再单独redisTemplate.expire(key, timeout)。虽然大多数情况下两步很快就执行完了但一旦在中间出现 Redis 连接异常脚本卡在了第一步事情就变质了。我在代码评审时看到这种写法一律要求改成原子性的 setIfAbsent。2.3 锁过期时间设置问题业务执行超过锁过期时间还有一个经典的坑是锁的过期时间设得太短导致业务还没执行完锁就自动过期了。一旦锁过期另一个线程就获得了锁两个线程同时跑同一段业务那分布式锁就没有意义了。比如我把锁的过期时间设成 30 秒但某个批量处理接口跑了整整 60 秒。时间一到Redis 删除锁后面的请求立刻进来拿到新锁两个线程同时处理同一批订单数据就乱成一团。对 SETNX 这种简单的实现来说只能尽量避免业务耗时超过锁过期时间。可以把过期时间设置得足够大但谁也无法保证业务永远不会抖动。这也是 SETNX 锁最难受的地方没有自动续期能力。真正解决续期问题是后面 Redisson 做的最重要的一件事。2.4 误删他人锁的问题value 校验与 Lua 脚本如果只是直接 delete key还有一个隐蔽但极容易出的事故误删别人的锁。正常流程是 A 拿到锁执行业务业务超过了锁的过期时间锁自动过期B 拿到锁开始执行。此时 A 业务终于结束执行redisTemplate.delete(key)这把锁明明已经是 B 的了却被 A 给删掉了B 的临界区瞬间就不再安全。要解决这个问题加锁时在 value 里放一个唯一的标识比如 UUID 或者当前线程 ID释放锁时先判断 value 是否还是属于自己的值。如果一样才能删除。但判断和删除这两步必须是原子操作否则还是会有竞态。常见写法是 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应的 Java 代码可以写DefaultRedisScriptLong脚本的结果用 Long 接收判断是否为 1 来决定是否释放成功。我见过有人在 Java 里先get再delete中间窗口极小但理论上就是错误的永远不要赌线程切换的时间。提示有搜索热词是“分布式锁 value 为当前日期”千万别把 value 只设成日期字符串。如果两个线程在同一天内、在不同时刻想获取同一把锁value 有可能都是“2025-01-01”。一旦 A 在锁过期后 B 拿到了锁B 的 value 也是“2025-01-01”A 释放锁时判断会误判成“自己的”删除 B 的锁。value 的唯一性必须是每次加锁随机生成比如 UUID。2.5 SETNX 锁的“可重入”问题JVM 锁是天然可重入的但 SETNX 的锁默认不可重入。如果业务代码里同一个线程持锁后调用了另一个方法另一个方法又尝试获取同一把锁在 SETNX 语义下它会返回 false因为锁已经被自己占着于是可能死锁或者抛出异常。在没有重入机制时最稳妥的建议是尽量别写嵌套锁但业务多了嵌套一旦开了代码就埋雷。实际演进过程中工程师们会在 value 里存一个计数器或者用 ThreadLocal 记录当前线程持锁次数但实现起来很繁琐。Redisson 直接把可重入性内置了。3. 第二代Redisson 分布式锁的完整演进3.1 Redisson 是什么以及它为什么能解决问题Redisson 是一个 Java 客户端它把分布式锁做成了 JDK Lock 风格的体验API 长得和java.util.concurrent.locks.Lock很接近。你只需要这样用RLock lock redissonClient.getLock(lock:order:123); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }看起来简单但背后替你把上面 SETNX 方案里所有坑都填掉了。这也是为什么我强烈建议新项目不要再自己造 SETNX 锁的原因。Redisson 的锁并不是简单靠 setnx 一条命令实现的它内部使用 Lua 脚本完成加锁、续期、释放、可重入计数等一系列操作。对于 Java 开发者来说这些底层细节可以先不用管但对线上运维和排障来说了解内部机制很有必要。3.2 看门狗机制自动续期避免锁过期业务还在跑Redisson 默认的锁过期时间是 30 秒但它有一个叫“看门狗”(Watchdog) 的机制拿到锁之后如果业务还没有执行完后台会每隔 10 秒自动给对应 key 续期到新的 30 秒。这个续期动作会一直持续直到锁被主动释放。看门狗的实现原理是Redisson 在加锁成功后会启动一个定时任务定时任务每 10 秒执行一次检查锁的剩余过期时间如果锁还存活就调用 Lua 脚本把过期时间重新设置为 30 秒。如果当前线程持有锁并且还在运行就一直续。如果持有锁的客户端崩溃看门狗随进程消失锁自然在最多 30 秒后过期不会被永久占用。这个机制解决了 SETNX 锁最头疼的“业务执行超过锁过期时间”问题。你甚至不需要自己去算业务会跑多久只要锁内部有看门狗就能安全地自动续期。但要注意如果进程本身被阻塞或者网络分区看门狗可能无法续期锁仍然会过期这在分布式锁里是没法完全避免的。我在用 Redisson 时通常不会再手动设置 lock.leaseTime而是用默认的看门狗模式。一旦你手动指定了 leaseTime看门狗就不会生效锁到期就自动释放Renew 被禁用。比如lock.lock(10, TimeUnit.SECONDS)这种写法就代表锁固定存活 10 秒不会续期。如果你不确定业务时长千万别传 leaseTime。3.3 可重入锁与公平锁Redisson 提供的不同锁形态Redisson 的RLock和java.util.concurrent.locks.ReentrantLock一样支持可重入。也就是说同一个线程在持有锁后还能多次调用lock()内部会维护一个计数器每解锁一次计数减一直到计数为 0 才真正释放锁。这解决了我在 2.5 里提到的麻烦。除此之外Redisson 还提供了RedissonFairLock也就是公平锁。公平锁会按线程请求锁的顺序来分配锁先到先得不会发生“饿死”现象。默认的getLock是非公平的遇到高并发抢锁时会存在线程插队问题但大多数场景下非公平锁性能更好因为避免了排队唤醒的成本。如果对照 JDKFairLock也可以看做一个公平抢占锁但用在分布式场景时排队列表是存在 Redis 里的需要额外的内存和命令开销。所以除非业务明确要求顺序一般用非公平锁就够了。3.4 联锁与红锁多节点场景下的权衡Redisson 还有一个RedissonMultiLock可以把多个 RLock 组合成一把“联锁”使用时必须所有锁都拿到才算加锁成功。比如希望多个资源同时被锁定业务里同时依赖订单缓存和库存缓存就可以用联锁。这种做法比较重通常用在跨资源强一致性场景。真正硬核的是 Redlock也叫红锁。Redlock 是 Redis 官方提出在多个独立主节点上同时加锁的算法。因为单节点 Redis 可能出现主从切换导致的锁丢失问题A 在主节点加了锁主节点还没来得及同步到从节点就宕机了从节点升为主节点后没有这把锁B 就能加锁成功互斥性被破坏。Redlock 的思想是部署 N 个完全独立的 Redis 节点客户端向多数节点申请加锁只有在超过一半节点加锁成功并且耗时未超时才认为加锁成功。Redisson 提供了RedissonRedLock支持这种模式。不过 Redlock 本身在学术界和工程界都有争议它在异步复制、GC 停顿等场景下依然存在安全缺陷所以不少专家认为在绝大多数业务里不需要上 Redlock。我做系统设计时一般先评估主从切换概率和业务容忍度。如果必须要求极致互斥更倾向引入 ZooKeeper 或 etcd 这类强一致组件而不是复杂化 Redis。4. 从 SETNX 到 Redisson演进背后的设计思想4.1 原子性是第一生命线回顾整个演进过程最核心的关键点其实是“原子性”。加锁时如果只会 setnx 和 expire 分开做中间就可能出鬼释放锁时如果只会 get 和 del 分开做也可能出鬼。Redisson 的所有加锁释放逻辑全部通过 Lua 脚本在 Redis 服务端原子执行从根上消除了竞态窗口。这一点在写代码时容易被忽略但排查问题时却最痛。我回忆起一次线上事故就是一个同事用setIfAbsent后单独expire日志打印显示 set 成功了但 expire 报错了导致锁永久占住大量请求一直在等锁超时最终一堆接口全挂。后来我把项目里所有分布式锁实现换成了 Redisson再没有出过同类状况。4.2 安全性设计从随机 value 到线程身份绑定SETNX 方案里我们通过在 value 里放随机字符串来防止误删别人的锁。Redisson 把这个随机字符改进成了一个名为“客户线程 ID”的唯一标识。它的底层 Lua 脚本会判断锁持有者的 hash 是否等于当前客户端 ID 和线程 ID同时维护重入次数。这样既能保证不会误删别人的锁又能支持可重入。很多人刚开始学分布式锁时会疑惑为什么一个简单的锁需要这么复杂。其实可以理解成你要进入一个共享的房间房间里只有一个钥匙。你把钥匙拿走后必须确保你出门时只能还回自己那一把钥匙而不是把别人新拿走的钥匙也顺手丢掉。所以每把钥匙必须刻上你的名字。4.3 加锁模式对比SETNX 裸写、Lua 脚本、Redisson维度SETNXexpire 裸写SETNXLua 脚本Redisson原子性差两步窗口竞态好Lua 原子执行好Lua 原子执行过期兜底缺乏易死锁可加过期默认 30s看门狗续期释放安全容易误删需定义随机 value 判断内置线程标识判断可重入不支持需自己维护计数器原生支持续期机制无无看门狗自动续期实现成本低但越用越错中能应付简单场景高但省心稳定从我个人的工程经验看如果项目里 Redis 操作已经用了 Redisson那直接用 RLock 是最省事的。如果项目只用了 Spring Data Redis不想引入新依赖也可以单独封装一个基于 Lua 脚本的工具类但要接受它缺少看门狗和可重入这一层。4.4 为什么说 Redisson 不是银弹任何技术方案都有边界。Redisson 解决的是单 Redis 主节点下的绝大多数问题但它并不能保证跨节点的强一致。Redis 主从切换时锁记录可能丢失这一点 Redisson 也无法避免。如果你的业务对丢锁零容忍比如金融交易系统中涉及真金白银的资金操作最好把锁也放到强一致组件里比如 ZooKeeper 的临时节点锁或者 etcd 的租约锁。还有一点Redisson 的看门狗是在客户端进程内运行的定时线程。如果 JVM 发生了长时间的 Full GC时钟停顿超过锁超时时间看门狗来不及续期锁也照样会过期。这类问题没有完美解只能在业务设计上想好兜底策略。5. 生产环境避坑指南从参数配置到问题排查5.1 Redisson 的 Codec 选择StringCodec 与序列化陷阱在 Spring Boot 项目里用 Redisson 时Codec 的选择很容易踩坑。Redisson 默认使用Kryo5Codec或MarshallingCodec版本不同默认不一样用来序列化锁 key 和 value。因为锁相关的 key 一般绑定在 Redis 集群里客户端的操作命令需要 hash tag 来决定 key 在哪个槽位key 的序列化方式会影响最终 Redis 里存储的字节形式。搜索热词里频繁出现“redisson stringcodec”和“redisson codec”说明很多人遇到过反序列化异常。最典型的现象是用 Redisson 加锁然后在另一个地方用 RedisTemplate 去查看锁信息发现 key 名前面带了一串奇怪的前缀字符甚至直接报错。我在生产环境通常这样处理Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); config.setCodec(new StringCodec()); RedissonClient redisson Redisson.create(config);如果统一使用 StringCodec加锁的 key 就会以可读的字符串形式显示方便运维排查。但要注意如果你在同一个 RedissonClient 里既要存 JSON 结构数据又要加锁为了锁 key 的可见性可以单独为锁创建独立的 RedissonClient避免序列化冲突。更常见的做法是锁 key 和业务缓存 key 使用不同客户端不让双方互相污染。5.2 锁超时与看门狗配置什么时候该手动指定 leaseTime我建议绝大多数业务直接用默认的lock()不要传 leaseTime。但如果你明确知道业务最长执行时间而且希望锁一定在某个时间后释放即使持有者还在运行也要释放那就可以调用lock(10, TimeUnit.SECONDS)。比如一个刷数据任务最多 10 秒就能完成如果 10 秒后还没完成更希望锁释放让另一个实例顶上来避免阻塞这时候手动指定 leaseTime 是合理的。看门狗默认的续期周期是锁过期时间的 1/3也就是默认 30 秒过期时每 10 秒续期一次。这个值可以通过配置lockWatchdogTimeout来调整。如果你把锁的默认过期时间调大后台续期周期也跟着变大。需要留意的细节是看门狗只是一个定时任务它依靠线程池调度来实现如果应用里通用线程池被耗尽续期任务也会受影响。大多数情况下不必修改默认配置。5.3 定时任务重复执行的排查实例value 为当前日期的误用有一个非常典型的线上问题值得单独拎出来讲。某业务团队在定时任务里用 Redis 分布式锁防重为了防止线程误删他们把锁的 value 设成了“当前日期”比如task:summary:lock的 value 是2025-07-21。他们想表达的意思是只有当天的任务才能拿这把锁锁过期后第二天才能重新拿锁。这个思路听起来没问题但实际上埋了雷。当天的业务从 00:00:01 开始抢锁第一个线程拿到锁value 是2025-07-21。锁过期时间是 30 秒如果业务在 30 秒内没跑完锁过期。第二个线程在同一秒或者下一秒又拿到锁value 仍然是2025-07-21。当第一个线程终于跑完进入解锁流程它会去判断 Redis 里的 value 是否等于自己的 value发现是等于的因为都是日期字符串于是把它删除了。删除的这把锁可能是第二个线程还在使用的锁。两个线程同时操作同一批数据的风险由此产生。不要把 value 设计成一个有公共交集的值。锁的标识必须每次加锁时都生成独立的 UUID或者至少使用机器 ID 线程 ID 随机数。我在代码里习惯用IdUtil.fastSimpleUUID()生成并把它放在 ThreadLocal 或方法变量中等解锁时再拿出来校验。如果业务语义上真是“每天只能跑一次”不要在同一个锁 value 上搞幺蛾子应该用时间维度去设计 key比如task:summary:2025-07-21。key 本身就带日期value 则用会话级别的唯一值。5.4 常见问题速查表问题现象可能原因解决方案锁一直拿不到Redis 里 key 长期存在加锁时没有设置过期时间或 setnx、expire 未原子执行使用 SET NX EX 原子命令或升级 Redisson锁自动过期后两个线程同时进入过期时间设置过短业务执行超过锁时长启用看门狗自动续期或者预估足够大的 leaseTime释放锁时误删别人的锁没有校验 value 唯一性用 UUID 做 value配合 Lua 校验后删除同一线程嵌套加锁失败SETNX 锁不可重入使用 Redisson 的可重入锁Redisson 加锁后 key 乱码Codec 配置不一致锁独立的 Client 使用 StringCodec主从切换后锁丢失单节点 Redis 异步复制天然存在评估强一致组件必要时使用红锁定时任务在多个实例重复执行锁 key 没有加业务唯一维度或锁未生效确认锁的 key 设计、过期时间并检查是否真加了分布式锁6. 最后再分享一个调试小技巧如果你在本地排查分布式锁问题可以先登进 Redis 查看锁 key 和 TTLredis-cli -h 127.0.0.1 -p 6379 get lock:order:123 uuid-value ttl lock:order:123 (integer) 21get能看到锁 value 是否属于当前线程ttl能看到剩余的过期时间。这样配合日志能很快判断出是没释放、还是误删、还是过期时间太短。调试 Redisson 时也可以开 debug 日志看到续期动作但生产环境不建议频繁打这种日志。我个人在实际操作中最深的体会是分布式锁是一个“看着简单用起来到处都是坑”的东西。早期我用 SETNX 踩过的坑在 Redisson 里基本都被框架填平了。但框架并不是万能的你依然要理解它背后的机制知道主从切换的限制清楚什么场景该用什么锁。如果你也被锁问题折磨过优先看看项目里是不是还在用裸 SETNX如果是找一个版本安稳的时间段把锁实现切到 Redisson 上吧。