Redis分布式锁的实现
Redis 实现分布式锁是后端开发中非常经典的技术方案。一个完备的 Redis 分布式锁并不是简单地调用一个命令就能搞定的,它经历了一个不断演进、解决各种边界问题的过程。
下面我将从基础实现、问题与优化、工业级解决方案 (Redisson) 以及集群环境下的终极方案 (Redlock) 四个维度为您详细剖析。
一、 分布式锁的核心要求
一个靠谱的分布式锁,必须满足以下几个特性:
- 互斥性:在任意时刻,只有一个客户端能持有锁。
- 防死锁:即使持有锁的客户端崩溃,也能保证后续其他客户端可以获取锁(通常通过超时机制实现)。
- 加锁和解锁必须是同一个客户端:客户端不能把别人加的锁给解了。
- 高可用、高性能:获取锁和释放锁的操作要快,且 Redis 节点自身要高可用。
二、 Redis 分布式锁的演进过程
1. 青铜时代:SETNX + DEL
最开始,大家会想到用 Redis 的 SETNX (Set if Not eXists) 命令。
- 加锁:
SETNX lock_key 1(如果返回 1 说明加锁成功)。 - 解锁:
DEL lock_key。 - 致命缺陷:如果加锁后,应用实例宕机了,没来得及执行
DEL,那么这个锁永远不会被释放,导致死锁。
2. 白银时代:SETNX + EXPIRE
为了解决死锁,给锁加上过期时间。
- 加锁:
SETNX lock_key 1 - 设置过期:
EXPIRE lock_key 10(10秒后自动释放) - 致命缺陷:
SETNX和EXPIRE是两条命令,不具备原子性。如果在SETNX成功后,执行EXPIRE前一瞬间应用宕机,依然会死锁。
3. 黄金时代:SET key value NX EX (Redis 2.6.12 之后)
Redis 官方修改了 SET 命令的参数,支持将判断存在和设置过期时间合并为一个原子操作。
- 加锁:
SET lock_key unique_value NX EX 10
(NX = Not eXists,EX = Seconds 过期时间) - 致命缺陷(误删锁):
假设线程 A 拿到锁(过期时间10秒),但业务执行了15秒。
第10秒时,锁自动释放了。此时线程 B 拿到了锁。
第15秒时,线程 A 业务执行完,执行DEL lock_key。这时候 A 把 B 的锁给删了!
4. 钻石时代:唯一标识 + Lua 脚本解锁
为了解决误删锁的问题,我们在加锁时存入一个唯一标识(比如 UUID + ThreadID),解锁时先判断是不是自己加的锁,如果是再删除。
由于 "判断" 和 "删除" 是两步操作,为了保证原子性,必须使用 Lua 脚本。
加锁(伪代码):
String uuid = UUID.randomUUID().toString() + Thread.currentThread().getId();
// 执行 SET lock_key uuid NX EX 10
boolean locked = redisTemplate.opsForValue().setIfAbsent("lock_key", uuid, 10, TimeUnit.SECONDS);
解锁(Lua 脚本):
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
到这里,单机版的 Redis 分布式锁已经基本完善了。但还剩下一个难以解决的问题:锁过期了,但业务没执行完怎么办?
三、 工业级解决方案:Redisson 与 "看门狗"
为了解决 "业务没执行完,锁却自动过期了" 的问题,Java 生态中最著名的框架 Redisson 提供了一个完美的机制:看门狗 (Watchdog)。
Redisson 的工作原理:
- 自动续期:当你使用 Redisson 加锁且不指定锁的超时时间时,Redisson 会默认设置锁的超时时间为 30 秒。
- 看门狗机制:同时,Redisson 会启动一个后台定时任务(看门狗)。它会每隔 10 秒(
timeout / 3)去检查一下这个线程是否还持有锁,如果持有,就重新把锁的超时时间重置为 30 秒。 - 安全释放:如果应用宕机,看门狗线程也会随之销毁,锁会在 30 秒后自然过期,不会死锁。
Redisson 使用示例:
RLock lock = redissonClient.getLock("myLock");
try {
// 阻塞式获取锁,默认开启看门狗机制
lock.lock();
// 执行业务逻辑
} finally {
// 只有锁是当前线程持有,才进行解锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
注:Redisson 底层的加锁和解锁逻辑全部是使用极其复杂的 Lua 脚本实现的,并且支持可重入锁、读写锁、红绿灯等丰富的锁特性。
四、 集群环境下的终极挑战:Redlock (红锁)
上述所有的方案,都存在一个极端场景的隐患:Redis 主从切换导致锁丢失。
场景说明:
- 线程 A 在 Redis Master 节点上成功获取了锁。
- 还没来得及同步到 Slave 节点,Master 节点宕机了。
- 哨兵模式/集群模式下,Slave 晋升为新的 Master。
- 线程 B 来申请锁,由于新 Master 上没有原来 A 的锁信息,B 加锁成功。
- 导致 A 和 B 同时持有了锁,互斥性被破坏!
Redlock 算法
为了解决这个问题,Redis 之父 Antirez 提出了 Redlock 算法。
核心思想:不使用主从复制,直接部署 N 个(通常为 5 个)独立的 Redis Master 节点。
加锁流程:
- 获取当前时间戳
T1。 - 客户端尝试向这 5 个节点分别发送加锁请求(带上相同的 key 和 value,并设置较短的超时时间,防止在某个节点卡死)。
- 如果客户端在大多数(N/2 + 1,即至少 3 个)节点上成功获取了锁。
- 获取当前时间戳
T2。如果T2 - T1 < 锁的过期时间,则认为加锁成功! - 如果加锁失败(没凑够 3 个,或者耗时太长),客户端必须向所有节点发送解锁请求。
业界的取舍(非常重要)
虽然 Redlock 在理论上解决了单点故障引起的锁丢失问题,但在实际工业生产中,极少有公司真正使用 Redlock。
- Martin Kleppmann 的质疑:Redlock 强依赖服务器时钟,如果发生时钟跳跃(Clock Jump)或者长时间的 GC 停顿,Redlock 依然是不安全的。
- 运维成本高:为了一个锁要维护 5 个独立的 Redis 节点。
- 主流选择:对于 99% 的业务,使用 Redisson + Redis 主从/集群 已经足够了。如果偶尔发生极端的主从切换导致锁失效,可以通过数据库的乐观锁、唯一索引等兜底机制来保证数据一致性。
- 如果你的业务对锁的安全性要求是 绝对的(比如金融核心交易),业界更推荐使用 Zookeeper 或 etcd 来实现分布式锁(基于强一致性的共识算法)。
五、 总结与最佳实践
- 绝对不要自己手写 Redis 分布式锁(除非为了面试和学习)。里面的细节和边缘场景(Edge Cases)太多了。
- Java 开发者直接使用 Redisson。它帮你处理了 Lua 脚本、锁重入、自动续期(看门狗)等所有麻烦事。
- 加锁的 Key 要尽量小粒度。比如锁某一个用户的订单,Key 应该是
lock:order:{userId}:{orderId},千万不要锁整个lock:order,以免影响并发性能。 - 务必在
finally块中释放锁,并且先判断锁是否由当前线程持有。 - 考虑兜底方案:分布式锁防君子不防小人,数据库层面的乐观锁(版本号机制)和唯一约束依然是保证业务数据正确的最后一道防线。