简介:这是一份面向Java后端开发者与分布式系统学习者的实战源码包,聚焦大厂生产环境下Redis高并发分布式锁的实现与解析,适合具备一定Java与Redis基础、希望深入理解锁机制的中高级工程师。压缩包共39个文件,约148KB,包含14个java核心源码、8个class编译文件、3个xml与2个yml配置、2个properties属性文件及jar依赖等,覆盖锁的获取、续租、释放与异常处理等关键逻辑,并配有Git忽略规则与Maven构建配置,目录结构清晰。目前已有361人学习下载。通过研读源码,读者可掌握Redis命令实现锁、规避死锁与活锁、保障锁性能与可靠性的思路,理解Jedis客户端连接及Spring Boot集成方式,并借助模拟高并发场景的实战演练巩固理论知识,为实际业务中的分布式互斥设计提供可复用参考。
1. 大厂生产级 Redis 分布式锁到底在解决什么问题
电商大促零点,库存扣减接口 QPS 冲到 8 万,数据库里同一件商品被超卖了 300 单。事后复盘发现:代码里加了synchronized,但服务部署了 12 个 Pod,单机锁根本锁不住跨进程的并发。这就是分布式锁最典型的入场场景——Java 高并发下,多实例争抢同一份共享资源。
这个标题里的「生产级」三个字是关键。网上能搜到大量setnx就完事的 demo,但真到大厂流量下,它们会以各种姿势翻车:锁过期了业务还没跑完、删锁删掉了别人的锁、Redis 主从切换后锁凭空消失。这篇笔记就顺着「基于 Java 的 Redis 分布式锁」这条线,把加锁、续期、释放、Redlock 争议、以及源码里那些不起眼但决定生死的细节,一层层拆开讲清楚。适合已经会写SET NX、但还没搞明白生产环境为什么必须这么写的人。
2. 从 SETNX 到 SET NX PX:加锁原语的三次迭代
2.1 为什么setnx+expire是必翻车的写法
最早大家这么写:
// 反例:两条命令,非原子 Boolean ok = redis.setnx("lock:order:1001", "uuid-abc"); if (ok) { redis.expire("lock:order:1001", 30); // 如果这行之前进程挂了? doBusiness(); }问题很直白:SETNX和EXPIRE是两条独立命令。如果客户端在setnx成功之后、expire执行之前宕机或网络抖动,这把锁就永远没有过期时间,变成死锁,后续所有请求全部阻塞。这个窗口可能只有几毫秒,但在高并发下每天都会命中。
有人会说用 Lua 把两条命令包起来不就行了。可以,但没必要——Redis 2.6.12 之后SET命令直接支持了NX和PX参数,一条命令搞定原子加锁加过期。
2.2 生产级加锁:一条命令 + 唯一 value
public class RedisLock { private final StringRedisTemplate redis; private static final String LOCK_PREFIX = "lock:"; /** * @param key 业务键,如 order:1001 * @param requestId 本次请求唯一标识,释放锁时校验 * @param expireMs 锁自动过期时间,毫秒 */ public boolean tryLock(String key, String requestId, long expireMs) { String lockKey = LOCK_PREFIX + key; // SET key value NX PX expireMs,原子操作 Boolean ok = redis.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMs)); return Boolean.TRUE.equals(ok); } }逻辑说明:整条SET lock:order:1001 uuid-abc NX PX 30000在 Redis 单线程模型里是一个原子操作,要么成功写入并带上 30 秒过期,要么因为 key 已存在直接返回 nil。不存在「加了锁没设过期」的中间态。
参数说明:
requestId必须是每次请求唯一的,通常用UUID.randomUUID().toString()拼接线程 ID。它的作用是标识锁的持有者,释放锁时用来判断「这把锁是不是我加的」,后面 2.4 会展开。expireMs是锁的兜底存活时间。设太短,业务没跑完锁就没了;设太长,持锁进程崩溃后其他请求要干等很久。经验值:业务 P99 耗时的 3~5 倍,常见 10~30 秒。
提示:
setIfAbsent在 Spring Data Redis 里最终映射到SET ... NX PX,但不同版本对Duration的支持有差异,老版本要用setIfAbsent(key, value, timeout, TimeUnit.MILLISECONDS)。上线前用MONITOR命令确认实际发出的指令。
2.3 锁过期时间怎么定:一个被低估的参数
expireMs是分布式锁里最容易拍脑袋设错的参数。设 5 秒,一个慢 SQL 就超时;设 5 分钟,进程 OOM 后其他节点要等 5 分钟才能拿到锁,期间接口全部超时。
我的做法是分两步定:
第一步,压测拿到业务逻辑的 P99 耗时,记为T99。第二步,锁过期时间取max(T99 * 3, 10s)。乘 3 是给 GC 停顿、网络抖动、Redis 主从切换留缓冲。如果业务本身耗时不可控(比如要调外部接口),那就不能靠固定过期时间兜底,必须上看门狗续期,见第 3 章。
这里有个反直觉的点:锁过期时间不是越长越安全。过期时间越长,持锁进程崩溃后的「锁真空期」越长,系统可用性越差。它本质是在「业务跑不完」和「崩溃后恢复慢」之间找平衡。
2.4 释放锁:为什么del会删掉别人的锁
看这段血泪代码:
// 反例:直接 del public void unlock(String key) { redis.delete(LOCK_PREFIX + key); // 危险! }翻车过程是这样的:线程 A 拿到锁,过期时间 30 秒,但业务跑了 35 秒。第 30 秒锁自动过期,线程 B 拿到同一把锁开始执行。第 35 秒线程 A 执行完,调用unlock把锁删了——删的其实是线程 B 的锁。线程 C 立刻又能加锁,于是 B 和 C 同时持锁,互斥性彻底失效。
正确做法是「校验持有者 + 删除」两步,且必须原子:
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) { String lockKey = LOCK_PREFIX + key; Long result = redis.execute( new DefaultRedisScript<>(UNLOCK_LUA, Long.class), Collections.singletonList(lockKey), requestId); return result != null && result == 1L; }逻辑说明:Lua 脚本在 Redis 里整体执行,不会被其他命令打断。脚本先get出当前锁的 value,和传入的requestId比对,相等才del。这样即使锁已经过期被别人拿走,旧持有者也删不掉新持有者的锁。
参数说明:KEYS[1]是锁键,ARGV[1]是加锁时写入的requestId。注意DefaultRedisScript的返回类型要写Long.class,因为 Lua 的return 0和return 1在 Redis 协议里是整数回复,用Boolean接会抛类型转换异常,这是很多人第一次写 Lua 释放锁时的踩坑点。
3. 看门狗续期:业务没跑完锁先过期了怎么办
3.1 续期的本质是「用后台线程替业务续命」
固定过期时间解决不了「业务耗时不可控」的问题。看门狗(Watch Dog)的思路是:加锁成功后,起一个后台定时任务,每隔expireMs / 3检查一次,如果业务还在跑,就把锁的过期时间重置回expireMs。业务执行完主动释放锁时,顺便把这个定时任务停掉。
Redisson 的RLock内置了这套机制,默认lockWatchdogTimeout是 30 秒,续期间隔是 10 秒。但很多人不知道:只有调用不带超时参数的lock()才会启动看门狗,调用lock(10, TimeUnit.SECONDS)是不会续期的,到点就释放。这个差异坑过不少人。
3.2 手写一个最小可用的看门狗
不想引入 Redisson 的话,自己写一个也不复杂:
public class WatchDogLock { private final StringRedisTemplate redis; private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private volatile ScheduledFuture<?> renewTask; public boolean tryLock(String key, String requestId, long expireMs) { Boolean ok = redis.opsForValue() .setIfAbsent(key, requestId, Duration.ofMillis(expireMs)); if (!Boolean.TRUE.equals(ok)) return false; long period = expireMs / 3; renewTask = scheduler.scheduleAtFixedRate(() -> { // 只有锁还是自己的才续期 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('pexpire', KEYS[1], ARGV[2]) " + "else return 0 end"; redis.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), requestId, String.valueOf(expireMs)); }, period, period, TimeUnit.MILLISECONDS); return true; } public void unlock(String key, String requestId) { if (renewTask != null) renewTask.cancel(false); // 复用 2.4 的 Lua 释放逻辑 } }逻辑说明:续期脚本同样用 Lua 保证「校验 + 续期」原子。scheduleAtFixedRate以expireMs / 3为周期执行,这样即使某一次续期因为网络抖动失败,还有两次机会在锁过期前补上。
参数说明:period = expireMs / 3是经验值。周期太长,一次失败就可能导致锁过期;周期太短,Redis 压力大。1/3 是容错两次的平衡点。renewTask.cancel(false)里的false表示不打断正在执行的续期任务,避免续期脚本执行到一半被中断。
注意:看门狗线程池必须和业务线程池隔离,否则业务线程池打满时续期任务也排不上队,锁照样过期。这是生产环境一个隐蔽的翻车点。
3.3 续期失败时的降级策略
续期不是万能的。如果 Redis 集群整体不可用,续期脚本会一直失败,锁最终还是会过期。这时候业务线程还在跑,就会出现「锁没了但业务还在写」的情况。
我的处理方式是:续期连续失败 3 次,主动中断业务线程(通过Thread.interrupt()),让业务快速失败而不是带着「以为还持锁」的错觉继续写数据。同时打点告警,因为续期连续失败通常意味着 Redis 集群出了大问题,这比单把锁失效严重得多。
4. 避坑与排查:分布式锁上线后最常见的 5 个问题
4.1 现象:压测时锁完全不起作用,并发照样穿透
原因排查下来,九成是锁的粒度错了。比如锁键用了固定的lock:order,所有订单共抢一把锁,看起来是「锁住了」,但业务里操作的是不同订单,锁根本没起到隔离作用;反过来,如果锁键拼了requestId,那每个请求的锁键都不一样,等于没锁。
解决:锁键必须由「共享资源的唯一标识」构成,比如lock:order:{orderId}、lock:stock:{skuId}。判断标准是——两个并发请求如果会修改同一份数据,它们的锁键就必须相同。
4.2 现象:偶发IllegalMonitorStateException或释放锁返回 0
原因:释放锁时传入的requestId和加锁时不一致。常见于把requestId存在 ThreadLocal 里,但异步线程或线程池复用时没传递过去,导致释放时拿到的是别的请求的 ID。
解决:requestId要么作为方法参数显式传递,要么用TransmittableThreadLocal保证跨线程池传递。释放锁返回 0 时不要静默忽略,至少打一条 warn 日志,它往往意味着锁已经过期或被别人持有,是并发问题的早期信号。
4.3 现象:Redis 主从切换后,两把锁同时存在
原因:这是 Redis 分布式锁的经典缺陷。客户端 A 在 master 上加锁成功,master 还没把这条数据同步到 slave 就宕机了,slave 升为 master,客户端 B 在「新 master」上加锁也成功。于是 A 和 B 同时持锁。
解决:三种思路。一是接受这个风险,因为主从切换窗口极短,配合业务层的幂等或唯一索引兜底;二是用 Redlock,向多数节点加锁,但 Redlock 本身争议很大(见 5.1);三是关键业务直接上 ZooKeeper 或 etcd,用一致性协议保证。选哪种取决于业务对「极低概率双持锁」的容忍度。
4.4 现象:接口 RT 突然飙升,大量请求卡在加锁
原因:锁的等待策略写成了自旋。while(!tryLock()){}这种写法在锁竞争激烈时会把 CPU 打满,而且请求全部堆积。
解决:加锁失败后不要裸自旋,用带退避的等待。简单做法是Thread.sleep(50)后重试,重试次数控制在 3 次以内,超过就快速失败返回「系统繁忙」。更好的做法是用 Redis 的发布订阅或 Redisson 的tryLock(waitTime, leaseTime, unit),让等待逻辑由框架处理。
4.5 现象:日志里全是「加锁成功」,但数据还是错的
原因:锁保护的范围不对。常见于「先查库存再扣减」这种复合操作,锁只加在了扣减那一步,查询和扣减之间没有锁保护,两个请求都查到库存为 1,然后依次扣减,库存变成 -1。
解决:锁必须覆盖「读-判断-写」的完整临界区。加锁要在查询之前,释放要在写完之后。判断临界区范围的方法很简单——把这段代码想象成单线程执行,如果单线程下结果正确,那加锁范围就对了。
5. Redlock 争议与选型:到底该不该用
5.1 Redlock 的适用边界
Redlock 的思路是向 N 个独立的 Redis 节点(通常 5 个)依次加锁,超过半数成功且总耗时小于锁有效期,才算加锁成功。它的支持者认为这能抵御单点故障,反对者(以 Martin Kleppmann 为代表)指出:Redlock 依赖各节点时钟大致同步,而时钟跳变在真实环境里并不罕见,一旦发生,互斥性依然会被破坏。
我的判断是:Redlock 解决的是「Redis 单点故障导致锁失效」,但引入了「时钟依赖」和「部署复杂度」两个新问题。对于绝大多数业务,单实例 Redis(或主从 + 哨兵)配合业务幂等已经够用;只有对互斥性要求极高、且能接受运维 5 个独立 Redis 集群成本的场景,才值得上 Redlock。
5.2 三种方案的横向对比
| 方案 | 互斥性保证 | 部署复杂度 | 性能 | 适用场景 |
|---|---|---|---|---|
| 单实例 Redis + Lua | 主从切换时有极小概率失效 | 低 | 高 | 大部分业务,配合幂等 |
| Redlock | 依赖时钟,理论上有争议 | 高(5 节点) | 中 | 对互斥性要求极高的场景 |
| ZooKeeper/etcd | 强一致,无时钟依赖 | 中 | 中 | 已有 ZK 体系,或强一致需求 |
选型的核心不是「哪个最强」,而是「业务能容忍多大的失效概率」。库存扣减这种场景,超卖一单的代价可能远小于系统不可用,那就选单实例 Redis + 数据库唯一索引兜底;而像「同一笔支付只能被处理一次」这种场景,失效代价高,就值得上 ZK。
5.3 一个容易被忽略的选型维度:锁的持有时间
如果业务持锁时间很短(几十毫秒),Redis 的性能优势明显;如果持锁时间很长(秒级甚至分钟级),锁的过期和续期逻辑会变得复杂,这时候 ZK 的会话机制反而更省心——客户端断连后 ZK 会自动释放锁,不需要自己维护看门狗。
6. 用 JMH 压出锁的真实开销:一个可复现的验证方法
前面讲了这么多参数和策略,但「锁到底带来多少开销」「过期时间设多少合适」这些问题,靠拍脑袋是不行的。我一般用 JMH(Java Microbenchmark Harness)压一遍,拿到真实数据再定参数。
先加依赖:
<dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.37</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.37</version> </dependency>然后写一个对比基准,分别测「无锁」「单实例 Redis 锁」「Redisson 锁」三种情况下的吞吐:
@BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @State(Scope.Benchmark) @Threads(32) // 模拟 32 并发 @Warmup(iterations = 3, time = 2) @Measurement(iterations = 5, time = 3) public class LockBenchmark { private RedisLock lock; private String requestId; @Setup public void setup() { lock = new RedisLock(/* 注入 StringRedisTemplate */); requestId = UUID.randomUUID().toString(); } @Benchmark public void withLock() { String key = "bench:lock"; if (lock.tryLock(key, requestId, 30000)) { try { // 模拟 1ms 业务 Blackhole.consumeCPU(1000); } finally { lock.unlock(key, requestId); } } } @Benchmark public void noLock() { Blackhole.consumeCPU(1000); } }逻辑说明:@Threads(32)模拟 32 个并发线程争抢同一把锁,Mode.Throughput输出每秒完成的操作数。对比withLock和noLock的吞吐差,就能量化出「加锁 + 释放」这一来一回的 Redis 往返开销。
参数说明:
@Warmup和@Measurement的迭代次数不能太少,否则 JIT 还没预热完,数据不可信。我一般 warmup 3 轮、measurement 5 轮。Blackhole.consumeCPU(1000)用来模拟业务耗时,避免 JIT 把空方法优化掉。1000 大约对应几十微秒,可以按需调整。- 压测时 Redis 要和被测服务分开部署,否则测出来的是本机 loopback 的延迟,不是真实网络开销。
跑出来的典型结果是:无锁场景吞吐在几十万级,单实例 Redis 锁在 3~5 万级,Redisson 因为多了看门狗线程和更多 Lua 调用,会再低一些。这个数据能帮你判断:如果业务 QPS 只有几千,锁开销可以忽略;如果 QPS 上万且锁竞争激烈,就要考虑锁分片或者换更轻量的方案。
我自己的习惯是:任何一把分布式锁上线前,先用 JMH 压出「加锁开销」和「锁竞争下的 P99 等待时间」两个数,再结合业务 P99 耗时去定expireMs。这样定出来的参数,比抄别人的配置靠谱得多。踩过几次「参数拍脑袋、大促翻车」的坑之后,我现在宁可多花半天压测,也不愿意在凌晨被告警叫醒。希望帮到你。
本文还有配套的精品资源,点击获取