Redis 分布式锁

这篇笔记还在编辑中,内容可能会继续补充、调整或重写。

为什么需要分布式锁?

在深入讨论实现细节之前,先要理解我们为什么需要分布式锁。

与分布式锁相对的是单机锁。在编写多线程程序时,为了避免多个线程同时修改共享变量而产生数据问题,我们通常会用锁实现互斥,保证共享变量的正确性。不过,这类锁的作用范围通常只在同一个进程内。

如果多个进程需要同时操作一份共享资源,应该怎么实现互斥?

现代业务应用通常会部署多个实例。假设这些进程都要修改 MySQL 中的同一行数据,单机锁无法约束其他进程。为了避免并发操作破坏业务状态,我们需要把锁放到所有进程都能访问的地方,这就是分布式锁要解决的问题。

实现分布式锁需要依赖一个外部系统,所有进程都向它请求获取锁。这个系统必须提供互斥能力:两个请求同时到达时,只能让一个请求成功,另一个请求失败或等待。

MySQL、Redis 和 ZooKeeper 都可以承担这个角色。实际选择不能只看性能,还要看业务需要的一致性、可用性和故障模型。下面以 Redis 为例,从最简单的实现开始,逐步分析分布式锁里的安全问题。

如何实现分布式锁?

Redis 的 SETNX 表示 SET if Not eXists:只有 key 不存在时才写入,否则什么也不做。利用这个语义,可以实现一个最基本的互斥锁。

客户端 1 先请求获取锁:

127.0.0.1:6379> SETNX lock 1
(integer) 1

返回 1 表示写入成功,客户端 1 获得了锁。客户端 2 随后执行同一条命令:

127.0.0.1:6379> SETNX lock 1
(integer) 0

因为 lock 已经存在,客户端 2 获取失败。此时客户端 1 可以继续操作共享资源,例如修改 MySQL 中的一行数据或调用一个 API。

操作完成后,客户端需要及时释放锁,让其他客户端继续竞争:

127.0.0.1:6379> DEL lock
(integer) 1

最基本的流程就是:

获取锁 → 操作共享资源 → 释放锁

这个实现的问题也很明显。如果客户端 1 获取锁后发生异常,或者进程直接崩溃,它就没有机会执行 DEL。锁会一直留在 Redis 中,其他客户端也就永远无法获得它。

如何避免死锁?

一个直观的解决办法是给锁设置租期,也就是让 key 到期后自动删除。

假设一次操作通常不会超过 10 秒,可以在获取锁后设置过期时间:

127.0.0.1:6379> SETNX lock 1
(integer) 1
127.0.0.1:6379> EXPIRE lock 10
(integer) 1

这样即使客户端异常退出,锁也会在 10 秒后释放。不过这里还有一个问题:SETNX 和 EXPIRE 是两条独立命令。

如果 SETNX 成功,但客户端在执行 EXPIRE 前崩溃,或者第二条命令因为网络问题没有送达 Redis,key 就没有过期时间。此后如果也没有显式执行 DEL 或其他恢复机制,锁可能长期存在,其他客户端将无法获得它。

从 Redis 2.6.12 开始,SET 支持 NX 和过期时间选项,可以用一条命令原子地完成加锁和设置租期:

127.0.0.1:6379> SET lock 1 EX 10 NX
OK

到这里,持锁客户端即使崩溃,锁也能在租期结束后自动释放。但自动过期又带来了新的问题。

考虑下面的过程:

  1. 客户端 1 获取锁,开始操作共享资源。
  2. 操作时间超过锁的租期,锁自动过期。
  3. 客户端 2 获取同一把锁,也开始操作共享资源。
  4. 客户端 1 完成操作并执行 DEL,却把客户端 2 的锁删除了。

这里有两个独立的问题:客户端 1 的任务还没完成,锁就已经过期;客户端 1 完成后,又释放了不属于自己的锁。

把过期时间从 10 秒增加到 20 秒,可以降低问题发生的概率,却不能消除它。业务执行期间还可能遇到进程暂停、网络超时和下游服务变慢。只要租期来自预估,就总有可能出现任务比租期更长的情况。

如果锁被别人释放了怎么办?

客户端获取锁时,不能把所有锁的 value 都写成 1,而应该为每次加锁生成一个跨客户端、跨请求唯一且难以猜测的标识,例如 UUID:

127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

释放时先检查锁的 value 是否仍然等于自己的 UUID,只有相等时才能删除:

if redis.get("lock") == $uuid:
    redis.del("lock")

不过,GET 和 DEL 仍然是两条命令。如果锁在 GET 之后、DEL 之前刚好过期,并被另一个客户端重新获得,旧客户端仍然会误删新锁。

因此,检查所有权和删除也必须原子执行。常见做法是使用 Lua 脚本:

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end

return 0

Redis 会把脚本作为一个原子操作执行,其他命令不会插入脚本的执行过程。这样就能保证只有锁的当前持有者可以删除它。

到这里,一个相对严谨的单实例 Redis 锁流程是:

  1. 加锁:SET $lock_key $unique_id EX $expire_time NX。
  2. 操作共享资源。
  3. 解锁:用 Lua 脚本检查 value,再删除 key。

锁的过期时间很难估算怎么办?

