深入解剖 Redis 分布式锁:从 SETNX 到 Redlock 的演进之路

深入解剖 Redis 分布式锁:从 SETNX 到 Redlock 的演进之路

摘要:在微服务与分布式架构中,“如何防止资源被并发抢占”是一个永恒的话题。从秒杀扣库存到定时任务调度,分布式锁无处不在。Redis 因其高性能和原子性命令,成为了实现分布式锁的首选中间件。但你真的写对 Redis 锁了吗?本文将带你一层层剥开 Redis 分布式锁的实现原理,揭秘那些容易被忽视的“坑”。


一、 为什么我们需要分布式锁?

在单机多线程环境下,我们可以使用 Java 的 synchronizedReentrantLock 来保证线程安全。但在分布式系统中,服务部署在多台机器上,JVM 内部的锁无法跨进程、跨机器生效。

我们需要一个所有服务都能访问到的“第三方组件”来维护锁的状态,Redis 天然适合这个角色。

一个合格的分布式锁必须满足以下四个核心条件:

  1. 互斥性 (Mutual Exclusion):在任意时刻,只能有一个客户端持有锁。
  2. 无死锁 (Deadlock Free):即使持有锁的客户端崩溃或断网,锁也必须能自动释放,不能永久锁住资源。
  3. 安全性 (Safety):解铃还须系铃人,锁只能被持有者删除,不能被别人误删。
  4. 高可用 (Fault Tolerance):Redis 节点故障时,锁机制依然能正常工作。

二、 基础篇:从错误示范到正确姿势

1. 史前时代:SETNX (错误示范)

最简单的想法是利用 Redis 的 SETNX (SET if Not Exists) 命令。

  • 如果 Key 不存在,设置成功,返回 1(加锁成功)。
  • 如果 Key 存在,设置失败,返回 0(加锁失败)。
SETNX lock_key 1

❌ 致命缺陷:死锁
如果服务获取锁后,还没来得及释放(DEL)就挂了(OOM 或断电),这个 Key 永远存在,后续所有请求都会阻塞。

2. 补救尝试:SETNX + EXPIRE (依然错误)

为了解决死锁,我们给锁加一个过期时间。

// 伪代码
if (redis.setnx("lock_key", 1) == 1) {
    redis.expire("lock_key", 10); // 设置 10秒过期
    try {
        doBusiness();
    } finally {
        redis.del("lock_key");
    }
}

❌ 致命缺陷:非原子性
SETNXEXPIRE 是两条指令。如果程序在第一行执行完,还没执行第二行时服务器挂了,锁依然没有过期时间,导致死锁。

3. 正确姿势:原子命令 (Redis 2.6.12+)

Redis 官方为了解决上述问题,扩展了 SET 命令,使其同时支持 NX 和过期时间。

SET key value NX PX milliseconds
  • NX:Not Exists,不存在才设置。
  • PX:过期时间(毫秒)。

✅ 解决了原子性问题:加锁和设置过期时间合并成了一条原子指令。


三、 进阶篇:如何防止“误删”锁?

使用了原子命令就万事大吉了吗?看下面这个场景:

  1. A 线程获取锁,过期时间设为 10s。
  2. A 线程业务逻辑执行慢,用了 15s。此时锁在第 10s 自动过期失效。
  3. B 线程在第 11s 进来,发现没锁,于是获取锁成功。
  4. A 线程在第 15s 执行完,执行 DEL 释放锁。
  5. 问题出现:A 删除的其实是 B 的锁!B 正在裸奔,C 线程又能进来了。

解决方案:Value 设为唯一 ID + Lua 脚本删除

我们在加锁时,Value 不能随便设为 1,而要设为一个唯一 ID(如 UUID + 线程 ID)。解锁时,先判断 ID 是否一致,一致再删除。

加锁:

SET lock_key <UUID> NX PX 10000

解锁(必须使用 Lua 脚本保证原子性):

// 判断 value 是否等于我的 UUID,是则删除,否则返回 0
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

四、 高级篇:看门狗(Watch Dog)机制

上面的方案还有一个痛点:过期时间设多少合适?

  • 设短了:业务还没跑完,锁就失效了(虽然解决了误删,但并发问题依然存在)。
  • 设长了:万一服务挂了,其他线程等待时间太长。

