在分布式系统中,确保数据在并发访问下的一致性是一个永恒的挑战。分布式锁,作为实现互斥(Mutual Exclusion)的关键工具,是每个后端工程师都必须掌握的核心技能。Redis因其高性能和丰富的原子命令,成为了实现分布式锁的首选。然而,一个看似简单的SETNX
,在复杂的分布式环境下却隐藏着诸多陷阱。
本文将带你从最基础的Redis锁出发,剖析其在主从架构下的缺陷,并最终深入理解Redis官方推荐的Redlock(红锁) 。
为何基于主从复制的锁并不可靠?
实现Redis分布式锁最直观的方式,是在实例中创建一个唯一的键(Key):
SET my:lock random_value NX PX 30000
-
NX
:确保只有当键不存在时才能设置成功,保证了锁的原子性获取。 -
PX 30000
:为锁设置一个过期时间(例如30秒),防止客户端因崩溃而无法释放锁,导致死锁。 -
random_value
:一个唯一的客户端标识,用于在释放锁时验证身份,防止误删他人的锁。
这套方案在单机Redis上运行良好。但为了保证高可用性,我们通常会引入主从复制(Master-Slave)架构。问题也随之而来:这套方案在主从故障转移(Failover)面前,不堪一击。
致命的竞争条件
由于Redis的主从复制是异步的,数据从Master同步到Slave存在延迟。这就为数据不一致创造了条件,从而可能破坏锁的互斥性。
考虑以下场景:
- 客户端A 在Master节点上成功获取了锁
my:lock
。 - 在Master将这个新的键值对同步到Slave之前,Master节点突然崩溃宕机。
- 哨兵(Sentinel)或集群机制触发故障转移,将一个Slave节点提升为新的Master。
- 客户端B 尝试在新Master上获取锁
my:lock
。由于数据同步延迟,新Master上根本不存在这个键。 - 客户端B成功获取了锁。
结果是灾难性的:客户端A和客户端B同时持有了本应是互斥的锁。 这种安全违规会导致数据错乱、重复执行等严重问题。我们对锁最基本的要求——在任何时刻只允许一个持有者——被轻易地打破了。
Redlock算法:为分布式锁建立多数派共识
为了解决主从异步复制带来的锁失效问题,Redis的作者Antirez提出了一个更复杂的分布式锁算法——Redlock。
Redlock的核心思想是放弃主从复制,转而采用N个完全独立的Redis Master节点。这些节点之间不进行任何数据同步。客户端必须从大多数节点上成功获取锁,才能被认为真正持有了该锁。
Redlock加锁流程
假设我们有N=5个独立的Redis Master实例。客户端获取锁的操作如下:
记录开始时间:客户端以毫秒为单位,获取当前时间戳
t1
。顺序尝试获取:客户端使用相同的键名(e.g.,
my:lock
)和唯一的随机值,依次向所有N个Redis实例发送加锁请求。- 设置短暂超时:在向每个实例请求时,客户端需要设置一个远小于锁总有效期的网络超时(例如,锁有效期10秒,超时设为50毫秒)。这可以防止客户端因等待一个宕机的节点而长时间阻塞。
计算结果并校验:当客户端从所有N个实例都请求完毕后,记录当前时间戳
t2
。此时,客户端必须同时满足以下两个条件,才能认为加锁成功:- 条件1:获取多数派支持:客户端成功从超过半数(
N/2 + 1
,在此例中是5/2 + 1 = 3
个)的实例上获取了锁。 - 条件2:耗时小于有效期:获取锁的总耗时
t2 - t1
必须小于锁的初始有效时间。
- 条件1:获取多数派支持:客户端成功从超过半数(
重算锁的有效时间:如果加锁成功,锁的真实有效时间应为初始设置的有效时间减去加锁过程的耗时。例如,初始设置30秒,加锁花了2秒,那么锁的剩余生命周期只有28秒。
失败则全部释放:如果客户端未能满足上述任一条件(例如,只从2个实例获取到锁,或加锁耗时过长),则必须向所有N个Redis实例(包括那些加锁失败的)发送解锁命令,以清理残留的锁。
为什么是奇数个节点?
Redlock依赖于“多数派”共识。N = 2X + 1
是一个经典的容错公式,其中 X
是系统能够容忍的失效节点数。
- 若部署
N=3
台 (2*1 + 1
),系统能容忍X=1
台节点宕机。剩下的2台仍能构成多数派 (3/2 + 1 = 2
)。 - 若部署
N=5
台 (2*2 + 1
),系统能容忍X=2
台节点宕机。剩下的3台仍能构成多数派 (5/2 + 1 = 3
)。
使用奇数个节点可以在满足同等级别容错能力的情况下,节约资源。例如,3台和4台都只能容忍1台节点失效,但3台成本更低。
Redisson实现:看门狗与锁续期
理论虽好,但在实际应用中,还有一个棘手的问题:如果锁的过期时间到了,但业务逻辑还没执行完,怎么办?
Java中对Redlock算法的最佳实现是Redisson客户端。它通过一个巧妙的 “看门狗”(Watchdog)机制,完美解决了锁的自动续期问题。
看门狗 (Watchdog) 机制
当你使用Redisson的lock()
方法(不带租约时间参数)获取锁时,如果加锁成功,Redisson会启动一个后台线程,即“看门狗”。
- 默认锁期:Redisson中的锁,默认过期时间是30秒。
- 定时续命:看门狗是一个定时任务,它会每隔10秒(即默认锁过期时间的1/3)检查一次持有锁的客户端线程是否依然存在。
- 自动续期:如果线程还活着(即业务还在执行),看门狗就会将该锁的过期时间重置为30秒。
这个机制确保了只要业务逻辑没有执行完毕,锁就不会因为超时而提前释放,极大地增强了锁的可靠性。当然,当业务执行完毕,客户端调用unlock()
方法时,这个续期定时器会被取消。
加锁与解锁
Redisson的锁操作是通过执行Lua脚本来保证原子性的。
加锁(可重入) :
- 使用
HEXISTS
判断锁是否存在。如果不存在,则通过HSET
存入当前线程ID和计数1,并设置过期时间。 - 如果锁已存在,且
HGET
出的线程ID是当前线程ID,则证明是锁重入。只需将计数加1即可。 - 否则,返回锁的剩余过期时间,加锁失败。
- 使用
解锁:
- 检查锁的持有者是否为当前线程。如果不是,则抛出异常。
- 如果是,则将重入计数减1。
- 如果计数仍大于0,则仅更新哈希值;如果计数变为0,则通过
DEL
命令彻底删除该键,释放锁。
结论
分布式锁的实现远非一个简单的SETNX
命令。从单机锁到不可靠的主从锁,再到基于多数派共识的Redlock算法,我们看到的是为了在不可靠的网络和硬件之上构建可靠系统所付出的巨大努力。
Redlock通过牺牲一定的性能和增加架构的复杂性,换取了在分布式环境下锁的高安全性和容错性。而Redisson作为其工业级实现,通过看门狗机制解决了锁自动续期的难题,并提供了可重入等高级功能,使其成为在Java生态中使用Redis分布式锁的不二之选。理解其背后的原理,是构建健壮分布式应用的必经之路。