Skip to content

Redis 分布式锁:从 SETNX 到 Redlock 的演进与实践

问题的起点

单机应用里,synchronizedReentrantLock 这些 JVM 级别的锁够用。但服务拆成多实例部署后,每个进程各自独立,一个 JVM 的锁管不到另一个 JVM。比如一台机器上订单服务 A 获取了锁,另一台机器上订单服务 B 完全不知道,直接进入临界区——数据就乱了。

需要一个所有进程都能看到的、原子性的锁服务。Redis 因为性能(单机 QPS 10w+)和原子操作支持,成了分布式锁最常用的实现方案。

但这个方案不是一蹴而就的,中间踩过不少坑,也埋了不少争议。

第一阶段:原始 SETNX

Redis 2.0 提供 SETNX key value,语义是"只有当 key 不存在时才设置"——天然适合做锁。

java
// 最简单的分布式锁
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

为了解决死锁,加一个过期时间:

java
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 命令支持 NXEX 选项,把加锁和设过期合成一步原子操作:

java
// 原子加锁 + 过期时间
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),删除时校验是不是自己的锁:

java
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)。

java
RLock lock = redissonClient.getLock("lock:order");
// 最多等 100s,拿到锁后自动续期 30s(WatchDog 默认)
if (lock.tryLock(100, 30, TimeUnit.SECONDS)) {
    try {
        // 业务逻辑,随便跑多久
    } finally {
        lock.unlock();  // 释放锁时会通知 WatchDog 停止续期
    }
}

WatchDog 的核心逻辑在 Redisson 的 LockWatchDogTask 中:

java
// 伪代码: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 节点依次尝试加锁。

java
// 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. 节点 1 获取了锁,发生 Full GC 暂停了 10s+
  2. 锁过期,节点 2 也获取了锁
  3. 节点 1 的 GC 恢复,它以为自己还持有锁,两个节点同时进入临界区

Martin 在 2016 年用了一个真实案例:一个 Java 服务在 8GB 堆上遇到 CMS remark 阶段停顿 8s 的 Full GC,锁超时设的是 10s——刚好够出问题。

fencing token 是 Martin 给出的解决方案:每次获取锁时,锁服务分配一个单调递增的 token,写入下游存储时校验 token 是否有效。

java
// 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.5ms2ms180001 次 RTT
Redisson RLock1.2ms5ms8000包含续期定时任务
Redlock(5 节点)12ms35ms8005 次 RTT + 仲裁判断
etcd 锁3ms15ms30001 次 RTT + Raft 多数写
ZK 锁10ms50ms1000临时顺序节点 + 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 分布式锁的演进,本质上是在解决四个问题:

  1. 死锁 → 加过期时间,用原子 SET NX EX
  2. 误删锁 → value 加唯一标识,Lua 脚本校验后删除
  3. 锁过期业务未完成 → WatchDog 自动续期
  4. 单点故障 → 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。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。