分布式锁的三种实现:数据库、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 加锁
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 加锁: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 校验持有者
-- 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 中:
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 秒续一次)。
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 唯一索引
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)
BEGIN;
SELECT * FROM resource WHERE id = ? FOR UPDATE; -- 锁住这一行
-- 业务逻辑
COMMIT; -- 提交时自动释放优点是锁的生命周期和事务绑定:进程崩溃、连接断开,事务回滚,锁自动释放,没有「锁泄漏」的问题。
代价也很明显:整个业务过程都占着数据库连接和行锁,并发高时连接池和锁等待都会成为瓶颈;锁等待时间受 innodb_lock_wait_timeout 控制(默认 50 秒),远长于一般业务的容忍度。
适合场景:并发不高、业务逻辑短、本来就要操作这张表。典型例子是账户余额扣减——锁的对象和要改的数据是同一行,顺带就把互斥解决了。
四、ZooKeeper 实现
原理是临时顺序节点:
- 每个客户端在
/lock/order-1下创建一个临时顺序节点,比如/lock/order-1/req-0000000003; - 列出所有子节点,序号最小的那个获得锁;
- 没获得锁的客户端只监听排在自己前一位的节点,避免节点删除时惊群;
- 前一个节点被删除(正常释放或会话断开)时,收到通知重新判断。
关键在于临时节点与会话绑定:客户端崩溃或网络断开,会话超时后 ZooKeeper 自动删除节点,锁随之释放。这比「靠 TTL 猜任务要跑多久」更贴近实际需求——释放的依据是「持有者是否还活着」,而不是「时间到了没有」。
代价是:
- 每次加锁解锁都要写入 ZooKeeper 并等待过半节点确认,吞吐远低于 Redis;
- 会话超时时间(通常几秒到几十秒)决定了崩溃后的释放延迟;
- 需要额外维护一个 ZooKeeper 集群。
Curator 的 InterProcessMutex 已经实现了上述细节,不建议自己写。
五、怎么选
| 维度 | 数据库 | Redis | ZooKeeper |
|---|---|---|---|
| 性能 | 最低 | 最高 | 中等 |
| 崩溃后释放 | 事务回滚立即释放;唯一索引方案靠清理任务 | 等 TTL 到期 | 会话超时后自动删除 |
| 误释放风险 | 校验 owner 即可 | 必须 Lua 校验 + 续期 | 基本没有 |
| 额外组件 | 无 | 通常已有 | 需要独立集群 |
| 适合 | 低频任务、已有事务上下文 | 绝大多数业务场景 | 对正确性要求极高、并发不高 |
实践中的默认选择是 Redis + Redisson:RLock 已经实现了 Lua 解锁、watchdog 续期、可重入等细节,比自己写可靠。自己实现时,至少要做到唯一持有者标识、Lua 解锁、超时兜底这三点。
六、常见误区
- 「
SETNX加锁再EXPIRE设置过期」:两步之间崩溃会留下永久锁,必须用SET ... NX PX。 - 「解锁直接 DEL」:实测会删掉别人的锁。
- 「TTL 设长一点就安全」:崩溃后其他实例要等待同样长的时间。
- 「用了分布式锁就不会重复」:锁失效的路径很多,业务层必须有唯一约束或条件更新兜底。
- 「RedLock 是更安全的选择」:它的安全性建立在时钟和停顿的假设上,不要把它当作强一致的保证。
小结
三种实现的差别,本质是「怎么判断持有者已经不在了」:数据库靠事务结束,Redis 靠时间到期,ZooKeeper 靠会话断开。Redis 性能最好,但需要自己补齐唯一标识、Lua 解锁和续期;ZooKeeper 语义最贴近需求,代价是吞吐和运维。无论选哪种,都要在业务层留一道兜底,锁只是用来减少冲突的。
锁与事务的先后顺序、防重复提交的具体写法,见 分布式锁与并发控制实战。
配套实验
- codesphere-labs/cache/redis-atomicity-and-locks:加锁、直接 DEL 误删、校验 token 的解锁与续期、租约过期与 fencing token(验证记录)
参考资料