Skip to content

分布式锁的三种实现:数据库、Redis 与 ZooKeeper ​

分布式锁的难点从来不是「怎么加锁」,而是「持有者崩溃了怎么办」。数据库靠事务、Redis 靠过期时间、ZooKeeper 靠会话,三种机制各自留下了不同的破绽。

本文用 Redis 8.10.1 实测了加锁、误删、Lua 校验解锁和续期这四个关键行为,再对比三种实现各自的失效方式与适用场景。

一、先说结论 ​

  • 一把可用的分布式锁至少要满足四点:互斥、不死锁(持有者崩溃后能释放)、只能由持有者解锁、可重入或明确不可重入。
  • Redis 实现用一条命令加锁:SET key uuid NX PX 30000,把「不存在才设置」和「设置过期时间」合成一次原子操作。
  • 解锁必须校验持有者。 实测 A 的锁过期后 B 拿到锁,A 再执行 DEL 直接删掉了 B 的锁;改用 Lua 校验后,A 的解锁返回 0。
  • 过期时间是个两难选择:设短了任务没跑完锁就没了,设长了崩溃后要等很久。解法是自动续期(watchdog),实测用 Lua 校验持有者后 PEXPIRE 即可。
  • 三种实现的取舍:数据库简单但性能最低,Redis 性能最高但依赖时钟与 TTL,ZooKeeper 语义最严谨但吞吐和运维成本更高。
  • 业务层面的兜底比锁本身更重要:唯一索引、状态机条件更新、幂等键,这些即使锁失效也能保证正确性。

二、Redis 实现:四个关键行为实测 ​

2.1 加锁 ​

bash
SET lock:order:1 ownerA NX PX 2000

实测 A 加锁返回 OK,B 在 A 持有期间加锁返回空(失败);以 2 秒租约加锁后立即查询,PTTL 为 2000ms。

三个要素缺一不可:

  • NX:只有 key 不存在时才设置,保证互斥;
  • PX:设置过期时间,持有者崩溃后锁能自动释放;
  • ownerA:唯一的持有者标识(通常是 UUID 加线程 ID),解锁时用来校验。

早期常见的「SETNX 加锁,再 EXPIRE 设置过期」是错的:两条命令之间崩溃,锁就永远不会过期。

2.2 误删:为什么解锁不能直接 DEL ​

客户端 A客户端 BSET NX PX 2000加锁成功业务执行超过 2 秒锁已自动过期DEL lock删掉的是 B 的锁SET NX PX 成功B 获得锁锁被 A 删除B 仍以为自己持有实测:A 的 DEL 返回 1,B 的锁消失;改用 Lua 校验持有者后,A 解锁返回 0
图 1 · A 的锁因超时自动释放,B 拿到锁后 A 才执行完,这时 A 的 DEL 删掉的是 B 的锁;解锁必须校验持有者

实测过程:

text
A 加锁:SET lock token-a NX PX 300   → OK
B 加锁:SET lock token-b NX PX 300   → 空,A 还持有锁
等待 400ms(A 的业务还没跑完,锁已过期)
B 加锁:SET lock token-b NX PX 5000  → OK,B 现在持有锁
A 执行 DEL lock                       → 返回 1,删掉的是 B 的锁
GET lock                              → 空,锁已经不存在

这不是理论风险:只要业务耗时可能超过 TTL(GC 停顿、下游变慢、网络抖动都会导致),就会发生。

2.3 用 Lua 校验持有者 ​

lua
-- KEYS[1] = 锁的 key,ARGV[1] = 自己的持有者标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

实测:A(非持有者)解锁返回 0,锁仍然是 B 的;B 解锁返回 1,锁被删除。

Lua 脚本在 Redis 中原子执行,所以「读取并比较」和「删除」之间不会插入其他命令。用 GET 判断再 DEL 的两步写法同样有竞态。

Spring Data Redis 中:

java
private static final RedisScript<Long> UNLOCK = RedisScript.of(
        "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end", Long.class);

public boolean unlock(String key, String owner) {
    return Long.valueOf(1L).equals(redis.execute(UNLOCK, List.of(key), owner));
}

2.4 续期:解决「TTL 设多长」的两难 ​

TTL 设置的本质矛盾:它既是「崩溃后多久释放」,又是「任务最长能跑多久」。两者的合理值往往差很多。

解法是让持有者定期续期:后台线程每隔 TTL 的三分之一检查一次,如果还持有锁就把它延长。Redisson 的 watchdog 就是这个机制(默认 30 秒 TTL,每 10 秒续一次)。

lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0

实测 B 以 2 秒租约加锁,约 1.1 秒后锁剩 897ms:A(非持有者)续期返回 0,TTL 不变;B 续期返回 1,TTL 变为 4999ms。

续期同样要校验持有者,否则会把别人的锁续上。另外,续期线程必须在业务失败或进程退出时停止,否则锁永远不会释放。

2.5 关于 RedLock ​

RedLock 是 Redis 作者提出的多节点算法:向 N 个独立的 Redis 实例申请锁,超过半数成功且总耗时小于锁有效期才算获得锁。它想解决的是单个 Redis 主从切换时锁丢失的问题(异步复制下,主节点写入锁之后宕机,从节点上没有这把锁)。

它也一直存在争议,核心分歧在于它对时钟和进程停顿的假设:如果某个节点的时钟跳变,或者持有者发生长时间 GC 停顿,算法的安全性论证就不成立。

