从SETNX到Redisson:Redis分布式锁的演进与实战避坑指南 📅 发布时间:2026/9/16 2:16:41 👁 浏览次数: 1. 从 SETNX 到 Redisson分布式锁到底在解决什么问题先说个最直白的场景你有个订单服务库存只有 10 件结果同一秒涌进来 20 个下单请求。单机时代一个 synchronized 就能锁死临界区但系统一拆成多实例每个实例各持一把锁谁也锁不住谁超卖就来了。这时候分布式锁就该登场了。它的核心诉求就一句话让多个进程或者多台机器在访问同一个共享资源时彼此互斥。你可以把它理解成整栋楼只有一把厕所钥匙谁拿到谁进用完必须还回来而且钥匙只有一把不会出现两个人同时蹲同一个坑的情况。分布式锁的实现方案不少数据库、ZooKeeper、etcd 都能做但现实中用得最广、聊得最多的还是 Redis。原因也简单Redis 快单线程模型天然适合做原子操作而且公司里基本都有现成的 Redis 集群不用额外引入一堆基础设施。但快归快Redis 实现分布式锁的坑是真的多。早期大家直接用 SETNX后来发现各种边界问题又引入了 SET NX EX、Lua 脚本、RedLock再到后来干脆用 Redisson 封装好的 Lock 接口。这一路演进踩过的坑我一个个说清楚。2. 第一代方案SETNX 的“看似简单”和它的致命伤2.1 SETNX 的基本用法SETNX 的全称是 SET if Not eXists语法特别简单SETNX key value如果 key 不存在就设置成功返回 1如果 key 已经存在设置失败返回 0。用这个命令做分布式锁的思路非常直白SETNX lock_order_1001 1 # 返回 1说明拿到锁 # 返回 0说明别人持锁继续等待拿到锁的线程执行业务逻辑处理完了用 DEL 释放锁DEL lock_order_1001这个方案有什么问题我都不用细想你光看这个流程就能感觉到一股“裸奔”的味道。核心痛点有三个一个比一个致命。2.2 第一个坑死锁——锁永远不释放如果线程在拿到锁之后、执行 DEL 之前突然崩了、被 kill 了、或者 Redis 连接断了那这个 key 就永远留在 Redis 里了。后面所有线程再 SETNX 都是返回 0整个系统的这段时间内所有需要这把锁的业务全部卡死而且不会自动恢复。这就相当于上厕所的人晕在里面外面的人只能在门口等到天荒地老。解决办法也简单加过期时间。最早的尝试是两条命令组合SETNX lock_order_1001 1 EXPIRE lock_order_1001 30但这两条命令不是原子的。想象一下这种时序SETNX 执行成功线程正要执行 EXPIRE这时候 JVM 进程突然被 kill 了EXPIRE 没执行。死锁问题照样存在——你只是把“先拿锁再崩溃”的情况变成了“SETNX 后、EXPIRE 前崩溃”的情况窗口变小了但没有根除。所以 Redis 后来提供了带过期时间的原子命令SET lock_order_1001 1 NX EX 30这个命令保证设置 key 和设置过期时间作为一个原子操作完成才算是把死锁这个最大的坑填上了一大半。这也是为什么现在你几乎不应该再单独用 SETNX 命令做锁除非你是要兼容特别老版本的 Redis。2.3 第二个坑业务执行时间超过锁过期时间锁设了 30 秒过期结果业务跑了 40 秒。到 30 秒的时候lock key 自动过期了这时候另一个线程 B 成功 SET 了同一个 key拿到了锁。你以为自己还持有锁还在写共享资源但实际上你已经不是唯一的持锁者了。两个线程同时在临界区干活锁形同虚设超卖、重复扣款、库存错乱全出来了。这不是什么极端情况只要是慢查询、外部接口调用、大批量数据计算都非常容易把锁活活“拖过期”。2.4 第三个坑误删别人的锁这个坑和第二个坑经常叠加出现。线程 A 的锁过期了线程 B 拿到锁开始干活。结果 A 的业务终于跑完了执行 DELETE lock_order_1001把 B 的锁删了。这下局势彻底失控B 以为自己还持锁C 也能 SET 成功拿到锁三个线程同时在临界区干活。数据不乱才怪。有人说那我删除之前先 GET 一下看看 value 是不是自己的是才删。但这又要面临“判断 删除”两步操作不是原子的老问题。你必须用 Lua 脚本把“比较 value 和删除 key”做成一个原子操作才能避免误删if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这算是一个能用的方案但手动管理 value、过期时间、续期复杂度已经上来了。每个业务方都得自己复制这段逻辑一旦写得不一样排查问题的成本剧增。2.5 为什么 SETNX 方案还能在面试里出现不是因为它现在好用而是因为它能很好地考察候选人对“分布式锁到底要考虑哪些边界问题”的理解。面试官看你回答 SETNX会接着问死锁怎么办过期时间设多少业务超时怎么办误删怎么办续期怎么做一条线问下来答得越深入越说明你真正处理过并发场景。在实际生产项目中纯 SETNX 方案几乎已经被淘汰了。如果你要说用 Redis 做分布式锁至少得往 SET NX EX Lua 脚本 随机 value 这个程度去写才勉强有点说服力。3. 演进中间态SET 原子命令 Lua 脚本的“手动挡”方案3.1 一个还算规范的手写锁长什么样再回答那个经典问题不借助 Redisson你自己写一个可用的 Redis 分布式锁最低限度要怎么设计我直接给你一个参考实现Java 伪代码层面加锁// value 必须是全局唯一的比如 UUID String value UUID.randomUUID().toString(); // SET key value NX EX原子性的“不存在才设置 过期时间” String result jedis.set(lock:order:1001, value, NX, EX, 30); if (OK.equals(result)) { // 拿到锁执行业务 }释放锁-- Lua 脚本保证比较和删除的原子性 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个方案解决了三个老坑原子加锁防死锁、随机 value 防误删、Lua 脚本保证释放锁的原子性。3.2 但你的业务超时续期怎么办注意我上面还留着一个大坑没填锁过期时间设 30 秒业务跑 40 秒怎么办手写方案里常见的处理方式是开一个后台守护线程在锁快要过期的时候自动续期每过 10 秒检查一次如果锁还在就把过期时间再往后推 30 秒。业务执行完主动释放锁同时通知守护线程停止。这听起来不复杂但实现起来细节极其繁琐守护线程什么时候启动什么时候停止怎么保证不泄漏续期操作本身失败了是重试还是放弃放弃的话锁可能瞬间失效。业务线程和守护线程之间的通信怎么设计别搞出新的并发问题。分布式环境里每个节点的时钟如果不一样会影响过期判断吗这些问题不是不能解决但你写一遍得花不少精力而且容易写出隐蔽 Bug。更关键的是你在项目里每换一个业务场景都得把这些逻辑再复制一遍维护成本滚雪球一样涨。3.3 集群模式下的新问题主从切换丢锁上面聊的都是单机 Redis或者主从架构里所有操作都在主节点的情况。可一旦主节点挂了哨兵把从节点提升为主节点你就要面对一个残酷的事实Redis 主从复制是异步的。线程 A 在主节点上 SET 锁成功但这个 key 还没来得及同步到从节点主节点就宕机了。哨兵把从节点升为主节点这个从节点上根本没有锁 key。线程 B 这时候再去加锁加锁成功。A 和 B 又同时拿到了锁。这就是著名的“异步复制丢锁”问题也是 RedLock 算法诞生的背景。不过 RedLock 也不是银弹Redisson 本身支持 RedLock 方案但实际生产中如果你们没到特别极端的场景比如金融级强一致一般也不会轻易上 RedLock。因为它需要至少 5 个独立 Redis 节点运维成本高而且 RedLock 本身在分布式系统领域也一直有争议。这里我不展开 RedLock 的争论文战只说结论如果你的系统允许偶尔的锁失效大多数互联网业务其实都能接受用 Redisson 的单实例或主从模式就够了。如果绝对不允许那应该认真考虑 ZooKeeper 或 etcd而不是在 Redis 上拼命打补丁。4. Redisson 是怎么把这些坑打包解决的4.1 Redisson 锁的基本用法Redisson 是一个基于 Redis 的 Java 客户端框架它最大的价值不是 Redis 操作 API 多好用而是它把分布式锁做成了一套标准的、开箱即用的 Java Lock 接口实现。用法简单到令人感动RLock lock redissonClient.getLock(lock:order:1001); try { // 尝试加锁最多等 10 秒锁自动过期时间 30 秒 boolean locked lock.tryLock(10, 30, TimeUnit.SECONDS); if (locked) { // 执行业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }你都不用去管什么 Lua 脚本、随机 value、续期线程Redisson 全给你封装好了。4.2 Redisson 的看门狗机制Watchdog这是 Redisson 最核心的特性没有之一。当你调用lock.lock()或者lock.tryLock()不指定 leaseTime 的时候Redisson 会启动一个后台定时任务默认每 10 秒执行一次检查当前线程持有的锁是否还存在如果存在就把锁的过期时间重置为默认的 30 秒。这就像你去借书图书馆管理员每隔 10 分钟看你还在看就自动帮你把借书期限往后再推 30 分钟直到你把书还回去为止。这就完美解决了业务执行时间超过锁过期时间的问题。有个点必须强调看门狗只在未指定 leaseTime 时生效。如果调用tryLock(waitTime, leaseTime, TimeUnit)时显式传了 leaseTimeRedisson 不会开续期线程到点就自动释放锁。这个设计是为了满足那些“硬性要求锁必须多长时间内自动释放”的场景——比如你有非常严格的超时控制不希望锁被无限续期下去。4.3 锁的可重入性RLock实现了 Java 的Lock接口所以天然支持可重入。同一个线程在持锁状态下再次调用lock()计数器加一对应每次unlock()计数器减一直到归零才真正释放锁。这个特性有多重要你写业务的时候很容易出现 A 方法加锁之后调 B 方法B 方法里面又加了同一把锁的情况。如果锁不可重入你直接死锁。Redisson 把这一点也封装得干干净净你甚至不需要感知它的存在。4.4 失败重试与等待时间tryLock(waitTime, leaseTime, unit)的第一个参数 waitTime 表示获取锁的最大等待时间。如果你设置 waitTime10 秒意味着锁被别的线程持有时当前线程会最多等待 10 秒再去尝试获取锁超过 10 秒还不成功就放弃并返回 false。而lock()不带参数时会一直阻塞等待直到拿到锁配合看门狗非常顺滑。这个设计对业务友好度极高。你可以根据自己的业务容忍度自由选择是“抢不到就快速失败”还是“抢不到就排队”。4.5 Redisson 解决误删问题的底层逻辑Redisson 删除锁的时候也是通过 Lua 脚本先比较 value 再删除保证原子性。但这个 value 不是简单的 UUID而是一个基于线程 ID 和 UUID 生成的唯一标识同时保存了锁的持有线程信息。它在释放锁时不仅检查 value 是否匹配还会检查当前解锁线程是否真的是锁的持有线程。这层设计彻底堵死了“线程 A 删除线程 B 的锁”这种 bug。你拿RLock的时候根本不用传 valueRedisson 内部管理好了这一切。5. Redisson 也不是万能药说说它没帮你解决的事5.1 看门狗续期失败会导致什么看门狗续期靠的是 Redis 连接。如果业务线程和 Redis 之间的网络出现了长时间抖动续期请求发不出去锁到期就会自动释放其他线程就能拿到锁。这时候你的业务还在跑锁却没了照样会出现并发进入临界区的情况。Redisson 能做的只是尽量降低这种情况发生的概率它做不到像分布式事务一样彻底杜绝。因为分布式环境下网络分区是不可完全避免的。5.2 Redisson 锁在 Redis 主从架构下的固有缺陷前面说的主从切换丢锁问题Redisson 单实例模式照样存在。Redisson 官方给的建议是用 RedLock 算法但 RedLock 本身实现复杂度高需要多节点协调而且在极端情况下也存在争辩点。大多数团队在实践中的选择是能接受极小概率的锁失效用 Redisson 单节点模式完全不能接受直接换 ZooKeeper——因为 ZooKeeper 的顺序节点加 Watcher 机制在这种场景下的强一致保证比 Redis 更可靠。5.3 锁粒度太大导致性能雪崩Redisson 解决的是锁“正确性”问题但它解决不了你“锁效率”的问题。如果你给每个订单都加同一把全局锁那所有订单的写入操作全被串行化了QPS 直接断崖式下降。正确的做法是尽量缩小锁的粒度。比如处理库存扣减不锁整个订单只锁商品 ID 维度RLock lock redissonClient.getLock(lock:stock: skuId);这样不同商品的锁互相独立并发能力才能上去。我看到很多团队的生产故障都是锁粒度设计不合理导致的Redis 本身没锅锅都在设计上。5.4 Redisson 配置的注意点很多新人被 Redisson 默认配置坑过。这里我列几个关键配置项来源于我实际生产环境的总结lockWatchdogTimeout看门狗默认超时时间默认是 30000 毫秒。你要是设置得太小比如 5000 毫秒业务稍微卡顿就可能出现锁提前过期设置得太大万一节点挂掉锁迟迟不释放严重影响恢复速度。按默认 30 秒就够大多数场景。retryAttempts加锁重试次数默认 3。这个参数控制的是加锁失败后重试多少次而不是业务请求失败的重试次数别混淆。retryInterval重试间隔默认 1000 毫秒。如果你对加锁延迟很敏感可以调小这个值但会增加 Redis 负载。Codec序列化器默认是 Kryo 或者 Jackson具体版本不一样。有些团队在项目里同时用了多个 Redis 客户端发现锁 key 在 Redis 里看到的 value 是二进制乱码甚至出现 A 客户端存的锁 B 客户端读不到的情况大概率是 Codec 配置不一致。建议统一使用 StringCodec 或者在项目中全局只用一个 RedissonClient。6. 分布式锁的实战落地配置与避坑经验6.1 一个直接可用的 Redisson 配置示例用 Spring Boot 的话你可以直接这么配置spring: redis: host: 10.0.0.10 port: 6379 password: xxxxx timeout: 3000ms redisson: config: | singleServerConfig: address: redis://10.0.0.10:6379 password: xxxxx connectionMinimumIdleSize: 10 connectionPoolSize: 32 idleConnectionTimeout: 10000 connectTimeout: 3000 timeout: 3000 retryAttempts: 3 retryInterval: 1500 codec: !org.redisson.codec.Kryo5Codec {}自定义 RedissonClient Bean 时可以这样写Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://10.0.0.10:6379) .setPassword(xxxxx) .setConnectionPoolSize(32) .setConnectionMinimumIdleSize(10) .setRetryAttempts(3) .setRetryInterval(1500) .setTimeout(3000); config.setCodec(new org.redisson.codec.Kryo5Codec()); return Redisson.create(config); } }这里要特别提醒生产环境一定要设置好连接池和超时。默认的连接池比较小在高并发加锁场景下可能因为连接数不够导致加锁等待时间变长进而引发连锁故障。6.2 用线程池模拟并发验证锁的效果每次写完分布式锁我都要用一个简单的并发验证脚本确保锁真的生效写一个 Java 方法模拟 100 个线程同时去抢同一把锁每个线程拿到锁后 sleep 500 毫秒然后释放。如果锁逻辑没问题这 100 个线程的执行总耗时应该接近 50 秒如果锁失效总耗时会出现明显缩短同时你可以在临界区里放一个计数器看最终结果是否出现并发冲突。这段验证代码不值得贴全核心思路就是制造并发压力然后检查共享变量的最终一致性。如果最终计数器和理论值不一致锁必然有问题。6.3 锁的 key 命名规范这个坑我从生产事故中总结出来的。锁 key 的命名要遵循一个原则业务域 业务对象 唯一标识。比如订单支付锁lock:pay:order:1001库存扣减锁lock:stock:sku_88776655用户迁移锁lock:user:migrate:123456千万别直接用用户 ID 或者订单 ID 裸奔也别把所有锁都叫lock。否则将来排查问题时你根本分不清这个 key 是哪个业务留下的还可能出现业务间锁冲突。6.4 解锁的标准姿势解锁一定要在 finally 块中执行这个不用多说了关键点在于两点第一调用unlock()前要判断isHeldByCurrentThread()这是为了避免锁已被看门狗释放或者被其他逻辑释放后不经判断就删除了别人的锁。第二如果业务逻辑里主动删除了锁然后还继续做耗时操作这就是纯粹给自己挖坑。一旦锁提前释放其他线程就可以进入临界区后面的操作全部处于无锁保护状态。6.5 锁内不要做太耗时的远程调用这是我反复强调的分布式锁使用的第一原则不要让锁内的代码执行时间逼近或者超过锁过期时间。哪怕有看门狗续期看门狗默认 30 秒如果业务里嵌套了外部接口调用、数据库慢 SQL、大量 IO锁的稳定性会大幅下降。如果确实没法避免试着把锁的粒度拆小。只对真正需要保护的临界代码加锁比如“校验库存 扣减库存”这一段而不是把整个下单流程全部锁住。7. 生产环境常见问题与排查思路下面这些问题都是我实际遇到过的我整理成了一个速查表省得大家再踩一遍。现象可能原因排查方法与解决方案锁设置了过期时间但业务频繁报“获取锁失败”锁过期时间太小业务还未完成锁就释放了调大锁默认过期时间或利用 Redisson 看门狗业务很少加锁失败但数据仍然错乱锁 key 在不同业务之间冲突如订单锁和库存锁撞车检查 key 命名空间确保业务隔离Redis 主从切换后出现重复执行和超卖主从复制延迟导致锁 key 丢失评估是否升级 RedLock或换 ZooKeeper 方案unlock 时报IllegalMonitorStateException当前线程不是锁的持有者却调用了 unlock检查是否误删锁按 finally 规范解锁Redis 连接异常导致锁不可用连接池耗尽或 Redis 连接超时调大连接池、设置合理超时增加 Redis 服务端排查Redisson 锁 key 在 Redis 中显示为乱码Codec 不一致或者自定义序列化配置错误统一 Codec建议使用 StringCodec 进行调试业务线程在持有锁时被中断锁没被释放没有处理中断异常finally 未正确执行检查锁释放逻辑中的异常处理确保 finally 块执行7.1 排查锁问题时最常用的几个命令有时候你没法直接看 Java 日志需要到 Redis 命令行去验证锁是否还在。这几个命令非常实用# 查看锁是否存在以及剩余 ttl TTL lock:stock:88776655 # 查看锁 key 的值判断是不是当前线程的 GET lock:stock:88776655 # 查看当前 Redis 里有多少个锁相关的 key谨慎使用 KEYS大数据量下会阻塞 SCAN 0 MATCH lock:* COUNT 100我用 TTL 排查过一个很诡异的问题锁的 TTL 一直是 -1也就是没有过期时间。后来发现是团队里有人直接用 SETNX 命令加锁然后忘了设置过期时间。这完全暴露了没有统一规范使用 Redisson 带来的隐患。7.2 千万别动不动就清 Redis key 来“解围”我曾经见过一个运维事故线上 Redis 锁莫名不生效大量请求打到数据库导致系统几乎雪崩。负责人情急之下直接执行了FLUSHALL把整个 Redis 上的缓存数据全清了结果线上缓存血崩数据库被压垮事故级别直接升级。锁 key 异常时先判断是哪个业务的锁锁的键名是否有业务标识。如果是临时紧急恢复可以用DEL指定 key但不到万不得已别清库。真正的根源是锁设计或配置问题必须从代码层面修复不能靠清 key 救火。7.3 一个让我印象深刻的线上事故复盘有一年双十一备战我们的订单服务集群从 3 个节点扩到 10 个节点后突然出现大量下单失败。查日志发现所有请求都卡在获取库存锁上。进一步排查后发现锁 key 用了商品 ID 作为粒度但流量全部集中在几个爆款商品上锁竞争极其严重而且 Redisson 默认的连接池只有几个连接在 10 节点的高并发下瞬间被打满获取连接超时。后来我们做了两件事一是把锁粒度从 sku 维度细化到“sku 库存扣减批次”维度大幅度分散了锁竞争二是把 Redisson 连接池调大并加了合理的等待超时。改完再压测加锁成功率和接口耗时都回到正常水平。这个案例告诉我们分布式锁不是光选个实现方案就完事锁粒度、连接池、并发模型都要通盘考虑。否则你可能在锁机制没出错的情况下因为它封装得太好了而根本意识不到它成了瓶颈。8. 关于锁的运维监控和压测建议锁这个组件平时不出问题感觉不到它存在一出问题就是大事故。所以生产环境必须做基本监控。锁的监控指标我建议关注以下几点锁请求失败数看是否有大量加锁失败一般意味着业务可能在等待锁或锁冲突严重。锁等待时间统计 tryLock 的平均等待时间。如果等待时间持续增长说明锁竞争加大需要审视锁粒度或 Redis 性能。Redis 实例 load 和连接数锁操作是 Redis 的写入操作频繁加锁/释放锁会增加 Redis 负载如果 Redis 本身已成为瓶颈锁性能也会跟着崩。锁异常数量比如 Redisson 抛出的 unlock 异常、看门狗续期异常等这些都是生产事故的先兆。压测方面建议在测试环境用一个和线上相近的并发模型按正常业务提交流量放大 5 到 10 倍跑半个小时以上。因为有些问题只在长时间运行后才会出现比如连接池内存泄漏、锁 key 堆积、看门狗线程异常泄漏等短时间压测根本测不出来。9. 从 SETNX 到 Redisson我们应该得到的启发回头看这一路演进其实非常有代表性。SETNX 本身没有错它只是一个命令错的是我们指望用它一个命令解决所有分布式锁问题。后来我们通过 SET NX EX 解决了死锁通过随机 value 加 Lua 脚本解决了误删通过看门狗解决了过期时间不可控。每一步都是在解决前一步留下的问题同时也暴露出新的问题。Redisson 的价值不在于它发明了什么新理论而在于它把分布式锁的“正确性细节”沉淀成了一个标准组件让业务方不需要重造轮子。但 Redisson 也不是终点主从切换丢锁的硬伤它也没完全解决。真要追求强一致分布式协调服务才是更匹配的方案。我在实际项目中最大的体会是分布式锁的选型不是越复杂越好而是要和你的业务一致性容忍度匹配。大多数互联网业务偶尔因为节点异常导致锁失效是可以接受的Redisson 单实例到主从的演进就够用了而资金、交易等强一致性场景不要犹豫直接考虑 ZooKeeper 或者 etcd 那一套。最后分享一个小技巧不管用哪种分布式锁你一定要在代码里对“锁获取失败”的场景做清晰的降级处理是直接返回失败还是走削峰限流还是重试等待提前想明白。锁只是保证并发安全的手段真正的业务稳定靠的是整体架构设计而不是一把锁单打独斗。