我们需要一种机制:只要业务还在运行,锁就自动续期。

这就是 Java 客户端 Redisson 实现的 “看门狗” 原理。

原理图解

sequenceDiagram participant Client as 客户端 participant Redis as Redis服务端 participant WatchDog as 后台看门狗线程 Client->>Redis: 1. 加锁 (默认30s) activate Client Client->>WatchDog: 2. 启动看门狗 loop 每隔 10s (1/3周期) WatchDog->>Redis: 3. 检测锁是否存在? alt 存在 WatchDog->>Redis: 4. 续期到 30s else 不存在 WatchDog->>Client: 停止续期 end end Client->>Client: 5. 执行业务逻辑 Client->>Redis: 6. 释放锁 (Lua脚本) Client->>WatchDog: 7. 停止看门狗 deactivate Client

核心逻辑:

  1. 加锁时,默认过期时间 30s。
  2. 启动一个后台线程(TimerTask),每隔 30 / 3 = 10s 检查一次。
  3. 如果锁还存在(说明业务还在跑),就将 TTL 重置为 30s。
  4. 如果机器宕机,看门狗线程也挂了,没人在 10s 后续期,锁在 30s 后自动失效。不影响死锁。

五、 骨灰篇:Redis 集群的阿喀琉斯之踵 —— Redlock

前面的方案都是基于单机 Redis(或主从架构)的。但在 Redis 主从集群下,存在一个致命缺陷。

1. 主从切换导致锁丢失

  1. Client A 在 Master 节点获取锁成功。
  2. Master 宕机,锁数据还没来得及同步(Replication)给 Slave。
  3. Slave 晋升为新的 Master。
  4. Client B 来加锁,发现新 Master 上没有锁,加锁成功。
  5. 结果:A 和 B 同时持有了锁,互斥性失效。

2. 解决方案:Redlock 算法

为了解决这个问题,Redis 作者 Antirez 提出了 Redlock 算法。它的核心思想类似于 Raft/Paxos:少数服从多数

Redlock 流程:

假设有 5 个独立的 Redis Master 节点(没有从节点):

  1. 计时:记录当前时间戳 T1。
  2. 并发加锁:依次尝试向 5 个节点申请锁。
    • 加锁时要设置一个很小的网络超时时间(如 50ms),防止在一个挂掉的节点上浪费时间。
  3. 计算结果
    • 如果成功获取了 N/2 + 1 个节点(即 >= 3个)的锁。
    • 并且 消耗的时间 (当前时间 T2 - T1) < 锁的过期时间
    • 则认为加锁成功。
  4. 实际有效时间锁的设置过期时间 - 消耗时间
  5. 失败处理:如果没凑够 3 个,或者超时了,必须向所有节点发起解锁请求(哪怕有些节点你没加锁成功,以防万一)。

3. 争议

关于 Redlock,分布式专家 Martin Kleppmann 曾与 Antirez 发生过著名的论战。Martin 认为 Redlock 强依赖系统时钟,不够安全。
但在绝大多数业务场景下(非金融级强一致性),Redlock 依然是解决 Cluster 锁丢失问题的最佳现成方案。


六、 总结与最佳实践

作为架构师或开发者,在选择 Redis 分布式锁方案时,可以参考以下清单:

方案 实现难度 安全性 适用场景 备注
SETNX 极低 禁止使用 必然导致死锁
SET NX PX 简单业务,允许少量并发 存在锁过期但业务未完的风险
Redisson (看门狗) 中 (依赖库) 生产环境推荐 解决了续期、可重入、阻塞等待等问题
Redlock 极高 对一致性要求极高的场景 性能损耗较大,依赖多 Redis 实例

最后一句忠告:
不要尝试自己造轮子(写 Lua 脚本、写续期逻辑),生产环境直接使用 Redisson。它已经帮你把看门狗、原子性、Redlock 等所有坑都填平了。

// Redisson 的简单示例
RLock lock = redisson.getLock("myLock");
try {
    // 自动续期,阻塞等待
    lock.lock();
    doBusiness();
} finally {
    lock.unlock();
}

希望这篇博客能帮你彻底搞懂 Redis 分布式锁的底层原理!

posted on 2025-11-27 00:45  滚动的蛋  阅读(0)  评论(0)    收藏  举报

导航