实践建议:不要指望任何分布式锁提供严格的互斥保证。把锁当作「减少冲突的优化」,正确性交给数据库的唯一索引、条件更新或幂等键来兜底。这一点在 分布式锁与并发控制实战 中有具体写法。

停顿的持有者并不是理论问题:实测租约 500ms 的持有者停顿 1.5 秒后恢复,锁早已归别人,它的写入仍然覆盖了新持有者的结果;让下游校验单调递增的 fencing token 后,这次写入被拒绝。实验过程见 Redis 原子性边界。

三、数据库实现 ​

3.1 唯一索引 ​

sql
CREATE TABLE distributed_lock (
  lock_key    VARCHAR(64) PRIMARY KEY,
  owner       VARCHAR(64) NOT NULL,
  expire_at   DATETIME    NOT NULL
);

-- 加锁:插入成功即获得锁
INSERT INTO distributed_lock (lock_key, owner, expire_at) VALUES (?, ?, ?);
-- 解锁:只能删自己的
DELETE FROM distributed_lock WHERE lock_key = ? AND owner = ?;

问题在于「持有者崩溃」:记录不会自己消失,需要一个清理任务按 expire_at 删除过期记录,这和 Redis 的 TTL 是一回事,只是要自己实现。而且清理任务本身也可能误删——清理的瞬间,锁可能刚被另一个进程重新获取。

3.2 悲观锁(SELECT ... FOR UPDATE) ​

sql
BEGIN;
SELECT * FROM resource WHERE id = ? FOR UPDATE;   -- 锁住这一行
-- 业务逻辑
COMMIT;                                            -- 提交时自动释放

优点是锁的生命周期和事务绑定:进程崩溃、连接断开,事务回滚,锁自动释放,没有「锁泄漏」的问题。

代价也很明显:整个业务过程都占着数据库连接和行锁,并发高时连接池和锁等待都会成为瓶颈;锁等待时间受 innodb_lock_wait_timeout 控制(默认 50 秒),远长于一般业务的容忍度。

适合场景:并发不高、业务逻辑短、本来就要操作这张表。典型例子是账户余额扣减——锁的对象和要改的数据是同一行,顺带就把互斥解决了。

四、ZooKeeper 实现 ​

原理是临时顺序节点:

  1. 每个客户端在 /lock/order-1 下创建一个临时顺序节点,比如 /lock/order-1/req-0000000003;
  2. 列出所有子节点,序号最小的那个获得锁;
  3. 没获得锁的客户端只监听排在自己前一位的节点,避免节点删除时惊群;
  4. 前一个节点被删除(正常释放或会话断开)时,收到通知重新判断。

关键在于临时节点与会话绑定:客户端崩溃或网络断开,会话超时后 ZooKeeper 自动删除节点,锁随之释放。这比「靠 TTL 猜任务要跑多久」更贴近实际需求——释放的依据是「持有者是否还活着」,而不是「时间到了没有」。

代价是:

  • 每次加锁解锁都要写入 ZooKeeper 并等待过半节点确认,吞吐远低于 Redis;
  • 会话超时时间(通常几秒到几十秒)决定了崩溃后的释放延迟;
  • 需要额外维护一个 ZooKeeper 集群。

Curator 的 InterProcessMutex 已经实现了上述细节,不建议自己写。

五、怎么选 ​

数据库唯一索引 / FOR UPDATERedisSET NX PX + LuaZooKeeper临时顺序节点 + Watch持有者崩溃事务回滚即释放唯一索引方案需要兜底清理持有者崩溃等 TTL 到期TTL 太短会提前失效持有者崩溃会话断开即删除节点依赖会话超时时间性能最低性能最高性能中等没有一种实现能同时保证「不会有两个持有者」和「持有者崩溃立刻释放」
图 2 · 数据库锁靠事务或唯一约束,Redis 锁靠 TTL,ZooKeeper 锁靠会话;三者在网络分区和进程卡顿时的表现不同
维度数据库RedisZooKeeper
性能最低最高中等
崩溃后释放事务回滚立即释放;唯一索引方案靠清理任务等 TTL 到期会话超时后自动删除
误释放风险校验 owner 即可必须 Lua 校验 + 续期基本没有
额外组件无通常已有需要独立集群
适合低频任务、已有事务上下文绝大多数业务场景对正确性要求极高、并发不高

实践中的默认选择是 Redis + Redisson:RLock 已经实现了 Lua 解锁、watchdog 续期、可重入等细节,比自己写可靠。自己实现时,至少要做到唯一持有者标识、Lua 解锁、超时兜底这三点。

六、常见误区 ​

  • 「SETNX 加锁再 EXPIRE 设置过期」:两步之间崩溃会留下永久锁,必须用 SET ... NX PX。
  • 「解锁直接 DEL」:实测会删掉别人的锁。
  • 「TTL 设长一点就安全」:崩溃后其他实例要等待同样长的时间。
  • 「用了分布式锁就不会重复」:锁失效的路径很多,业务层必须有唯一约束或条件更新兜底。
  • 「RedLock 是更安全的选择」:它的安全性建立在时钟和停顿的假设上,不要把它当作强一致的保证。

小结 ​

三种实现的差别,本质是「怎么判断持有者已经不在了」:数据库靠事务结束,Redis 靠时间到期,ZooKeeper 靠会话断开。Redis 性能最好,但需要自己补齐唯一标识、Lua 解锁和续期;ZooKeeper 语义最贴近需求,代价是吞吐和运维。无论选哪种,都要在业务层留一道兜底,锁只是用来减少冲突的。

锁与事务的先后顺序、防重复提交的具体写法,见 分布式锁与并发控制实战。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权