
看到标题是“分布式锁Lua Redisson”我就知道这又是一个被问烂但始终没多少人真正讲透的话题。前阵子一个朋友公司出过一次线上事故用户连点两次支付按钮结果扣了两笔钱代码里明明做了状态判断还是没挡住。排查到最后问题出在加锁那一步——他们用的是单机JVM锁服务一扩容成多实例锁就形同虚设了。我帮他重构的时候把所有方案理了一遍正好借这篇文章把Lua手写分布式锁和Redisson框架这两条路线完整梳理一遍。无论你是正在准备面试还是已经在线上踩过重复执行的坑这篇内容应该都能给你一个清晰的参考。1. 为什么需要分布式锁从一次超卖事故说起先说个最简单的实验场景。你有一个库存表扣减逻辑是“查库存 - 判断是否大于0 - 减一 - 写回”单机部署时用synchronized或者ReentrantLock包住这段代码一切正常。但当你把服务部署成两个实例前面挂一个负载均衡问题立刻暴露两个实例各自持有一把JVM锁它们互相之间完全不知道对方的存在。同一时刻实例A读到库存是1实例B也读到库存是1两个都通过了判断各减一次库存变成了-1。这就是典型的超卖。1.1 单机锁为什么管不住分布式环境很多人对锁的理解停留在“锁是语言提供的关键字或者API”这个层面没有意识到锁的本质是“多方协商出来的互斥协议”。单机锁的协商范围是当前JVM进程内的所有线程锁对象在堆内存里放一个标记线程抢到了就改标记其他线程看到标记就知道该等。可一旦进程变成多个堆内存就不共享了A实例的锁标记存在A的堆里B实例根本看不到协商彻底失败。这时候需要一把“大家都看得见、都能参与竞争的锁”于是分布式锁登场。它的实现思路很简单找一个所有实例都能访问到的第三方存储谁先在存储里写下自己的标识谁就拿到锁。Redis、ZooKeeper、etcd、数据库都能干这件事Redis因为性能高、接入成本低成了最主流的方案。1.2 真正需要分布式锁的典型场景不是所有并发问题都需要分布式锁。我见过不少团队在“其实单机锁够用”的场景里硬上分布式锁白白引入性能和运维复杂度。反过来该用的时候不用又容易出大事故。我总结下来下面这几类场景是分布式锁的高频使用区秒杀和库存扣减先读后写的复合操作必须保证“判断库存”和“扣减库存”之间不被其他线程插入。防重复提交用户连续点击提交按钮或支付回调重复推送需要保证同一个订单只能被处理一次。定时任务调度多实例部署后同一套定时任务会在每个实例上都跑一遍需要加锁保证只有一个实例执行。缓存重建缓存失效瞬间大量请求回源数据库需要加锁让只有一个请求去查库其余请求等锁释放后直接用缓存。分布式事务中间态控制对账、退款之类的任务需要确保同一笔资金的操作串行执行。这些场景的共同特点是它们都涉及“读一个状态 - 做判断 - 改状态”的复合操作且这个操作必须原子化。原子化是分布式锁的核心价值也是后面讲Lua脚本时贯穿始终的关键词。1.3 一把合格的分布式锁要满足什么面试和实战中衡量一把分布式锁是否合格我习惯用下面五个标准去卡互斥性任意时刻只能有一个客户端持有锁。安全性持有锁的客户端崩溃后锁能自动释放不会形成死锁。可用性加锁和解锁的开销要小且在Redis自身可用时锁服务不能宕机。可重入性可选同一个客户端可以重复获取同一把锁避免业务代码里同一个线程多次进入临界区时自己把自己锁死。公平性可选按请求顺序分配锁理想情况是先到先得。实际开发中互斥性和安全性是硬指标一条不满足就会出事故。可重入和公平性属于加分项多数业务不用强求。搞清楚这几个标准再看Lua脚本和Redisson的设计思路会清晰很多——它们本质上都是在补足这些标准里的某个短板。2. Lua脚本Redis原子性魔法的底层逻辑Redis官方文档里说EVAL命令执行的Lua脚本是原子的执行期间不会被其他命令插入。这句话是整个Redis分布式锁安全性的理论根基很多文章一笔带过但值得停下来仔细想清楚它为什么成立。2.1 单线程模型让Lua脚本成为天然事务Redis处理命令是单线程的无论是普通的GET、SET还是一个完整的Lua脚本最终都会进入同一个事件循环一个接一个地执行。EVAL的特殊之处在于当Redis开始执行一个Lua脚本时它会把这个脚本当作一个整体脚本内部的多个Redis操作之间不会穿插任何客户端的其他命令。相当于数据库事务里的原子性要么整个脚本全部执行完要么一条都不执行。正是这个特性解决了分布式锁里最棘手的问题检查和删除不是原子的。你可以用一条SET命令原子地加锁但释放锁时往往需要“先看一下这把锁是不是自己的再决定删不删”这两步如果分开执行中间就会被别的命令打断。用Lua把它们包进同一个脚本Redis在执行期间不允许其他操作插队问题就解决了。2.2 手写分布式锁的完整演进过程今年帮朋友排查线上问题时发现他的代码还在用SETNX加锁、EXPIRE设超时我就知道他对分布式锁的理解还停留在第一层。真正的正确写法其实经历了好几次迭代每迭代一次补上一个大坑。我把这个演进过程完整列出来你看完就知道为什么最终方案长这样。第一版SETNX EXPIRE 分两步SETNX lock:order:123 1 EXPIRE lock:order:123 30问题在于这两条命令之间没有原子性。如果SETNX成功之后、EXPIRE执行之前进程崩溃了锁就永远不释放后面所有请求全部卡死。第一版是典型的初学踩坑点。第二版SET NX PX 一步到位SET lock:order:123 7f3a9c2e-8b44-4d1a-9a0e-6e2a4c9d7b01 NX PX 30000Redis从2.6.12开始支持在SET命令上直接带NX和PX参数把加锁和设置过期时间合并成一条原子命令。这里有个细节值得注意value没有写死成1而是传了一个UUID。为什么要这么做因为释放锁的时候我们要判断“这把锁是不是我加的”靠的就是这个唯一标识。第三版释放锁时先校验再删除-- KEYS[1]: 锁的key -- ARGV[1]: 客户端唯一标识 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end释放锁的这段脚本业界基本是这么写的。它的执行逻辑是先取锁当前的值和客户端传进来的标识比对一样才删除不一样说明锁已经被别人重新获取了不能动。如果没有这个校验直接DEL会出现一个经典的连锁故障——线程A持锁超时锁自动过期线程B加锁成功A执行完业务后释放锁把B的锁删了线程C趁虚而入B和C同时进入临界区。这个场景我在4.1节会详细展开。这段脚本很短但它同时体现了Lua在分布式锁里的两个核心价值原子性和条件逻辑。没有Lua脚本你只能在客户端先GET再DEL中间那一瞬间锁的状态就可能被别人改写。2.3 Lua脚本的边界不是所有操作都适合写进脚本Lua虽好但有一些现实约束必须清楚。第一Redis默认限制Lua脚本的执行时间不能超过lua-time-limit配置值默认5秒。脚本如果执行太久Redis会记录日志并且后续命令会报“BUSY”错误极端情况下需要SCRIPT KILL才能恢复。所以脚本里尽量避免写大循环、大批量遍历。第二在Redis Cluster环境下一个Lua脚本里操作的多个key必须落在同一个哈希槽否则会直接报CROSSSLOT错误。实践中很多人因为锁key设计不当踩过这个坑。解决办法是在key里加哈希标签比如lock:{order:123}让Redis按花括号里的内容计算槽位。不过话说回来分布式锁本身一般操作单个key这个约束主要影响的是那些试图在一个脚本里同时操作多个业务key的进阶场景。第三脚本里不要使用TIME、随机函数这类非确定性命令。主从复制环境下主节点执行脚本得到的结果会原样复制到从节点如果脚本依赖运行时状态主从数据就会不一致。3. Redisson生产环境如何优雅地用好锁有了Lua脚本理论上可以手搓一把能用的分布式锁了。但生产环境和教学环境完全不同你需要考虑锁超时时间怎么定、业务执行超过锁时间怎么办、同一个线程重复加锁会不会死锁、获取不到锁时要不要等待。手写方案面对这些问题代码量会迅速失控。Redisson就是来兜底这些事的。3.1 Redisson的锁为什么是Hash Lua的组合我第一次看Redisson加锁源码时最大的疑问是为什么不用简单的String类型而是用一个Hash结构答案是为了支持可重入。Redisson加锁的Lua脚本简化后大概是这个逻辑-- KEYS[1]: 锁的key例如 lock:order:123 -- ARGV[1]: 锁的过期时间毫秒默认30000 -- ARGV[2]: 客户端唯一标识UUID 线程ID if (redis.call(exists, KEYS[1]) 0) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; return redis.call(pttl, KEYS[1]);逻辑拆开看其实很清晰。Redis里存的是一个Hashkey是锁名field是“UUID 线程ID”value是重入次数。第一次加锁时Hash不存在走hincrby把次数记为1同时设置过期时间。如果同一个客户端再次加锁Hash已经存在且field匹配就把次数加1然后刷新过期时间。如果Hash存在但field不匹配说明锁被别的客户端持着就返回当前锁的剩余过期时间调用方据此决定要不要等待。解锁对应的Lua脚本逻辑是反过来重入次数减1减到0才真正删除锁。这样一个结构就同时实现了互斥和可重入还保证了所有操作原子化。3.2 看门狗机制自动续期是怎么回事手写分布式锁最头疼的问题就是锁过期时间怎么设。设短了业务没跑完锁就释放并发穿插进来设长了万一持有锁的实例挂了锁要很久才释放拖垮整体可用性。Redisson的看门狗机制就是冲着这个痛点去的。默认配置下lockWatchdogTimeout是30秒。客户端成功加锁后Redisson会启动一个后台定时任务每10秒默认超时时间的三分之一执行一次续期把锁的过期时间重新刷新为30秒。这样只要客户端还活着锁就一直续期业务跑多久都不怕锁提前过期。关键细节来了如果你手动通过tryLock(waitTime, leaseTime, unit)指定了leaseTime看门狗就不会启动。这是Redisson源码里的明确行为也是很多人踩过的坑。有人为了“保险”手动设了一个超时时间结果发现锁的行为变得不可预期原因就在这里。我个人的建议是除非你对业务耗时上限有非常确定的把握否则不要手动传leaseTime让看门狗接管最省心。看门狗还有一层保障逻辑客户端进程崩溃后续期任务也跟着停止锁在30秒后自动过期不会死锁。这就把前面说的“安全性”和“业务耗时不确定”两个问题一并解决了。3.3 常用姿势代码怎么写才规范Redisson的分布式锁接口是RLock配合tryLock使用是生产环境最常见的写法。注意区分两种签名// 方式一只传等待时间leaseTime不传 - 看门狗生效锁会跟随业务自动续期 lock.tryLock(3, TimeUnit.SECONDS) // 方式二等待时间和leaseTime都传 - 看门狗不生效锁在leaseTime后强制过期 lock.tryLock(3, 30, TimeUnit.SECONDS)我标准的使用模板大概是下面这样RLock lock redissonClient.getLock(lock:order: orderId); boolean locked false; try { // 最多等3秒拿不到锁就走降级 locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { // 这里建议做降级处理直接返回“系统繁忙”或者异步重试而不是死等 return Result.busy(); } // 真正的业务逻辑 doBiz(); } finally { // 只有拿到锁才需要解锁 if (locked) { lock.unlock(); } }这里有两个细节值得强调。一是finally里解锁前要判断locked避免没拿到锁却执行了unlockRedisson虽然会抛异常但没必要。二是tryLock的等待时间不要设置太长分布式环境下的锁竞争是网络开销很大的操作等待3秒已经算比较激进的设定了再长就直接影响接口响应时间。3.4 公平锁、读写锁什么时候需要Redisson除了默认的非公平锁还提供了FairLock和ReadWriteLock。FairLock底层用Redis的队列为每个等待线程排队实现先到先得代价是性能开销明显高于非公平锁绝大多数业务根本不需要公平性用了反而拖慢速度。ReadWriteLock适合读多写少的场景读写锁内部把一个锁拆成了两组key读锁之间可以并发写锁互斥。我见过一个订单查询系统用它来保护缓存重建多个读请求可以同时加读锁触发器重建缓存时加写锁效果不错。不过这类需求占比不高默认RLock足够覆盖大多数场景。4. 生产环境踩过的坑锁超时、时钟跳跃与主从切换Redisson虽然省心但分布式锁的风险并没有完全消失。下面这几个坑都是我在不同项目里真实遇到过的按踩坑频率从高到低排列。知道这些坑比背十篇理论文章都有用。4.1 锁过期时间短于业务耗时的连锁反应这是分布式锁最经典的事故模型。假设锁过期时间设了10秒你的业务正常情况3秒跑完。某天大促某个慢SQL把这段业务拖到了20秒。第10秒时锁过期另一个线程B拿到锁进入临界区。第20秒时线程A终于执行完了它进入finally执行unlock——问题来了如果锁没有带唯一标识校验它会把线程B的锁直接删掉。线程C看到锁没了也拿锁进来。所有保护全部失效。即使带了唯一标识校验线程A的unlock会失败但线程B和线程C的重叠执行已经成为事实数据的一致性问题已经发生。这类事故最恶心的点在于锁本身没有坏是过期时间设置不合理导致的保护真空。所以经验是锁的过期时间必须覆盖业务的最坏情况耗时不是平均耗时同时配合看门狗或者在业务内增加幂等兜底。分布式锁解决的是“大概率问题”它不能替代幂等设计。4.2 主从切换导致锁丢失Redis高可用部署通常会做主从架构。客户端A在主节点上加锁成功但主节点还没来得及把这条写入同步给从节点主节点就挂了。哨兵把从节点提升为主节点这个新主节点上没有刚才的锁记录。此时客户端B也来加锁成功。A和B同时持有同一把锁互斥性被破坏。这是Redis分布式锁的先天不足业内吵了很多年。RedLock算法的思路是向多个独立的Redis节点同时申请锁超过半数成功才算加锁成功试图把单点故障的概率降下去。这个算法本身的争论我没打算在这里展开但可以很坦诚地说绝大多数团队的生产环境根本没有部署多套独立RedisRedLock落地成本很高实际价值存疑。我现在的观点很明确如果业务可以接受“极小概率的锁丢失”那就用单实例Redis加Redisson靠锁超时和幂等兜底如果绝对不能接受锁丢失比如资金类操作那就别在Redis上纠结直接上ZooKeeper或者etcd这类强一致服务。不要试图用一个自身也依赖选举的Redis高可用架构去追求强一致的锁语义——这条路走不通。4.3 锁粒度过大把并发全锁死这是一个容易被忽视的性能问题。有次陪一个团队排查接口变慢的问题发现所有请求都堵在一个Redis锁上。看了代码才发现锁的key写成了lock:user而不是lock:user:{userId}。也就是说所有用户的所有请求都在抢同一把锁接口直接变成了全局串行。这个问题的教训是锁key的设计要精确到业务对象的最细粒度。锁一个订单就写lock:order:{orderId}锁一个用户就写lock:user:{userId}锁一个优惠券就写lock:coupon:{couponId}。同时给锁key加业务前缀方便监控和清理。太过粗粒度的锁在低并发时毫无感知一上流量立刻暴露。4.4 锁和数据库事务的执行顺序最后这个坑属于进阶型问题。假设你的业务逻辑是先开启一个数据库事务再获取分布式锁最后在finally里解锁并提交事务。这个顺序会有一个隐蔽的bug另一个线程拿锁进入临界区后它读到的还是上一个线程未提交的数据因为上一个线程虽然解锁了事务还没提交。正确的顺序应该是先获取分布式锁再开启数据库事务业务执行完后先提交事务再释放锁。这样释放锁时数据已经对外可见下一个拿到锁的线程读到的就是最新提交的数据。这个顺序问题在并发量不大的环境里很难触发但一旦出现就是特别诡异的数据不一致现象排查起来很费劲。优先在代码设计阶段就把它理顺。5. 手写Lua还是直接用Redisson我的选型建议结合近几年在多个项目里的实际经验我告诉你我现在的选择标准基本可以覆盖90%以上的业务场景。5.1 场景决定方案不是技术决定方案先说结论再讲原因。维度手写Lua SETNXRedisson可重入支持需要自己维护计数器内置支持自动续期看门狗需要自己实现定时器内置支持等待锁机制需要自己实现阻塞或轮询内置tryLock(waitTime)公平锁/读写锁自己写成本高内置支持代码量与维护成本脚本短但逻辑要自己维护框架封装出问题需查源码适用场景简单加锁释放、教学、精细定制大多数生产业务如果是写一个工具类或者中间件业务非常简单锁生命周期明确可控手写一段Lua就够了。一个SET NX PX加一段解锁脚本几行代码搞定依赖也少。如果是业务系统里的常规需求直接选Redisson。理由很务实它把可重入、看门狗、等待队列这些生产环境刚需都替你实现了代码里少写很多可能会被后人改坏的东西。Redisson源码里那几十行Lua脚本是Redis官方和社区多年打磨的结果比大多数人自己写的要稳。5.2 更通用的Lua需求不只是分布式锁还有一个不太被注意的角度。你会发现Lua在Redis里的价值其实远不止分布式锁。很多场景下你需要把多个Redis操作打包成一个原子动作。比如秒杀场景里的“检查库存是否充足充足则扣减并记录当前用户已经秒杀成功”再比如发券场景里的“判断用户是否已领过没有则发券并计数”。这些操作如果用客户端代码实现需要好几轮网络往返中途还可能被并发穿插。写成一个Lua脚本一个EVAL打完收工性能和安全都兼顾。所以说“Lua Redisson”这个标题组合本质上是两条互补的技术路径。Redisson是分布式锁的上层封装Lua是Redis原子操作的底层工具。你用Redisson的时候它内部在跑Lua你需要更灵活的原子逻辑时你直接手写Lua。两条路都要会用才算真正把Redis分布式锁用明白了。5.3 我自己的落地方案最后分享一套我现在个人比较推荐的落地方案。这不算标准答案但它是经过多次事故后沉淀下来的实践框架。第一默认所有分布式锁需求都走Redisson但是给锁key带上业务前缀和对象级粒度。第二除非有明确理由否则不传leaseTime让看门狗默认续期。第三tryLock的等待时间设置一个上限超时直接返回繁忙而不是无限阻塞。第四所有被锁保护的写操作在业务层再做一层幂等控制例如数据库的唯一索引、状态机校验形成“锁 幂等”双保险。第五Redis不可用时要有降级策略我的习惯是直接返回失败或者熔断而不是放行——放行可能引发更大的数据问题。第五点值得展开说一句。有些团队为了追求可用性在Redis故障时选择跳过锁直接执行业务这个设计很危险。锁服务失效时放行等于把数据一致性完全暴露给并发环境。宁可暂时拒绝一部分请求也不能让超卖、重复支付这类问题出现。分布式锁本来就是用一部分可用性换一致性这个交易要始终坚持。