ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redisson分布式锁从原理到实战:看门狗、Lua脚本与超卖事故复盘

Redisson分布式锁从原理到实战:看门狗、Lua脚本与超卖事故复盘 1. 先回忆一次库存扣减的事故现场分布式锁到底锁的是什么Redisson分布式锁在Java后端项目里几乎是绕不开的话题但很多同学对它的理解停留在lock()和unlock()两个方法的层面。我刚开始接触分布式锁时也是这样直到线上出了库存超卖事故才真正开始研究这套机制。这篇文章想把我从入门到实战踩过的坑、看过的源码、总结出的经验一次讲清楚适合用过Redis但还没系统学习过分布式锁的后端开发者也适合准备面试时需要把原理说透的读者。先还原一次事故。电商大促某商品库存只剩1件两个用户几乎同时下单。订单服务部署在两个节点上每个节点的代码里都加了ReentrantLock保护库存扣减逻辑。结果一个节点扣掉了库存另一个节点也扣掉了库存数据库里库存变成了-1。原因不用说你也猜到了ReentrantLock是JVM级别的锁每个节点各自维护一把锁A节点的锁管不住B节点的线程。多实例部署时单机锁的互斥边界就被戳穿了。1.1 事故现场还原两个JVM一把锁不住并发再往深看一层库存扣减这个场景的问题本质是什么多个进程同时读写同一个共享资源库存需要保证读-判断-写这个复合操作在同一时刻只有一个线程能执行。在单机环境下JVM的锁可以让多个线程排队到了多节点环境排队规则就必须由所有节点共同认可的一个第三方仲裁者来制定。分布式锁要解决的就是这个跨进程互斥问题。它不像synchronized那样锁住一个对象而是锁住一个逻辑资源让所有进程去同一地方竞争同一个标记。谁拿到标记谁才有资格执行临界区代码。这里的关键词不是锁本身而是互斥约束——所有节点必须遵守同一个约定。我复盘事故时列了三个疑问为什么数据库行锁没挡住因为扣减逻辑分为查询库存、业务校验、更新库存多步不是一条UPDATE语句行锁只能保护单条SQL的原子性。为什么乐观锁没选重试成本高大促场景下大量冲突会导致接口响应变慢。为什么不用消息队列串行化订单流程不是单纯写库存还有大量同步逻辑改造成本太大。分布式锁在当时是最直接的方案我们改造时选择了Redisson。它把复杂的加锁、续期、解锁逻辑封装好了用法简单稳定性也有保障。但如果你不了解它内部怎么处理锁超时重复获取持有者判定这些问题线上早晚还会出幺蛾子。1.2 分布式锁需要满足的五个硬性条件业界对分布式锁的要求有比较统一的共识我拆成五个点要求含义不满足时的后果互斥性任意时刻只有一个客户端持有锁并发代码同时执行数据错乱可重入同一线程可重复获取同一把锁递归或嵌套方法死锁防死锁持有者宕机后锁能自动释放锁永久存在后续请求全部失败高性能加锁解锁延迟低、不占用太多资源接口RT飙升拖垮整体服务高可用锁服务本身不能单点不可用Redis挂掉后业务完全不可用这里要单独强调可重入和防死锁因为手写方案的坑基本都出在这两个点上。可重入最常见的场景是外层方法加锁后调用内部方法内部方法也想加同一把锁如果锁不具备可重入能力自己会被自己阻塞住。防死锁则对应进程突然被杀、网络分区、OOM等极端情况。分布式锁的典型使用场景我也盘点一下方便你对号入座库存扣减、秒杀下单这类强一致写操作。多节点定时任务同一时刻只允许一台机器执行任务。幂等控制同一业务请求多次到达时只让第一次真正处理。分布式事务中的并发资源保护。如果你遇到的是这些场景Redisson分布式锁是性价比很高的选择。它不需要你懂太多底层细节但接下来我要讲的几个原理和坑是你迟早要补上的。2. 自己手写过Redis锁之后我才理解Redisson省掉了哪些麻烦很多分布式锁教程会教你用Redis的SET NX EX命令自己实现锁网上也有大量类似的工具类。我在早期项目里确实手写过当时觉得也就几十行代码的事。后来在生产环境被教育了几次才明白Redisson这类成熟客户端封装的那些看起来多余的机制每一行都是踩坑踩出来的。2.1 一个经典的手写Redis锁实现及其缺陷先看我当时第一版代码简化后的样子public boolean tryLock(String lockKey, String requestId, long expireMs) { // 使用 SET NX EX 保证原子性不存在才设置同时带上过期时间 String result jedis.set(lockKey, requestId, NX, PX, expireMs); return OK.equals(result); }释放锁时我当时直接调了jedis.del(lockKey)。这段代码有三个致命问题。第一个问题是锁过期时间与业务执行时长不匹配。如果expireMs设为10秒业务却执行了15秒锁在业务结束前就自动释放了。此时另一个线程可以立刻拿到锁两个线程同时进入临界区分布式锁形同虚设。第二个问题是误删别人的锁。线程A持有锁后执行较慢锁过期被线程B拿到。线程A执行结束后调用del删掉的是线程B持有的锁。要修复这个问题必须在删除前比较value是否还是自己写入的那个值并且比较和删除要保证原子性否则又会引入竞态。public boolean releaseLock(String lockKey, String requestId) { // 必须用 Lua 保证 检查-删除 的原子性 String lua if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; Object result jedis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(requestId)); return Long.valueOf(1L).equals(result); }第三个问题是不可重入。同一个线程执行到嵌套逻辑时再次调tryLock会直接失败因为你不知道当前线程是否已经持有过这把锁Redis里也没有记录持有者线程的维度。2.2 手写方案还缺哪些东西续期、阻塞等待和主从安全除开上述三个问题手写方案还缺少一个重要的后台机制自动续期。真实场景里业务耗时很难精确预估锁的过期时间长了有安全风险短了锁容易提前失效。正确做法是在线程还持有锁时定期续期这个机制俗称看门狗。手写方案要自己启动定时任务去延长过期时间崩溃时还要记得取消复杂度一下就上来了。另外手写方案通常只提供尝试一次的接口不提供等待获取的能力。多个请求同时竞争一把锁时抢不到的请求往往需要立即返回失败或者重试但更好的行为是让它们阻塞等待就像ReentrantLock.lock()那样。这一点也会增加代码复杂度。主从架构下的锁丢失问题更隐蔽客户端A在master节点上加锁成功master在异步复制数据给slave之前宕机slave提升为新master客户端B在新master上加同一把锁也能成功。两个客户端都认为自己是持锁者。手写方案对此毫无办法。对比一下手写方案和Redisson的方案就很直观了能力手写SET NXRedisson加锁原子性具备通过Lua脚本保证可重入不支持支持基于Hash计数自动续期需要自己实现内置看门狗阻塞等待需要自己实现支持基于发布订阅唤醒误删保护需要自己设计内置持有者校验公平锁/读写锁/联锁无有对应实现这个对比不是说手写方案完全不能用而是想说分布式锁的难点不在能不能加锁而在异常情况下还安不安全。Redisson的价值恰恰是把这些异常情况都兜住了。3. 从依赖引入到第一把锁Redisson分布式锁的基础用法讲原理之前先让没接触过的读者把环境搭起来把第一把锁跑通。Redisson支持Spring Boot Starter和原生API两种方式我用Spring Boot项目演示。3.1 依赖引入与配置Maven项目里添加依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependencyGradle项目对应implementation org.redisson:redisson-spring-boot-starter:3.27.2如果你的项目里还有其他Redis依赖注意统一客户端版本避免Java类冲突。配置上最简单的方式是在application.yml里指明Redisson独立配置文件spring: data: redis: redisson: file: classpath:redisson.yamlredisson.yaml里可以这样写singleServerConfig: address: redis://127.0.0.1:6379 database: 0 password: null connectionMinimumIdleSize: 4 connectionPoolSize: 16 threads: 8 nettyThreads: 16如果是Spring Boot环境也可以直接注入RedissonClient。最好用Configuration手动创建客户端方便后期调参Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setDatabase(0) .setConnectionPoolSize(16) .setConnectionMinimumIdleSize(4); return Redisson.create(config); } }3.2 核心APIlock、tryLock、unlock获取一把可重入锁非常简单RLock lock redissonClient.getLock(stock:sku:10001); lock.lock(); try { // 临界区扣减库存、校验订单、写缓存等 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这是最常见的写法。几点要说明getLock(String name)的参数就是锁的名称也就是Redis里的key。锁的粒度由这个key决定后面讲到锁粒度时再展开。默认的lock()方法不传leaseTime此时锁的有效时间为30秒并且看门狗会每10秒自动续期。unlock()只有当前持有者才能调用否则会抛出IllegalMonitorStateException。所以我在finally里先判断了一下isHeldByCurrentThread()。再看tryLock。这是我最常用的方法适合需要控制等待时间的场景boolean acquired lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { // 3秒内没拿到锁直接返回失败避免请求堆积 throw new BusinessException(系统繁忙请稍后重试); } try { // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock(waitTime, leaseTime, unit)有三个参数含义分别是waitTime尝试获取锁的最大等待时间超过这个时间还没拿到就返回false。leaseTime锁的自动释放时间超时后Redis会强制删除锁。unit时间单位。注意一个容易踩坑的点一旦你手动指定了leaseTime看门狗就不会启动了。锁到了时间必然释放不会管你的业务是否执行完。所以这里要合理地预估业务最长耗时留足冗余。3.3 可重入、公平锁、读写锁与联锁的简单介绍Redisson的getLock()拿到的是RLock它的可重入体现在同一个线程在持锁期间再次调用lock()同一把锁时不会被阻塞内部计数加1对应的需要调用同样次数的unlock()计数减到0才会真正释放锁。除了RLock还有几个锁类型也值得了解方法锁类型使用场景getLock(name)可重入锁大多数业务场景getFairLock(name)公平锁需要按请求到达顺序获取锁的场景getReadWriteLock(name)读写锁读多写少且读操作不需要互斥的场景getMultiLock(lock1, lock2...)联锁需要同时持有多个独立锁的场景这些类型我在实际生产中用得不多常用的是可重入锁读写锁在某些缓存一致性场景里很有效。联锁通常用来解决多个资源必须一次性锁定的需求例如转账操作需要同时锁住转出账户和转入账户。4. 看门狗、Lua与可重入Redisson锁的三个底层关键机制如果你只是调用APIRedisson看起来确实简单。可一旦线上出了诡异问题比如锁提前失效、锁抢不到、同一条数据被两个线程改坏最终都要溯源到它的底层机制。这一章我会把加锁、续期、解锁的源码逻辑用通俗的方式拆开讲。4.1 加锁时到底执行了什么Lua脚本Redisson加锁的核心是一段Lua脚本我把它简化并加了注释-- KEYS[1] 是锁名称KEYS[2] 是用于通知的 channel -- ARGV[1] 是锁的过期时间(毫秒)ARGV[2] 是当前线程标识(uuid threadId) -- 锁不存在时创建Hash结构并设置过期时间 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; -- 锁存在但当前线程已经在持有者列表中则计数加1实现可重入 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里不是简单的key-value字符串而是一个Hash结构。Hash的key是锁名称Hash里的field是线程标识field对应的value是重入计数。这解释了可重入的实现同一线程再次加锁时Hash中已经有对应field直接对计数做加1操作。第二个关键是原子性。整个判断是否存在、创建Hash、设置过期时间全部在Lua脚本里完成Redis执行Lua脚本是原子的不会出现检查时锁不存在、创建时被别人抢先这种竞态。手写SET NX EX其实也是一种原子方案但无法同时实现可重入计数和持有者记录。第三个关键是锁过期时间。脚本里的ARGV[1]默认是30000毫秒也就是30秒。这就是lock()不传leaseTime时的默认租期。它只是初始值后续由看门狗动态续期。4.2 看门狗30秒不是硬性限制而是续期基准我第一次看到默认锁过期时间30秒时很慌我的业务要跑40秒锁不就提前没了吗后来才明白这30秒并不是说你只能持有锁30秒而是给看门狗一个续期的基准周期。Redisson的看门狗机制是这样的线程持有锁成功后如果使用的是未指定leaseTime的lock()Redisson会启动一个后台定时任务每隔leaseTime / 3即10秒检查一次Redisson客户端当前是否还持有这把锁。如果还持有就把锁的过期时间重新设置为30秒。这个设计解决了两类问题业务耗时超过了初始30秒时锁不会被自动释放保证临界区安全。如果持有锁的进程突然宕机、线程被kill看门狗任务也随之消失锁会在最多30秒后由Redis强制删除不会造成死锁。我自己的理解是看门狗不是永久续期而是只要进程还活着且锁还被当前客户端持有就续期。实际编码中要注意lock(long leaseTime, TimeUnit unit)和tryLock(waitTime, leaseTime, unit)如果不传leaseTime或者传-1看门狗才会启动一旦传入明确leaseTime看门狗就会被禁用锁到期就直接释放。4.3 解锁时做了什么为什么也需要Lua脚本来保证原子性解锁流程同样是一段Lua简化后-- 锁不存在说明锁已经释放返回nil if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; end; -- 当前线程的重入次数减1 local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then -- 还有重入次数只刷新过期时间不删除锁 redis.call(pexpire, KEYS[1], ARGV[2]); return 0; else -- 重入次数归零删除锁并发布解锁消息 redis.call(del, KEYS[1]); redis.call(publish, KEYS[2], ARGV[1]); return 1; end;这段脚本解决了三个关键问题一是防止误删。hexists会校验当前线程的field是否真的存在不存在就说明你不是锁持有者脚本直接返回不会执行删除。这样就不用关心值比对删除两步操作的原子性问题了。二是重入计数递减。计数减1后如果还大于0说明线程外部还有一层锁没释放只刷新过期时间不删除Hash。这里也能看到每次lock()必须对应一次unlock()多了一层锁少了一次解锁锁就永远释放不掉。三是唤醒等待者。publish会向KEYS[2]这个channel发送一条解锁消息。正在tryLock里阻塞等待的其他线程通过Redis的发布订阅监听这个channel收到消息后会再次尝试加锁。这比轮询休眠高效得多也是Redisson在激烈竞争下还能保持较低延迟的原因之一。4.4 常见理解误区看门狗不是万能的看门狗虽然好用但它只能保证持有锁的线程还活着时锁不过期。它不能保证主从切换时的锁安全也不能避免你手动指定leaseTime后业务超时导致的锁提前释放。还有一点要注意如果锁内代码执行时间特别长看门狗会一直续期这会让其他等待线程一直阻塞在tryLock上直到它的waitTime超时。这本身不算锁的bug而是锁的独占时间设计问题。临界区就该短平快这句话后面实战章节还会再强调。5. 线上踩过的坑锁超时、主从切换与锁粒度过度设计理论再好不如实际踩一次坑。这一章记录我在生产环境里遇到的三个典型问题都和数据错乱、接口超时直接相关。5.1 锁内做耗时操作从锁提前失效到任务堆积有一次定时任务改造我们拿锁执行数据同步。任务里有一块逻辑要调用第三方接口第三方接口偶发抖动最慢能拖到2分钟。当时设计者用了lock()默认看门狗模式表面看锁不会提前失效问题却变成了另一种形态因为看门狗一直在续期锁被同步任务长时间占用其他节点上的对账任务、补偿任务全部在tryLock等待超时后失败任务不断重试系统日志里全是抢锁失败告警。这个案例给我的经验是临界区只放必须互斥的本地操作把外部接口调用、长I/O挪到锁外面。如果外部调用确实躲不开一定要给外部调用设置网络超时并配合熔断机制不要让业务线程无限等下去。反过来如果用的是显式leaseTime锁提前失效后的表现就是另一副面孔。曾经有个业务同学把tryLock(2, 5, TimeUnit.SECONDS)写进了库存扣减逻辑某次因数据库慢查询导致扣减耗时8秒5秒时锁已经自动释放另一个请求进来后两个线程同时做扣减库存又出现了负数。排查时在Redis里用MONITOR能同时看到两个线程对同一把锁做了加锁都返回成功数据时间线也对得上一切都指向锁的超时时间设置不合理。5.2 主从切换带来的锁丢失要不要升级到红锁这是我在一个金融类项目评审时被问得最多的问题。Redis主从架构下加锁master负责写slave负责异步复制。假设客户端A在master上加锁成功master还没来得及把锁的Hash复制到slavemaster就宕机了哨兵把slave提升为新的master。此时客户端B到新master上加同一把锁会因为锁key不存在而加锁成功。于是A和B同时进入临界区。Redisson提供了RedissonRedLock来解决多节点锁问题理论上是同时在多个Redis实例上加锁超过半数成功才算加锁成功。但我要说实话红锁在工程界争议很大异步副本和故障时间窗口很难根治而且可用性会明显下降。只要有一个Redis实例不可用或者网络分区就可能出现少数派拿到锁的诡异状态。我给普通电商业务的选择是不引入红锁用数据库兜底。比如库存字段用UPDATE ... WHERE stock #{count}做原子扣减锁保证并发快速失败数据库保证最终正确。这样即使极端情况下锁失效最坏是数据库更新失败返回友好提示而不是数据常态错乱。5.3 锁粒度全局锁与Key粒度锁的性能差距锁粒度是分布式锁设计里最影响性能的维度。我之前见过一个优惠券项目给用户积分扣减加锁时用了points:deduction这个全局key。结果每次任意用户扣积分全系统所有用户都在这把锁后面排队高峰期接口RT直接破秒。正确做法是将锁key细化到业务实体维度points:{userId}:deduction。这样不同用户之间完全无竞争只有同一个用户并发操作自己的积分时才需要排队。锁粒度从全局串行变成了按用户串行吞吐量成倍提升。同理商品库存应该用stock:{skuId}而不是stock:all订单操作可以用order:{orderId}。加锁之间最好只竞争真正共享的那份数据。锁粒度也不是越细越好。如果业务一次要操作多个账户比如A转账给B只锁A或者只锁B都可能造成中间状态不一致。这种情况适合用RedissonMultiLock一次性锁定多个资源或者定义一个组合key如transfer:{fromUserId}:{toUserId}从设计上规避死锁风险。5.4 排查分布式锁问题的完整链路线上遇到分布式锁相关问题时我的排查顺序是看应用日志中锁相关关键日志加锁成功与否、等待时长、是否进入catch异常路径。用Redis客户端观察锁key的变化规律TTL是不是在动态变化HKEYS里有多少个field。复现并发场景压测工具模拟多节点同时请求在加锁和解锁前后打印当前线程ID、节点IP。核对代码中leaseTime、waitTime的设置与业务最长耗时的关系。如果怀疑锁丢失检查Redis主从状态、哨兵切换记录。这套排查方式能覆盖绝大多数锁提前消失锁抢不到锁不释放的问题。定位耗时操作时最简单的方式是把进入临界区、退出临界区打点看业务代码真正花了多少时间和锁的leaseTime比较一下答案往往很快就出来了。6. 把锁用好之后面试常追问的几个底层话题写到这里Redisson分布式锁的入门和实战要点基本都覆盖了。这一章我把面试里常被追问的几个底层话题串一遍既是加深理解也是帮自己梳理知识体系。6.1 可重入到底是怎么实现的一句话版本Redisson的锁在Redis里是Hash结构field是线程标识UUID:threadIdvalue是重入次数。同一个线程再次加锁时脚本执行hincrby让计数加1解锁时hincrby让计数减1计数归零才删除锁。6.2 为什么公平锁更慢公平锁需要维护顺序Redisson公平锁在加锁时会使用zset记录等待队列无法立刻抢到锁时把线程标识写入队列并订阅解锁channel。每次解锁后都要按顺序唤醒下一个等待者额外的写操作和排序开销比非公平锁高得多。大多数业务场景不需要严格公平非公平锁的吞吐表现更好。6.3 看门狗和tryLock的交互细节面试里经常问这样一道题tryLock会不会启动看门狗准确的回答是看门狗的启动只取决于你是否显式指定了leaseTime。tryLock(3, TimeUnit.SECONDS)没有传leaseTime内部走的是leaseTime-1因此会启动看门狗tryLock(3, 10, TimeUnit.SECONDS)显式传了leaseTime就不会启动。lock(10, TimeUnit.SECONDS)同理不启动。6.4 分布式锁验证策略给锁写完自测代码后我建议至少做三轮验证第一轮并发压测比如100个线程同时扣减同一个库存key只要最终库存准确、没有负数说明互斥性达标。第二轮宕机测试拿到锁后直接kill -9模拟进程崩溃观察Redis里的锁key能否在预期时间内自动删除。第三轮超时测试把leaseTime设得很短验证看门狗是否正确续期再验证显式leaseTime场景下锁是否按指定时间过期。这三轮跑完锁的可靠性基本有底了。6.5 锁不是银弹和幂等、乐观锁配合使用分布式锁能挡掉大部分并发冲突但遇到网络分区、主从切换、长GC这类极端情况锁也可能失效。可靠的做法是再设一道兜底数据库层的唯一约束、UPDATE语句的条件判断、业务幂等表选一个就行。我通常会跟团队强调分布式锁只是提高并发正确性的第一道防线最终一致性要靠数据库约束来守。我个人在实际项目里的体会是分布式锁的成败不在API用得熟不熟而在四个问题是否想清楚了锁的是哪个资源、锁粒度够不够细、锁的最大持有时间是多久、锁没拿到时的降级策略是什么。每个问题都有明确答案再动手写代码线上基本不会出大问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进