大厂生产级Redis分布式锁:高并发下锁失效与Redisson源码解析 简介本资源是面向Java后端开发者与分布式系统学习者的生产级Redis高并发分布式锁实战源码包聚焦大厂真实场景下锁的获取、续租、释放及异常处理等核心问题适合具备一定Java与Redis基础、希望深入理解分布式锁实现原理与最佳实践的中高级开发者。压缩包共39个文件约148KB以14个java源码文件为核心辅以8个class编译文件、3个xml与2个yml配置、2个properties属性文件及jar依赖等涵盖Maven构建、Spring Boot集成与Jedis客户端连接等典型工程结构便于直接导入IDE运行调试。目前已有361人学习下载。通过研读源码读者可掌握避免死锁与活锁、保证锁性能与可靠性的具体思路理解生产环境下的配置策略与目录组织方式为高并发业务中的互斥控制提供可复用的参考实现。1. 大厂生产级 Redis 分布式锁为什么你的锁总在凌晨两点失效凌晨两点监控告警群炸了——库存扣减出现负数同一张优惠券被领了两次。翻日志发现加锁和解锁的代码都在Redisson 也引入了但问题偏偏出在 Redis 主从切换的那几十秒里。这不是玄学是分布式锁最典型的翻车现场锁还没同步到从节点主节点挂了从节点升主另一个线程立刻拿到同一把锁。基于 Java 的大厂生产级 Redis 高并发分布式锁讲的不是「怎么用SETNX加锁」这种入门题而是当 QPS 打到几万、Redis 做了主从加哨兵、业务线程池里几百个线程抢同一把锁时怎么保证锁不丢、不重、不死、不误删。它解决的是高并发场景下跨进程互斥的确定性问题适合已经写过基础分布式锁、但在生产环境被超时、续期、主从切换、锁重入这些问题反复折磨的 Java 后端。源码解析的意义在于你得知道 Redisson 的tryLock内部到底发了什么 Lua、看门狗线程怎么续期、解锁时为什么必须校验线程标识才能在出问题时改得动、调得准。2. 从 SETNX 到 Redisson生产级锁的四个硬指标2.1 手写 SETNX 锁为什么扛不住高并发很多人第一次写分布式锁都是这样起步的// 最朴素的写法问题很多 public boolean lock(String key, String value) { Boolean result redisTemplate.opsForValue() .setIfAbsent(key, value); // 等价于 SETNX return Boolean.TRUE.equals(result); }这段代码能跑通单机测试但放到生产环境至少有三个致命伤。第一没有过期时间一旦业务线程在解锁前宕机这把锁就永久留在 Redis 里后续所有请求全部阻塞。第二加锁和设置过期时间是两步操作不是原子的中间宕机同样死锁。第三解锁时直接DEL key不校验持有者A 线程业务超时后锁自动过期B 线程拿到锁A 线程执行完又把 B 的锁删了。正确的起步写法至少要变成这样// 加锁SET key value NX PX 30000一条命令原子完成 public boolean lock(String key, String requestId, long expireMs) { Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, requestId, expireMs, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); } // 解锁Lua 脚本保证「判断删除」原子性 private static final String UNLOCK_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public boolean unlock(String key, String requestId) { Long r redisTemplate.execute( new DefaultRedisScript(UNLOCK_LUA, Long.class), Collections.singletonList(key), requestId); return r ! null r 0; }requestId一般用UUID 线程ID拼出来保证每个线程的锁标识唯一。expireMs要大于业务最大耗时但也不能太大否则锁释放延迟会拖垮吞吐。解锁用 Lua 是因为GET和DEL之间如果插入其他操作判断就失效了Redis 执行 Lua 是单线程原子的这是生产级锁的基本功。2.2 Redisson 的看门狗和可重入是怎么实现的手写锁解决了原子性和误删但还有一个绕不开的问题业务执行时间超过锁过期时间怎么办。Redisson 的方案是看门狗watchdog自动续期。默认情况下如果你用lock()不传过期时间Redisson 会把锁的leaseTime设为 30 秒然后启动一个后台定时任务每 10 秒leaseTime / 3检查一次如果锁还被当前线程持有就重置过期时间为 30 秒。可重入的实现靠 Redis 的 Hash 结构。锁的 value 不是一个字符串而是一个 Hashkey: lock:order:123 value: { uuid:threadId: 2 // 重入次数 }每次同一个线程再次加锁hincrby把重入次数加一解锁时减一减到零才真正DEL。这样同一线程可以多次获取同一把锁不会自己把自己锁死。Redisson 加锁的核心 Lua 逻辑大致是这样的// Redisson 加锁 Lua 简化版理解逻辑即可 // KEYS[1] 锁名, ARGV[1] leaseTime, ARGV[2] 线程标识 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]); // 返回剩余时间外层自旋重试外层 Java 代码拿到pttl后会订阅一个 channel 等待锁释放消息而不是无脑自旋这样在高并发下能大幅降低 Redis 压力。这也是 Redisson 比手写锁更适合生产的原因它把等待、重试、续期、重入都封装好了你只需要关注业务逻辑和参数配置。2.3 生产环境必须调的几个参数引入 Redisson 后默认配置不能直接上生产。下面这张表是我在几个项目里反复调过的参数列出来供参考参数默认值生产建议说明lockWatchdogTimeout30000ms30000~60000ms看门狗续期基准太短会频繁续期太长故障恢复慢leaseTime-1启用看门狗显式设置 10~30s如果业务耗时可控建议显式设置避免看门狗线程堆积tryLock等待时间0根据业务设 100~500ms不设等待时间拿不到锁直接失败适合快速失败场景tryLock持有时间-1与业务超时对齐超过这个时间自动释放防止死锁连接池connectionMinimumIdleSize1032 起步高并发下连接不够会直接抛异常重试策略无最多 3 次间隔 100ms避免无限重试打爆 Redis配置示例Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(32) .setConnectionPoolSize(64) .setTimeout(3000); RedissonClient client Redisson.create(config); RLock lock client.getLock(lock:order:123); try { // 最多等 200ms拿到后 15s 自动释放 boolean ok lock.tryLock(200, 15000, TimeUnit.MILLISECONDS); if (!ok) { throw new BizException(系统繁忙请稍后重试); } // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }isHeldByCurrentThread()这个判断很关键。如果tryLock没成功你直接unlock会抛IllegalMonitorStateException把 finally 块变成新的故障点。这个坑我见过不止一次线上日志里一堆解锁异常其实就是没加这个判断。3. 主从切换下的锁失效RedLock 到底要不要上3.1 主从架构下锁丢失的完整链路Redis 主从复制是异步的。客户端写主节点成功主节点返回 OK但这时候数据还没同步到从节点。如果主节点在这之后挂了哨兵把从节点升为主新主节点上没有这把锁另一个线程就能立刻加锁成功。完整链路是这样的线程 A 向主节点发送SET lock:order NX PX 30000主节点写入成功并返回 OK。主节点在把这条命令同步给从节点之前宕机。哨兵检测到主节点故障把从节点提升为新主节点。线程 B 向新主节点发送同样的加锁命令因为新主节点没有这条数据加锁成功。此时线程 A 和线程 B 同时持有同一把锁互斥失效。这个窗口期通常很短几十毫秒到几秒但在高并发下足够造成超卖。很多团队第一次遇到这个问题时第一反应是「Redis 不是有持久化吗」但 AOF 和 RDB 都救不了这个场景因为故障发生在同步之前数据根本没落到从节点。3.2 RedLock 的争议与适用边界Redis 作者 antirez 提出的 RedLock 方案是向 N 个独立的 Redis 节点通常 5 个依次加锁如果在大多数节点N/21上加锁成功且总耗时小于锁的有效时间就认为加锁成功。解锁时向所有节点发送解锁请求。// Redisson RedLock 用法 RLock lock1 client1.getLock(lock:order:123); RLock lock2 client2.getLock(lock:order:123); RLock lock3 client3.getLock(lock:order:123); RedissonRedLock redLock new RedissonRedLock(lock1, lock2, lock3); try { boolean ok redLock.tryLock(200, 15000, TimeUnit.MILLISECONDS); if (!ok) { throw new BizException(获取锁失败); } // 业务逻辑 } finally { if (redLock.isHeldByCurrentThread()) { redLock.unlock(); } }但 RedLock 并不是银弹。分布式系统专家 Martin Kleppmann 就质疑过RedLock 依赖各节点时钟大致同步如果某个节点发生时钟漂移锁的有效期判断就会出错而且 RedLock 没有 fencing token 机制即使加锁成功后续的存储操作也可能因为 GC 停顿而乱序执行。我的实际经验是如果你的业务对一致性要求极高比如金融扣款不要只靠 Redis 锁应该在数据库层加唯一约束或乐观锁版本号做兜底。RedLock 适合那些「锁失效会导致重复劳动但不至于资损」的场景比如防止重复发送通知、防止重复刷新缓存。对于库存扣减这类场景Redis 锁 数据库行锁 唯一索引三层防护才稳妥。3.3 用 fencing token 堵住锁失效的最后一环fencing token 的思路是每次加锁成功时返回一个单调递增的版本号。后续写存储时带上这个版本号存储层拒绝版本号比已记录版本号小的写入。// 加锁时获取一个递增 token Long token redisTemplate.opsForValue().increment(lock:token:order:123); // 写数据库时带上 token int updated jdbcTemplate.update( UPDATE orders SET status ?, lock_token ? WHERE id ? AND lock_token ?, PAID, token, orderId, token); if (updated 0) { // 说明有更晚的 token 已经写入当前操作作废 throw new BizException(操作冲突请重试); }这样即使锁短暂失效两个线程同时拿到锁数据库层的lock_token ?条件也能保证只有最新的操作生效。代价是每次写操作多一次版本比较但换来的是确定性对于核心链路值得。4. 高并发下锁等待、续期与连接池的避坑清单4.1 锁等待把线程池打满现象接口响应时间从 50ms 飙到 5s线程池活跃线程数打满大量请求超时。原因tryLock不传等待时间或者等待时间设得太长几百个线程同时阻塞在等锁上Tomcat 线程池被占满后续请求连进都进不来。解决给tryLock设置合理的等待时间一般 100~500ms 足够。超过这个时间还没拿到锁直接返回失败让前端重试或走降级逻辑。同时给锁等待加熔断比如用 Sentinel 或 Hystrix 对获取锁的操作做限流。// 等待 200ms拿不到就快速失败 boolean ok lock.tryLock(200, 15000, TimeUnit.MILLISECONDS); if (!ok) { // 记录失败次数触发告警 metrics.counter(lock.fail).increment(); throw new BizException(系统繁忙); }4.2 看门狗线程泄漏现象应用运行几天后内存缓慢增长jstack 看到大量Redisson Timer线程。原因用了lock()不传 leaseTime每次加锁都启动一个看门狗定时任务。如果业务代码在unlock之前抛异常且没有在 finally 里正确释放看门狗会一直续期锁永远不释放定时任务也永远不取消。解决第一尽量用tryLock(waitTime, leaseTime, unit)显式设置持有时间不依赖看门狗。第二unlock必须放在 finally 里并且加isHeldByCurrentThread()判断。第三监控 Redisson 的定时任务数量超过阈值告警。4.3 连接池不够导致的假死现象Redis 命令超时但 Redis 服务端 CPU 和内存都很正常。原因Redisson 默认连接池最小空闲连接是 10高并发下不够用大量线程在等待连接表现为命令超时。解决把connectionMinimumIdleSize调到 32 以上connectionPoolSize调到 64 以上具体数值根据 QPS 和命令平均耗时估算。同时设置timeout为 3000ms避免无限等待。config.useSingleServer() .setConnectionMinimumIdleSize(32) .setConnectionPoolSize(64) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(100);4.4 锁粒度太粗导致的性能塌方现象系统 QPS 上不去Redis 监控显示大量pttl和exists命令。原因用一把大锁锁住了整个业务比如lock:order锁住所有订单操作导致所有请求串行化。解决把锁粒度拆细按业务主键加锁比如lock:order:{orderId}。这样不同订单之间互不影响并发度大幅提升。但要注意锁 key 的数量太多 key 会增加 Redis 内存和过期管理成本一般控制在百万级以内。4.5 解锁失败导致的锁残留现象某个 key 在 Redis 里一直存在TTL 显示 -1后续请求全部失败。原因加锁时没有设置过期时间或者看门狗续期后业务线程崩溃锁没有释放。解决所有加锁操作必须带过期时间哪怕是看门狗模式也要设置lockWatchdogTimeout。同时加监控对 TTL 为 -1 的锁 key 告警发现后手动清理或重启应用。更彻底的做法是在 Redis 层面配置maxmemory-policy但这不是根本方案根本方案还是代码里保证锁一定会释放。5. 用 JMH 压测你的锁从吞吐量到 P99 的验证方法写完锁的代码只是第一步能不能上生产得用数据说话。我一般用 JMH 做微基准测试重点看三个指标吞吐量、P99 延迟、锁失败率。先加依赖dependency groupIdorg.openjdk.jmh/groupId artifactIdjmh-core/artifactId version1.37/version /dependency dependency groupIdorg.openjdk.jmh/groupId artifactIdjmh-generator-annprocess/artifactId version1.37/version /dependency然后写一个压测类模拟 32 个线程抢同一把锁BenchmarkMode(Mode.Throughput) OutputTimeUnit(TimeUnit.SECONDS) State(Scope.Benchmark) Threads(32) Warmup(iterations 3, time 2) Measurement(iterations 5, time 3) public class RedisLockBenchmark { private RedissonClient client; private RLock lock; Setup public void setup() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(32) .setConnectionPoolSize(64); client Redisson.create(config); lock client.getLock(bench:lock); } Benchmark public void testTryLock() { try { if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) { try { // 模拟 1ms 业务耗时 Thread.sleep(1); } finally { lock.unlock(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } TearDown public void tearDown() { client.shutdown(); } public static void main(String[] args) throws Exception { Options opt new OptionsBuilder() .include(RedisLockBenchmark.class.getSimpleName()) .forks(1) .build(); new Runner(opt).run(); } }跑完之后重点看Throughput那一行。单节点 Redis、32 线程、1ms 业务耗时的情况下Redisson 的吞吐量大概在 8000~12000 ops/s 之间。如果明显低于这个数检查连接池是否够用、锁等待时间是否设得太长、Redis 是否开了慢查询。P99 延迟用Mode.SampleTime再跑一次看p99那一列。生产环境要求 P99 小于 50ms如果超过说明锁竞争太激烈需要拆锁粒度或者改用无锁方案。还有一个容易被忽略的验证点主从切换演练。用DEBUG SLEEP或者直接 kill 主节点观察哨兵切换期间锁的行为。如果切换后出现两个线程同时持有锁说明你的方案没有 fencing token 兜底需要补上。最后说个我自己的习惯每次上线新的锁逻辑之前先在预发环境用redis-cli monitor看一遍实际发出的命令确认加锁、续期、解锁的 Lua 脚本和预期一致。这个动作花不了五分钟但能拦住大部分参数配错和版本不兼容的问题。希望帮到你。本文还有配套的精品资源点击获取