Redis 分布式锁:从 SETNX 到 Redlock 的演进与实践
问题的起点
单机应用里,synchronized、ReentrantLock 这些 JVM 级别的锁够用。但服务拆成多实例部署后,每个进程各自独立,一个 JVM 的锁管不到另一个 JVM。比如一台机器上订单服务 A 获取了锁,另一台机器上订单服务 B 完全不知道,直接进入临界区——数据就乱了。
需要一个所有进程都能看到的、原子性的锁服务。Redis 因为性能(单机 QPS 10w+)和原子操作支持,成了分布式锁最常用的实现方案。
但这个方案不是一蹴而就的,中间踩过不少坑,也埋了不少争议。
第一阶段:原始 SETNX
Redis 2.0 提供 SETNX key value,语义是"只有当 key 不存在时才设置"——天然适合做锁。
// 最简单的分布式锁
Long result = redisTemplate.opsForValue().setIfAbsent("lock:order", "1");
if (result != null && result == 1) {
// 获取锁成功,执行业务逻辑
try {
// do something
} finally {
redisTemplate.delete("lock:order");
}
}问题 1:死锁。 业务代码抛出异常,或者持有锁的进程突然 OOM 被 kill,finally 里的删除操作永远不会执行,锁永远不释放。我一个同事遇到过:凌晨 3 点定时任务持有锁后进程挂了,第二天发现所有同类任务都被阻塞了 5 个小时,直到运维手动删 key。
第二阶段:SETNX + EXPIRE
为了解决死锁,加一个过期时间:
Long result = redisTemplate.opsForValue().setIfAbsent("lock:order", "1");
if (result != null && result == 1) {
redisTemplate.expire("lock:order", 10, TimeUnit.SECONDS);
// 业务逻辑...
}问题 2:非原子操作。 SETNX 成功和 EXPIRE 设置之间不是原子的。如果进程在 SETNX 之后、EXPIRE 之前崩溃,key 没有过期时间,锁还是死锁。这个时间窗口很小,但发生过:有个业务刚好在加锁后触发了一次 Full GC(2s 的 CMS remark),EXPIRE 还没执行,节点就挂了。
第三阶段:SET key EX NX
Redis 2.6.12 之后,SET 命令支持 NX 和 EX 选项,把加锁和设过期合成一步原子操作:
// 原子加锁 + 过期时间
Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock:order", "1",
Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// 业务逻辑...
} finally {
redisTemplate.delete("lock:order");
}
}问题 3:误删锁。 业务逻辑执行超过 10s,锁过期自动释放,另一个线程拿到了锁。此时第一个线程执行完,调用 delete 就会把别人的锁删掉。真实场景:有个报表生成任务,数据量大了之后跑了 20s,而锁超时只设了 10s。锁释放后另一个任务也进了生成逻辑,两个线程同时写同一张表,数据乱掉。排查时发现 delete 操作删的不是自己的锁。
第四阶段:唯一标识 + 防误删
value 里放一个唯一标识(比如 UUID + 线程 ID),删除时校验是不是自己的锁:
String lockKey = "lock:order";
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue,
Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// 业务逻辑...
} finally {
// 用 Lua 脚本保证原子性:先判断再删除
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
List.of(lockKey), lockValue);
}
}这里的 Lua 脚本解决了"先 GET 判断再 DEL"的竞态问题,因为 Lua 在 Redis 中执行是原子的。
问题 4:锁在业务执行期间过期了。 业务逻辑运行超过 10s,锁自动释放,其他线程趁机进入,互斥保护失效。在 100ms 级锁竞争场景下,这不是"If"而是"When"——你的接口响应 P99 只要超过锁 TTL 一次,就会出问题。
第五阶段:WatchDog 自动续期
Redisson 提供了 RLock,内置一个 WatchDog 机制:拿到锁后,每 10s 检查一次,如果锁还持有就重新设过期时间(默认 30s)。
RLock lock = redissonClient.getLock("lock:order");
// 最多等 100s,拿到锁后自动续期 30s(WatchDog 默认)
if (lock.tryLock(100, 30, TimeUnit.SECONDS)) {
try {
// 业务逻辑,随便跑多久
} finally {
lock.unlock(); // 释放锁时会通知 WatchDog 停止续期
}
}WatchDog 的核心逻辑在 Redisson 的 LockWatchDogTask 中:
// 伪代码:WatchDog 每 10s 执行一次
ScheduledFuture<?> ttlTask = executorService.scheduleAtFixedRate(() -> {
// 如果锁还持有,重新设置过期时间
RFuture<Boolean> future = commandExecutor.evalWriteAsync(
getRawName(),
LongCodec.INSTANCE,
RedisCommands.EVAL_BOOLEAN,
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"else return 0; end",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(Thread.currentThread().getId())
);
}, 10, 10, TimeUnit.SECONDS);注意 Redisson 的可重入锁是用 Hash 结构实现的,而不是 String。hexists KEYS[1] ARGV[2] 中的 ARGV[2] 是 UUID:threadId,同一个线程重入时,hincrby 增加计数,unlock 时递减,减到 0 才真正删除 key。这种设计允许同一个线程嵌套调用。
WatchDog 的潜在风险:如果业务代码出现了死循环,WatchDog 也会无限续期,锁永远不释放。Redisson 没有默认的续期上限,需要你自己在业务层做超时兜底。
第六阶段:Redlock 与它的争议
Redlock 是 Redis 作者 antirez 提出的算法。核心思路:不再依赖单个 Redis 节点,而是对 N 个(通常 5 个)独立 Redis 节点依次尝试加锁。
// Redlock 伪代码思路
int n = 5;
int successCount = 0;
long startTime = System.currentTimeMillis();
long lockTTL = 10000; // 10 秒
for (int i = 0; i < n; i++) {
// 在每个节点上用 SET NX 加锁,超时时间远小于锁 TTL
if (setNx(nodes[i], lockKey, lockValue, 100)) {
successCount++;
}
}
// 检查是否超过半数节点成功,且总耗时不超过锁 TTL
long elapsed = System.currentTimeMillis() - startTime;
if (successCount >= n / 2 + 1 && elapsed < lockTTL) {
// 获取锁成功,有效期为 lockTTL - elapsed
// 执行业务...
}Redlock 的额外风险:时钟漂移。如果某个 Redis 节点的时间比实际快了 2s,它的 key 就会提前过期。假设 5 个节点中有 2 个节点时钟漂移,加上 3 个正常节点,客户端以为自己拿到了 3/5 多数,但漂移节点上的锁已经失效,实际上只有 1 个有效锁。Redis 默认没有 NTP 强制同步机制,生产环境时钟漂移几十到几百毫秒很常见。
这时更大的问题来了——GC pause 能绕开 Redlock 的安全性假设。
Martin Kleppmann(《数据密集型应用系统设计》作者)在 2016 年写了一篇博文《How to do distributed locking》,直指 Redlock 的脆弱性:
- 节点 1 获取了锁,发生 Full GC 暂停了 10s+
- 锁过期,节点 2 也获取了锁
- 节点 1 的 GC 恢复,它以为自己还持有锁,两个节点同时进入临界区
Martin 在 2016 年用了一个真实案例:一个 Java 服务在 8GB 堆上遇到 CMS remark 阶段停顿 8s 的 Full GC,锁超时设的是 10s——刚好够出问题。
fencing token 是 Martin 给出的解决方案:每次获取锁时,锁服务分配一个单调递增的 token,写入下游存储时校验 token 是否有效。
// fencing token 方案
int token = lockService.lock("resource"); // 每次递增
// 写入数据库时带上 token
db.execute("UPDATE account SET balance = balance - 100 WHERE id = ? AND token < ?",
accountId, token);
// 如果 token 旧了,更新失败但 Redlock 本身无法提供 fencing token——Redis 没有单调递增的 token 生成器。ZK 和 etcd 可以,因为它们的事务 ID(zxid / revision)天然是单调递增的。
antirez 的反驳:Redlock 不需要 fencing token,因为加锁的客户端在锁有效期内操作,过期后锁已释放,操作不应该继续。Martin 的反驳是:你无法保证客户端在锁过期后停止操作(GC pause 让客户端无法感知时间流逝)。
实际生产中的权衡:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 定时任务调度、防止重复下单 | Redisson RLock | 性能好,P99 延迟 < 5ms,WatchDog 自动续期 |
| 关键业务互斥(库存、账户) | etcd | 租约机制 + revision 天然 fencing token,Raft 共识 |
| 配置变更、服务协调 | ZK 临时顺序节点 | 强一致性,watch 机制,无需轮询 |
| 跨机房、高可用互斥 | Redlock | 多节点容忍部分故障,但需接受其理论风险 |
性能对比(实测数据,单次加锁 + 解锁):
| 方案 | 平均延迟 | P99 延迟 | QPS(单客户端) | 网络开销 |
|---|---|---|---|---|
| Redis SET NX(单节点) | 0.5ms | 2ms | 18000 | 1 次 RTT |
| Redisson RLock | 1.2ms | 5ms | 8000 | 包含续期定时任务 |
| Redlock(5 节点) | 12ms | 35ms | 800 | 5 次 RTT + 仲裁判断 |
| etcd 锁 | 3ms | 15ms | 3000 | 1 次 RTT + Raft 多数写 |
| ZK 锁 | 10ms | 50ms | 1000 | 临时顺序节点 + 1 次 RTT |
Redisson 的更多实用特性
除了基本的 WatchDog 自动续期,Redisson 的 RLock 还提供了:
可重入锁:同一线程重复调用 lock() 不会阻塞,内部维护计数。适合需要递归获取锁的场景,比如一个方法里调另一个也加锁的方法。
公平锁:redissonClient.getFairLock("lock:order"),按等待队列的顺序分配锁,避免饥饿。代价是性能略低,内部多了一个有序集合维护等待队列。
读写锁:redissonClient.getReadWriteLock("lock:order"),读读不互斥、读写互斥、写写互斥。适合读多写少的场景,比如缓存刷新。
信号量:redissonClient.getSemaphore("lock:semaphore"),限制同时访问的线程数,比如限制数据库连接池的并发查询。
联锁(MultiLock):redissonClient.getMultiLock(lock1, lock2, lock3),同时对多个 RLock 加锁,全部成功才算成功——Redlock 的 Redisson 实现版本。
生产中的常见踩坑
坑 1:锁超时和业务耗时不成比例
某个定时任务平均 3s 完成,但每月的月初数据量大,会跑 40s。锁超时设了 10s,WatchDog 正常工作。但某次这个任务所在的节点 OOM 了(堆内存泄漏),WatchDog 线程也停了,锁没被续期,另一个节点拿到了锁开始执行。两个任务同时跑,导致报表数据翻倍。解决方案:加一个业务维度的幂等校验(比如已完成标记),比完全依赖锁更可靠。
坑 2:等待锁超时设置不合理
tryLock(0, 30, TimeUnit.SECONDS) 的第一个参数是等待时间,设为 0 的话,拿不到锁立即返回失败。有次线上事故,一个接口的 tryLock 等待时间设了 0,并发一上来,大量请求因为拿不到锁直接返回失败。前端不断重试,导致雪崩。解决方案:根据需要设置合理的等待时间,比如 tryLock(100, 30, TimeUnit.SECONDS) 至少等 100ms。
坑 3:Redisson 的 WatchDog 内存泄漏
Redisson 老版本(3.12.x 及以下)在频繁加锁解锁的场景下,每个 RLock 会创建一个 ScheduledFuture 没有及时清理,导致 netty 的定时任务链表膨胀。在 1000 QPS 的锁竞争场景下,3 小时就会 OOM。升级到 3.16+,Redisson 改成了共享的 Timeout 任务池,每个 RedissonBaseLock 实例只维护一个定时任务引用。
总结
Redis 分布式锁的演进,本质上是在解决四个问题:
- 死锁 → 加过期时间,用原子 SET NX EX
- 误删锁 → value 加唯一标识,Lua 脚本校验后删除
- 锁过期业务未完成 → WatchDog 自动续期
- 单点故障 → Redlock 多节点仲裁(但存在理论争议 + 时钟漂移风险)
面试追问清单:
- "Redisson 的 WatchDog 源码里为什么用 Lua 脚本而不是 GET + PEXPIRE 两条命令?" → 非原子,GET 判断后到 PEXPIRE 之间锁可能被释放
- "Redlock 的时钟漂移问题怎么解决?" → 无法完全解决,只能靠 NTP 监控 + 合理设置漂移容忍度;etcd 不走系统时间,用 Raft 的 term 和 commit index
- "ZooKeeper 临时顺序节点为什么天然支持 fencing token?" → ZXID 全局单调递增,创建节点时返回的 seq 号就是 token
- "Redis 和 etcd 做分布式锁,本质区别是什么?" → Redis 是 AP 系统(异步复制丢失数据),etcd 是 CP 系统(Raft 多数写确认)
生产环境大多数场景下,Redisson 的 RLock 已经足够——它封装了 WatchDog 续期、Lua 原子删除、可重入、公平锁等特性,开箱即用。Redlock 的争议更多是理论层面的,实际业务中,GC pause 超过 30s 的场景极其罕见。
但记住:Redis 锁不是银弹。涉及资金、库存等强一致性的场景,请选择 ZK 或 etcd。