前面提到,如果过期时间太短,任务还没执行完,锁就可能提前失效。把租期设置得更宽裕只能缓解问题,更通用的做法是自动续期。

客户端成功获取锁后,可以启动一个后台任务,定期检查锁是否仍然属于自己。如果业务还没有执行完,并且锁即将过期,就延长它的租期。这个后台任务通常被称为看门狗(Watchdog)。

在 Java 技术栈里,Redisson 已经封装了这套机制。调用未指定有限 leaseTime 的加锁方法,或传入 -1 时,只要持锁的 Redisson 实例仍然存活且锁尚未释放,它就会按 lockWatchdogTimeout 自动续期。只有显式传入正数 leaseTime 时,锁才会按指定时间到期,不使用看门狗续期。

Redisson 还提供可重入锁、公平锁、读写锁等抽象,让分布式锁的调用方式接近本地锁。不过,看门狗只能降低业务执行超过租期的概率。进程长时间暂停、与 Redis 失联或续期结果不确定时,旧客户端仍可能在锁失效后继续操作共享资源。因此,对正确性要求很高的业务,还需要幂等、条件更新或 fencing token 等下游保护,不能只依赖续期。

前面的问题可以暂时归纳为:

  • 持锁客户端崩溃导致死锁:为锁设置过期时间。
  • 加锁和设置过期时间之间存在窗口:用一条 SET ... NX EX 命令完成。
  • 任务超过锁的租期:合理设置租期,并按需使用自动续期。
  • 旧客户端释放了新客户端的锁:使用唯一标识,并原子地校验和删除。

这些讨论都建立在单个 Redis 主节点上。真实环境里,Redis 经常采用主从复制和 Sentinel 部署。主节点故障后,Sentinel 可以把从节点提升为新的主节点,但这会带来另一类问题。

主从切换后,锁还安全吗?

考虑下面的过程:

  1. 客户端 1 在主节点执行 SET,成功获取锁。
  2. 这次写入还没复制到从节点,主节点就发生故障。
  3. Sentinel 把缺少这条写入的从节点提升为新主节点。
  4. 客户端 2 在新主节点上再次获取同一把锁。

Redis 的常规主从复制是异步的,因此这段故障窗口确实可能破坏锁的互斥性。客户端 1 和客户端 2 都会认为自己成功获得了锁。

为了解决单节点故障带来的问题,Redis 的作者提出了 Redlock:客户端同时向多个彼此独立的 Redis 主节点请求加锁,只有在多数节点上成功,并且获取过程没有消耗掉过多租期,才认为加锁成功。

Redlock 的问题在哪里?

Redlock 解决的是多数 Redis 节点在租期内对锁状态达成一致,却不能阻止一个已经失去租约的客户端继续操作共享资源。考虑下面的情况:

客户端 1:获得多数锁 ── 长时间暂停 ───────── 恢复并写入
Redis:    锁 1 ─────── 过期 ── 锁 2
客户端 2:                    获得多数锁 ── 写入

客户端 1 可能因为 GC、进程调度或网络延迟暂停到租期结束。此时客户端 2 可以正常获得多数锁。客户端 1 恢复后如果不知道租约已经失效,两个客户端仍然会先后写入同一份资源。增加 Redis 节点数量没有消除这个问题,因为它来自租约过期,而不是某个节点丢失了锁记录。

Redlock 的安全性还依赖时间假设。客户端需要确认获取多数锁的耗时小于 TTL,各节点的时钟也要以近似相同的速度前进。Redis 官方文档指出,key 过期目前不使用单调时钟;墙上时钟发生跳变时,多个客户端可能同时认为自己持有锁。节点崩溃后立即重启还可能丢失未持久化的锁,因此严格按算法实现时,还要考虑 fsync=always 或让重启节点等待超过最长 TTL,代价分别是性能或可用性。

Martin Kleppmann 对 Redlock 的主要批评是:算法返回的随机 token 只能用于识别锁的所有者,不能表示锁的先后顺序。更可靠的做法是让锁服务在每次成功加锁时返回单调递增的 fencing token,并让下游资源拒绝旧 token:

客户端 2 携带 token 42,写入成功
客户端 1 恢复后携带 token 41,下游拒绝写入

fencing token 必须由下游系统在与写入一致的原子边界内校验。只生成 token,或者客户端在写入前自行检查一次,都挡不住检查之后发生的暂停。Redlock 本身也没有提供生成全局单调序号的机制。

这个问题存在争议。Redlock 的作者 Salvatore Sanfilippo 认为,获取前后检查耗时可以排除获取过程中的长延迟,随机 token 也可以配合下游的 compare-and-set 使用;Kleppmann 则认为,一旦正确性依赖锁,就不能把有界进程暂停、网络延迟和时钟漂移当成可靠前提。Redis 官方文档保留了 Redlock 的安全论证,也同时链接了双方分析,并明确建议正确性敏感的系统使用 fencing token、不要假设进程存活就代表租约仍然有效。

所以,是否使用 Redlock 要先看锁失效的后果。如果只是缓存重建或任务去重,偶发重复执行只增加成本,Redis 锁通常已经够用;如果锁失效会破坏资金、库存等关键不变量,就应该让数据库或其他下游系统通过幂等键、唯一约束、条件更新、事务或 fencing token 提供最后一道正确性保证,必要时选择基于共识的协调系统。

参考资料