Redis 分布式锁实战:从 SETNX 的坑到 Redisson 看门狗与可重入锁
“用 Redis 做分布式锁,SETNX 就够了吧?”——这句话背后藏着无数线上事故。只 SETNX 不加过期时间会死锁、加过期时间又可能误删别人的锁、锁超时了业务还没跑完会并发穿透、主从切换还会丢锁。本文从最原始的 SETNX 讲起,一步步推导出正确的实现,最后落到 Redisson 的生产级用法。
一、为什么需要分布式锁
单机环境下可以用 synchronized 或 ReentrantLock,因为它们锁的是同一个 JVM 内的对象。但应用做成多实例部署(集群)后,请求会落到不同进程,各自持有一把“本地锁”,互不感知,临界区就被并发进入。分布式锁要解决的就是跨进程、跨机器(甚至跨机房)的互斥。主流的实现载体有 Redis、ZooKeeper、etcd,本文聚焦使用最广的 Redis。
二、从零到一的四个进化版本
V1:只 SETNX(会死锁)
Boolean ok = redisTemplate.opsForValue().setIfAbsent("lock:order", "1");
if (Boolean.TRUE.equals(ok)) {
try {
doBusiness();
} finally {
redisTemplate.delete("lock:order"); // 崩溃后永远走不到这里
}
}
问题:拿到锁的线程在 finally 之前崩溃/宕机,key 永远存在,其他线程永远等不到锁——死锁。
V2:SETNX + 单独 EXPIRE(不是原子的)
redisTemplate.opsForValue().setIfAbsent("lock:order", "1");
redisTemplate.expire("lock:order", 30, TimeUnit.SECONDS); // 和上面不是一条原子命令!
问题:SETNX 成功但 expire 还没执行时进程崩溃,锁依然没有过期时间。必须用单条原子命令解决:
V3:SET key value NX EX(原子加锁)
// setIfAbsent(key, value, timeout, unit) 底层就是 SET key value NX EX seconds
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
问题依旧存在:业务执行超过 30 秒,锁自动过期被别的线程拿走,前一个线程结束时 delete 可能把别人刚抢到的锁删掉。
V4:value 用唯一标识 + Lua 原子释放(正确版本)
String requestId = UUID.randomUUID().toString(); // 每个请求的唯一标识
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(ok)) {
try {
doBusiness();
} finally {
releaseLock(lockKey, requestId);
}
}
// 释放锁:比较 value 是“自己的”才删除,两步合一用 Lua 保证原子
private void releaseLock(String key, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<Long>(script, Long.class),
Collections.singletonList(key), requestId);
}
要点:加锁用一条原子命令并设过期时间;value 必须存唯一标识;释放时必须先校验是不是自己的锁再删,校验+删除必须用 Lua 原子完成。到这步已经可以应对绝大多数单节点 Redis 场景。
三、过期时间怎么定?看门狗续期
V4 还有一个隐患:业务耗时 > 过期时间(比如 30 秒)时锁就失效了。手动把过期时间设很大又会放大“持有者崩溃后锁长时间不释放”的风险。生产级做法是看门狗(Watch Dog)自动续期:持有锁期间,后台定时(如每 1/3 过期时间)把过期时间续回默认值,业务结束主动释放并取消续期。自己实现容易踩坑,直接用成熟框架。
// Redisson 分布式锁:默认 30 秒过期 + 看门狗自动续期(默认每 10 秒续 30 秒)
RLock lock = redissonClient.getLock("lock:order:" + orderId);
try {
// waitTime=3:拿不到锁最多等 3 秒;leaseTime=-1:启用看门狗续期
if (lock.tryLock(3, TimeUnit.SECONDS)) {
doBusiness();
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 释放并取消看门狗
}
}
注意:tryLock(waitTime, TimeUnit) 不传 leaseTime 时才会启动看门狗;若手动传入 leaseTime,到期即释放、不会续期。看门狗能续期前提是持有锁的 JVM 还活着(每次续期都带着心跳),所以不会出现“进程死了锁还在”的情况。
四、可重入锁
同一线程内,方法 A 加锁后调用方法 B,B 也来抢同一把锁——如果是不可重入的实现,B 会把自己阻塞死。synchronized/ReentrantLock 天然可重入,而手写 SETNX 版本不支持。Redisson 的 RLock 基于 Redis 的 Hash 结构记录“持有线程 + 重入计数”,天然可重入:
// RLock 内部 key 类型是 hash:field=线程标识,value=重入次数
// 加锁:hincrby;释放:hincrby -1,减到 0 才 del key
redissonClient.getLock("lock:pay").lock(); // 同一线程可多次 lock/unlock 配对使用
五、Redlock 与“红锁争议”
Redis 主从架构下有一个经典漏洞:线程 A 在 master 上加锁成功,master 还没同步给 slave 就宕机,slave 晋升为 master,此时锁数据丢失,线程 B 又能加上同一把锁。Redis 作者提出的 Redlock 算法要求向 N 个(一般 5 个)独立 Redis 节点依次加锁,超过半数成功才算加锁成功——即使个别节点丢了锁,多数派仍可保证互斥。
但分布式领域大牛 Martin Kleppmann 曾公开质疑 Redlock:它仍依赖时钟、且在网络分区下有漏洞,建议用带“ fencing token”的方案(如 ZK 的自增序号 + 数据库版本校验)才能真正防并发。现实结论:
- 对绝大多数业务(秒杀扣库存、防重复下单),单机 Redis + Redisson + 业务兜底(数据库唯一索引/乐观锁)已经足够;
- 锁本身就是“概率性正确”的兜底,真正的强一致要落到数据库约束上;
- 对“锁丢了会死人”级别的场景(资金类强一致),优先考虑 ZooKeeper/etcd 或带版本号校验的机制。
六、方案选型速查
| 场景 | 推荐方案 |
|---|---|
| 单节点 Redis、Java 单体集群 | Redisson RLock(看门狗 + 可重入) |
| 不想引第三方库、逻辑极简 | 手写 SETNX + Lua(V4 版本),过期时间给足并做监控 |
| Redis 主从/哨兵、要求稍高 | Redisson + 数据库/状态机兜底;必要时 Redlock |
| 强一致、资金敏感 | ZooKeeper / etcd 分布式锁 |
| 只在单个 JVM 内并发 | synchronized / ReentrantLock 即可,别引分布式锁 |
七、高频面试题速答
- SETNX 怎么设置过期时间? 用 SET key value NX EX 单条原子命令,别 SETNX 后再 EXPIRE。
- 为什么释放锁要用 Lua? “判断是自己的锁”和“删除”是两步操作,不用 Lua 则两步之间可能被打断,导致误删他人锁。
- 锁过期了业务没跑完怎么办? 看门狗自动续期(Redisson),业务完成后主动释放。
- 怎么实现可重入? Redis Hash + 线程标识 + 计数;直接使用 Redisson RLock。
- 主从切换丢锁怎么办? Redlock 多数派方案;或结合业务状态机/数据库唯一约束做兜底。
- 分布式锁能完全替代数据库幂等吗? 不能。锁是并发控制,幂等是结果约束,生产上两者常配合使用。
一句话总结:加锁要原子(NX EX)、解锁要对人(唯一 value + Lua)、持有要续期(看门狗)、强一致别依赖 Redis。把这条线串起来,Redis 分布式锁从原理到落地就都通了。