ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redisson分布式锁原理与实战:从Lua脚本到七种锁类型全解析

Redisson分布式锁原理与实战:从Lua脚本到七种锁类型全解析 先说明一下背景。上个月我在核对大促订单时发现同一个用户在同一秒内提交了两笔相同商品的订单库存多扣了一次。翻日志定位到扣减代码发现同事为了优化性能把原来基于Redisson的可重入锁换成了自己手写的set nx锁结果经典的锁过期和误删组合拳一起打过来直接超卖。这不算个例。分布式锁这个技术点网上教程一抓一大把但真正能扛住生产环境考验的并不多。Redisson的厉害之处在于它把分布式锁的底层细节——原子性、锁续期、重入计数、公平排队——都封装成了开箱即用的API同时又提供了可重入锁、公平锁、读写锁、联锁、红锁、信号量、闭锁这一整套锁家族。这篇文章我不打算做API手册翻译而是把这几年用Redisson的实践经验做一个系统梳理底层Lua脚本和看门狗是怎么配合的每种锁的内部存储结构有什么差异waitTime和leaseTime到底该怎么配以及哪些业务场景对应哪把锁。适合正在用Redis做分布式锁的Java后端工程师也适合准备分布式锁面试题、想搞清楚原理而不是背答案的开发者。1. 手写set nx锁为何总在线上翻车我踩过的两个真实误区先说结论set nx px这个命令本身没错它确实能保证加锁和设置过期时间的原子性。但我见过太多团队在它上面栽跟头原因不是命令选错而是锁的完整生命周期远不止加锁这一步。1.1 加锁容易解锁的归属校验才是第一个坑很多初版代码长这样// 加锁 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, 1, 10, TimeUnit.SECONDS); // 业务 doBiz(); // 解锁 redisTemplate.delete(lock:order: orderId);表面看加锁带过期时间不会死锁业务结束就删key。问题是线程A加锁后如果A执行超过10秒锁自动释放此时线程B拿锁进入业务A执行完把锁删掉——删的是B的锁。A还没释放锁的时候B根本拿不到问题恰恰出在A超时之后。正确的解锁必须校验value是自己的标识String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { doBiz(); } finally { String current redisTemplate.opsForValue().get(lock:order: orderId); if (requestId.equals(current)) { redisTemplate.delete(lock:order: orderId); } } }但这又引入新问题判断value再删除这两步不是原子的判断完、删除前锁恰好过期别的线程set了新的value当前线程仍然会把别人的锁删掉。要真正解决必须用Lua脚本把两步合并成一个原子操作。这正是Redisson做的第一件事。1.2 锁过期了业务还没跑完谁来续期第二个高频翻车点锁的过期时间怎么定设短了业务没跑完锁就没了设长了客户端宕机后锁迟迟不释放后续线程全部阻塞。有人设60秒想着一劳永逸结果碰到一条SQL慢查询跑了2分钟锁在40秒时就过期了有人设10秒觉得够用结果GC停顿或者调了一次远程接口业务没结束锁却先释放了。Redis官方之所以在setnx之后追加px参数本质就是承认锁必须带过期时间防的是持有锁的客户端宕机导致的死锁。但防宕机和业务正常运行但还没执行完是两码事如果只用set nx px没有续期机制锁的超时和业务的实际执行时长永远在赛跑。Redisson的看门狗watch dog就是专门解决这个问题的只要客户端还活着且没释放锁后台会定时把锁的过期时间重置回30秒。这样锁的有效期永远大于业务执行时间又能在客户端宕机后自动释放。1.3 主从切换时锁消失单机锁模型的天然缺陷再往下挖一层是Redis集群架构的问题。set nx px写入的是Master节点如果Master在锁未同步到Replica之前宕机Replica晋升为Master后原锁就丢了。此时另一个客户端可以成功加同一把锁进入临界区。这个问题不是Lua脚本能解决的需要引入多节点锁方案比如Redisson的红锁。上面这三个坑基本概括了为什么很多团队最终放弃手写方案你不仅要写加锁和解锁还要写续期、写原子删除、处理主从切换每一条都是锁正确性的核心边界。2. Lua脚本和看门狗Redisson加解锁的底层骨架Redisson不是简单封装set nx px它的锁数据结构和加解锁流程是重新设计过的。理解了这一层后面所有锁类型的差异就很好懂了。2.1 锁的存储结构Redis里的Hash表Redisson的锁在Redis里是一个Hash结构不是普通字符串key锁资源名称比如order:pay:10086field客户端标识由UUID:ThreadId组成value重入计数每重入一次加1为什么要用Hash而不是直接SET key value因为要支持同一个线程重复加锁也就是可重入。重入一次就把value加1锁对象里记录这个计数。如果Redis只存一个字符串重入时就需要额外的字段记录持有者Hash天然适合做这个事。2.2 加锁Lua脚本为什么必须合并成原子操作Redisson加锁的核心逻辑是下面这段Lua脚本简化版-- KEYS[1]: 锁名称ARGV[1]: 过期时间ARGV[2]: 客户端标识 if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, 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]);几个return值分别代表什么面试常问返回nil加锁成功或者重入成功。返回剩余过期时间pttl加锁失败说明锁被其他客户端持有。关键点在于检查锁是否存在→写入Hash→设置过期时间这三步是在Redis服务端用Lua原子执行的不存在并发窗口。Java客户端拿到的解锁脚本同样用Lua完成校验field→减计数→删除Key三步操作。这就是上面说的判断再删除原子问题的标准解法。2.3 看门狗只有不手动指定leaseTime时才生效看门狗是Redisson最容易被误解的机制。默认情况下调用lock()不带过期时间Redisson会给锁设置30秒的leaseTime同时启动一个后台定时任务每10秒检查一次如果锁还在就把过期时间重置为30秒。这样设计的好处业务方法执行多久锁就自动续期多久不会因为业务执行时间长而提前失效。客户端宕机了看门狗随JVM一起停止锁最多30秒后自动释放不会死锁。注意一个细节如果你手动指定了leaseTime比如lock(10, TimeUnit.SECONDS)Redisson不会启动看门狗因为此时开发者已经明确表达了锁最多活10秒的意图续期逻辑交给谁都不合适。这也是我在生产环境最强调的一点要么完全交给看门狗要么自己精确控制超时时间两者不要混用。2.4 解锁流程重入计数归零才真正删除解锁同样是一段Lua脚本-- ARGV[3]: 客户端标识 if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; end; 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]); return 1; end;这段脚本解决两个问题首先校验客户端标识确保只能释放自己持有的锁其次处理重入计数比如锁被重入了3次那么需要解锁3次计数才归零第3次解锁时才会真正删除Key。如果你只调用一次unlock()锁不会释放——这是很多初学者第一次用可重入锁时踩到的坑。理解了这套底层骨架后你会发现Redisson所有锁类型都是在这个Hash加解锁机制上做变体的公平锁加了排队队列读写锁加了读写mode标识联锁和红锁则是多实例的加锁组合。3. 七种锁逐个拆解从可重入锁到红锁的实现差异Redisson之所以说多样化是因为它把并发编程里能想到的锁类型都在Redis上实现了一遍。下面逐个说清楚它们的原理、适用边界和典型代码。3.1 可重入锁最常用的默认选择标准用法RLock lock redissonClient.getLock(order:pay: orderId); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }业务里再次调用lock.lock()不会阻塞只是让Redis里的重入计数加1。这个锁默认是非公平的一个线程抢到锁后其他等待线程不按先来后到的顺序获取。可重入锁需要注意一个使用习惯锁对象要基于业务维度设计Key比如按订单维度、按用户维度、按商品维度而不是全系统共享一把锁。锁Key越粗并发能力越低这一点放到第5章详细说。3.2 公平锁先来后到现场维稳RLock fairLock redissonClient.getFairLock(fair:anyLock); fairLock.lock();公平锁在Redis里额外维护了一个等待队列抢锁失败的线程按到达顺序排队锁释放后队列前端的线程先获得锁。注意缺点因为要维护队列性能比默认锁低而且每个等待线程都要在Redis上有一份排队记录。业务上如果只有突发流量才可能出现锁竞争没必要用公平锁但如果涉及用户付款、抢名额这类顺序敏感的操作公平锁能避免线程饿死。3.3 读写锁读读共享、读写互斥RReadWriteLock rwLock redissonClient.getReadWriteLock(data:config); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock(); // 读线程 readLock.lock(); // 写线程 writeLock.lock();读写锁是JVM的ReentrantReadWriteLock的分布式版本。内部同样用Hash存储但多了一个mode字段值为read或write。读锁加锁时如果当前没有写锁可以多个读线程同时持有锁写锁加锁时必须等待所有读锁释放。典型的应用场景是热点配置、商品详情这类读远多于写的数据。比如商品详情缓存的更新多个线程可以同时读取只有缓存刷新线程需要拿到写锁重建数据。3.4 联锁MultiLock多把锁同时锁RLock lock1 redissonClient.getLock(lock:resource:1); RLock lock2 redissonClient.getLock(lock:resource:2); RLock lock3 redissonClient.getLock(lock:resource:3); RedissonMultiLock multiLock new RedissonMultiLock(lock1, lock2, lock3); multiLock.lock(); // 业务 multiLock.unlock();联锁的含义是所有参与的锁必须全部成功加锁整体才算加锁成功只要有一把失败已经成功的锁会全部释放。它适用于要同时操作多个独立资源的场景比如跨账户转账时要同时锁住转出账户和转入账户避免死锁。3.5 红锁RedLock多节点过半加锁Config config new Config(); config.useSingleServer().setAddress(redis://node1:6379); RedissonClient client1 Redisson.create(config); // 创建三个独立的 RedissonClientclient2、client3 同理 RLock lock1 client1.getLock(red:lock); RLock lock2 client2.getLock(red:lock); RLock lock3 client3.getLock(red:lock); RedissonRedLock redLock new RedissonRedLock(lock1, lock2, lock3); redLock.lock(); redLock.unlock();红锁的要点是必须在相互独立的Redis节点上加锁不是主从复制的关系超过半数的节点加锁成功才算持有分布式锁。它解决的是主从切换丢锁问题——Master挂了之后副本上位在过半机制下锁仍然在其他节点上存活。红锁在业内是有争议的它增加了部署复杂度和锁获取延迟但换来的是更高概率的锁安全。我的看法是普通业务并发量、允许少量重复执行的任务完全不需要红锁但对账、扣款这类不能容忍锁丢失的关键链路如果你坚持用Redis而不是ZooKeeper或数据库实现锁红锁是值得考虑的折中方案。3.6 信号量和闭锁限流闸门与等待集合// 信号量限流 RSemaphore semaphore redissonClient.getSemaphore(semaphore:traffic); semaphore.trySetPermits(100); semaphore.tryAcquire(); // 拿不到许可就返回 false semaphore.release(); // 闭锁等待 N 个任务完成 RCountDownLatch latch redissonClient.getCountDownLatch(latch:batch); latch.trySetCount(3); latch.await(); // 等待计数归零 latch.countDown(); // 每个任务完成后调用信号量不是互斥锁它更像是一个分布式令牌桶适用于同一时刻最多允许N个并发的限流场景。trySetPermits在分布式环境下只应该被调用一次否则可能把许可数重置这点要注意。闭锁则是等待多个节点/线程完成后再统一执行的场景比如数据批量处理时要等3个分片都处理完再汇总。3.7 锁类型快速对照锁类型核心特征适用场景不适用场景可重入锁同一线程可重复加锁大部分互斥临界区顺序敏感的抢单公平锁按申请顺序排队名额分配、抢购高吞吐并发场景读写锁读读共享、读写互斥读多写少的数据一致性写多场景写锁会成为瓶颈联锁 MultiLock多资源全部加锁跨账户转账、多表操作单资源场景纯属浪费红锁 RedLock多节点过半加锁不容忍锁丢失的关键链路普通业务成本和延迟偏高信号量固定并发许可数限流、批量任务并发控制真正意义上的互斥闭锁等待多个任务完成多分片任务汇总互斥、限流这张表建议收藏。面试中问到Redisson有哪些锁按这个表格回答既系统又有场景比单纯罗列API名称有说服力得多。4. waitTime与leaseTime锁参数配置的实战逻辑Redisson的tryLock是最容易用错的方法之一两个时间参数直接决定锁行为的边界条件。lock.tryLock(3, 30, TimeUnit.SECONDS);第一个参数waitTime抢锁等待时间超过这个时间没抢到锁就放弃返回false。第二个参数leaseTime锁的持有时间上限超过后锁自动释放。4.1 waitTime设置的两种极端很多人习惯tryLock(0, ...)抢不到直接失败。这适合拿不到锁就快速降级的场景比如缓存重建、非核心数据同步。但如果是扣库存、付款这类必须成功的操作waitTime0会导致大量请求直接打到失败分支用户体验很差。此时应该设置一个合理的等待时间比如2~3秒让请求在锁外排队而不是立即失败。反过来waitTime设得太大也有问题高并发下大量线程阻塞在Redis的锁等待上每个线程都是JVM线程线程池被打满后其他业务请求也会受到牵连。我建议根据整个请求链路的超时时间反推比如接口整体允许1秒返回那么waitTime尽量不要超过800毫秒。4.2 leaseTime指定了就失去了看门狗保护这是最容易被忽视的细节。前面说过leaseTime一旦显式指定看门狗不启动锁到期就释放。如果你设置leaseTime30但业务最坏情况下要跑40秒锁会在第30秒被Redis删除其他线程进来临界区保护失效。比较稳健的实践是如果使用lock()不带参数则完全信任看门狗续期如果用tryLock(waitTime, leaseTime, ...)手动指定leaseTime必须保证leaseTime严格大于预估的最长业务执行时间并且不接受应该够了吧这种估算方式。线上出过事的场景基本都是手工设了个自以为很大的值结果慢查询让它破了防。4.3 锁内代码的时长红线不管参数怎么配锁内做耗时操作都是首要禁忌。锁的核心价值是保护临界区临界区越大锁竞争越激烈整个系统的吞吐量越低。我自己有个经验值供参考锁内纯内存操作微秒到毫秒级可以放心加锁。锁内做一次Redis操作毫秒级可接受。锁内调用RPC或查询慢数据库超过几十毫秒甚至上百毫秒就不要放在锁里了。要么把耗时操作移到锁外通过状态机或异步补偿保证最终一致要么拆小锁粒度尽量缩短持锁时间。一个经典反面案例锁内调用了外部支付接口接口平均耗时300毫秒超时5秒不返回锁在此期间一直不释放所有请求排长队最终把Redis连接池打满。最后改成锁内只做扣减状态的标记支付结果由回调异步更新吞吐量直接翻了四五倍。5. 业务场景对照什么时候该用哪种锁分布式锁的使用场景网上列出来的不算少缓存击穿、定时任务调度、幂等防重、库存扣减。但具体到每一个场景锁的Key、锁类型、参数配置各不相同我按实际经验整理一下。5.1 库存扣减与防超卖首选可重入锁Key按商品维度设计如stock:reduce:{skuId}。注意锁内只做查库存→扣减→更新这几个操作不做任何IO或通知。高并发秒杀场景需要提前做流量控制锁只是最后一层兜底不能指望锁扛住全部流量。如果单商品维度的锁仍然成为瓶颈可以考虑分段库存把库存拆成N个库存桶每个桶一把锁用一致性哈希把请求分到不同桶上。这是锁粒度优化的进阶玩法。5.2 订单防重与幂等控制适合可重入锁Key用业务幂等键比如order:create:{userId}:{bizSerialNo}。同一用户同一业务序列号的请求只能有一个进入下单流程其余直接返回重复提交。这类场景的锁要特别注意锁Key一旦设计过粗比如只按userId维度那么同一个用户同时下两单就会被错误地串行化用户体验会受影响。锁的粒度应该精确到防重的最小粒度通常是幂等号而不是用户ID。5.3 分布式定时任务调度多个服务节点同时跑同一个定时任务时可以用可重入锁Key为任务名如job:settle:20260601。抢到锁的节点执行任务其他节点直接跳过并记录日志方便排查为什么这次任务没执行。这个场景我特别建议关心一下任务的幂等性如果任务执行到一半节点挂了锁会过期待释放但任务是半完成的。要么任务本身设计成可重入/可续跑要么在任务开始时写一个执行状态标记让下一个节点能接续处理否则不能只靠锁兜底。5.4 缓存击穿与热点数据保护缓存Key失效瞬间大量请求同时回源查数据库。此时可以用信号量限制回源并发数比如semaphore:cache:rebuild许可数为1同一时刻只允许一个线程回源重建缓存其余线程短暂等待后直接读新缓存更严格的场景可以直接用可重入锁把回源动作串行化但注意waitTime要设小拿不到锁的请求走读旧缓存快速降级。在这个场景里读写锁也是一种选择读线程不加锁直接读缓存写线程持有写锁重建缓存避免所有请求都在抢同一把锁。但前提是读多写少且缓存重建不是高频操作。5.5 跨资源操作与关键链路涉及多个账户、多张表、多个服务的联合操作用联锁同时锁定所有资源防止死锁和部分成功。对账、扣款、大额转账这类不可容忍锁丢失重复执行的场景才上红锁或基于ZooKeeper/数据库实现的分布式锁。用Redis做这类锁不是不行但要明确它保证的是大概率正确而不是绝对正确。下面这组选择逻辑是我给团队定的规矩直接照着选就行默认情况可重入锁。要求先到先得公平锁。读多写少且能容忍读并发读写锁。必须同时锁多个资源联锁。资源极其关键且Redis集群不可靠红锁。不是要互斥而是要限流信号量。等待多个任务完成再聚合闭锁。6. 面试追问与我的避坑记录分布式锁是Java面试里的高频题目面试官通常不问你会不会用Redisson而是从手写锁逐步追问到底层原理。下面这些问题是我被问过、也拿来问过别人的答案可以按前面几章的内容组织。6.1 高频分布式锁面试题整理Redis分布式锁为什么用Lua脚本答保证加锁、解锁组合操作在同一时刻只有一个客户端能执行防止并发导致锁归属校验和删除之间被插入其他操作。Redis锁过期但业务还在执行怎么办答降低过期时间带来的副作用用看门狗定期续期或者设置足够大的leaseTime但严格评估业务最大耗时时长。主从切换下锁丢失如何应对答严格互斥场景引入红锁多节点过半机制或者换用ZooKeeper/数据库行锁等强一致方案。可重入锁如何实现答Redis中锁以Hash存储field是客户端标识value是重入计数释放一次减一减到0才删除Key。公平锁的原理答Redisson在Redis里维护等待队列按请求顺序排队获取锁缺点是排队本身有性能开销。锁的粒度如何控制答Key按业务最小维度设计锁内只处理临界区操作耗时IO移出锁外。6.2 我实际踩过的坑unlock前必须判断持有状态这是我线上遇到最多的问题代码形态if (lock.tryLock(3, TimeUnit.SECONDS)) { doBiz(); lock.unlock(); }看起来没问题但doBiz()一旦抛出异常unlock()根本执行不到锁虽然会过期待释放但在过期之前整个服务可能已经因为锁累积而雪崩。正确的写法一定是try-finallyif (lock.tryLock(3, TimeUnit.SECONDS)) { try { doBiz(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }isHeldByCurrentThread()在用了看门狗或者手动过期之后仍然可靠吗它判断的是当前JVM线程是否持有该锁对象如果锁已经在Redis侧过期此时返回false不会重复解锁抛异常。这个判断不会增加额外的Redis调用放心用。6.3 另一个坑手动续期掩盖业务效率问题有人喜欢手动开一个定时任务去续期理由是防止业务慢导致锁过期。思路对齐但方向错了手动续期是头痛医头它掩盖的是锁内业务过于耗时这个根本问题。与其让锁无限续期护着一个3秒的临界区不如把临界区缩短到300毫秒。我在排查一个订单中心接口时看到过典型的续期地狱业务里嵌套了三次本地事务、两次外部RPC、一次报表同步锁从头锁到尾续期线程每10秒续一次。后来把这些操作全部移出临界区锁持有时间从平均5秒降到几十毫秒接口P99耗时反而下来了三倍。分布式锁是保护正确性的不是给慢代码兜底的。6.4 锁与事务的配合问题最后说一下锁和Spring事务的关系。常见的错误写法是在Transactional方法内部加锁。这样事务可能在锁释放之后才提交其他线程拿到锁后读到的事务隔离条件下可能看不到刚提交的数据出现拿到了锁但读旧数据的诡异现象。推荐的姿势是锁在事务外围即先加锁再开启事务事务提交后再释放锁。如果确实无法避免在事务内加锁要确保释放锁之前事务已提交比如在事务方法外部调用加锁逻辑lock.lock(); try { transactionalService.doBiz(); } finally { lock.unlock(); }这样锁的持有时间覆盖了整个事务其他线程拿到锁时上一个事务已经提交看到的一定是最新数据。我个人现在的默认姿势是能用可重入锁就不手写任何自定义锁逻辑lock()不带leaseTime完全交给看门狗托管锁内代码尽量控制在毫秒级超过300毫秒的操作全部移到锁外解锁一律放在finally里且用isHeldByCurrentThread()做二次校验。做到这三条线上因为分布式锁翻车的概率就非常低了。
返回列表