图 1:本文封面,一把锁后面是四条路线。
之前一篇真题解析讲过 Zookeeper 分布式锁的实现细节,本文不再重复。今天把镜头拉远:分布式锁不止 ZK 一条路,四条主流路线背后是完全不同的设计哲学。搞清楚它们的取舍坐标,比记住任何一条路的实现细节都更值钱。
先立一个评判框架:分布式锁要回答三个问题——持锁者挂了怎么办(可靠性)、抢锁放锁有多快(性能)、这套组件要不要专门养(运维成本)。四条路线的差异全在这三个维度上。
路线一:数据库,能跑但别指望它快。
最简单的实现连新组件都不用引入:建一张锁表,对资源名加唯一约束,抢锁就是 insert 一行,插入成功即持锁,释放就 delete。唯一约束由数据库保证,天然互斥。怕持锁者宕机留下死锁,可以给行加个过期时间字段,抢锁时先清理过期记录。
这条路线的优点是零新增组件,团队已有的 MySQL 直接上岗。代价写在脸上:每次抢锁都是一次磁盘 IO 参与的写操作,吞吐是四条路线里最低的;也没有等待与通知机制,抢不到只能自己轮询。适合并发不高、对性能不敏感、但又要求可靠的场景——比如后台批处理的防重入。
路线二:Redis,性能王者,但要跟「提前失效」搏斗。
Redis 路线的核心是一条原子命令:SET key token NX PX 毫秒数——NX 保证只有键不存在才能写入(互斥),PX 给锁一个毫秒级过期时间(防死锁),value 里存一个唯一 token。释放时不能直接 DEL,要用 Lua 脚本「先比对 token 再删除」,否则任务超时后锁已易主,你会把别人的锁删掉——这也是 Redis 官方文档给出的标准模式。
最大的坑是任务跑得比 TTL 久:锁到期自动释放,第二个客户端趁虚而入,两个持有者同时改数据。工程上有两手应对:一是看门狗续期——以 Redisson 为例,持锁实例存活期间按周期自动延长过期时间,默认超时 30 秒;二是 fencing token,让被保护的资源拒绝携带旧序号的写入。另外主从切换也有理论风险:锁写入 master 后还没同步到 replica 就故障转移,新 master 上没有锁记录,Redisson 默认开启副本同步检查来关闭这个窗口。
路线三:Zookeeper,用会话把「死锁」这个词删掉。
ZK 路线的精髓是把锁的生命周期绑在客户端会话上:临时节点随会话结束自动删除,持锁者宕机,锁自动释放,不需要任何过期时间估算。顺序节点给出全局单调递增的排队序号,等待者只监听前驱,公平且无惊群。代价是每次抢锁都要过一遍共识写入,性能比 Redis 低一个量级,而且多数团队不会专门为了一把锁养一套 ZK 集群——它更适合「反正已经用了 ZK」的场合。
路线四:etcd,ZK 思路的现代重演。
etcd 的对应物是租约(lease):锁键绑定一个带 TTL 的租约,客户端用 KeepAlive 流持续续期,租约过期或被撤销,挂在它上面的键全部删除——与 ZK 的临时节点异曲同工。排队则靠 etcd 的全局修订号:每个键携带 create_revision,分布式锁就用创建修订号的大小决定等待顺序。语义与 ZK 同族,运维上更合 Go 技术栈的手。
用代码把「TTL 风险与看门狗」跑一遍。
下面这段模拟演示 Redis 路线经典的事故与解法:任务跑 250ms,锁 TTL 只有 100ms。用 Node.js 22.15.0 直接运行(node lock-ttl.js):
'use strict';
// Redis 风格锁的 TTL 风险与看门狗续期(逻辑时钟模拟)
const TICK = 10;
function simulate({ taskMs, ttlMs, watchdog }) {
let now = 0;
let holder = null;
let renewAt = watchdog ? ttlMs / 3 : Infinity; // 看门狗周期续期
const log = [];
const overlap = [];
let renewals = 0;
const taskA = { owner: 'A', start: 0, end: taskMs };
const taskB = { owner: 'B', start: null, end: null };
const tryAcquire = (owner) => {
if (!holder || holder.expireAt <= now) {
holder = { owner, expireAt: now + ttlMs };
log.push(`t=${now}ms ${owner} 抢到锁(至 t=${holder.expireAt}ms)`);
return true;
}
return false;
};
tryAcquire('A');
while (now <= taskMs + 200) {
now += TICK;
if (holder && holder.expireAt <= now) {
log.push(`t=${now}ms 锁过期,${holder.owner} 失去锁`);
holder = null;
}
if (watchdog && holder && holder.owner === 'A' && now >= renewAt && now < taskA.end) {
holder.expireAt = now + ttlMs;
renewAt += ttlMs / 3;
renewals++;
}
if (watchdog && holder && holder.owner === 'A' && now >= taskA.end) {
log.push(`t=${now}ms A 完成任务,主动解锁(看门狗共续期 ${renewals} 次)`);
holder = null;
}
if (taskB.start === null && now >= ttlMs && tryAcquire('B')) taskB.start = now;
if (taskB.start !== null && taskB.end === null && now >= taskB.start + 100) taskB.end = now;
const aActive = now > taskA.start && now < taskA.end;
const bActive = taskB.start !== null && now > taskB.start && now < (taskB.end ?? now + 1);
if (aActive && bActive) overlap.push(now);
}
log.push(`A 的任务区间 [0, ${taskMs}]ms,B 的任务区间 [${taskB.start}, ${taskB.end}]ms`);
log.push(overlap.length > 0 ? `结果:出现 ${overlap.length * TICK}ms 双持锁重叠(危险)` : '结果:全程无重叠(安全)');
return log;
}
console.log('— 无看门狗:任务 250ms 超过 TTL 100ms —');
simulate({ taskMs: 250, ttlMs: 100, watchdog: false }).forEach((l) => console.log(l));
console.log('');
console.log('— 有看门狗:同样参数 —');
simulate({ taskMs: 250, ttlMs: 100, watchdog: true }).forEach((l) => console.log(l));
实测输出:
— 无看门狗:任务 250ms 超过 TTL 100ms —
t=0ms A 抢到锁(至 t=100ms)
t=100ms 锁过期,A 失去锁
t=100ms B 抢到锁(至 t=200ms)
t=200ms 锁过期,B 失去锁
A 的任务区间 [0, 250]ms,B 的任务区间 [100, 200]ms
结果:出现 90ms 双持锁重叠(危险)
— 有看门狗:同样参数 —
t=0ms A 抢到锁(至 t=100ms)
t=250ms A 完成任务,主动解锁(看门狗共续期 7 次)
t=250ms B 抢到锁(至 t=350ms)
t=350ms 锁过期,B 失去锁
A 的任务区间 [0, 250]ms,B 的任务区间 [250, 350]ms
结果:全程无重叠(安全)
无看门狗时,A 的任务没跑完锁就过期了,B 在 t=100ms 捡走锁,两个持有者的任务区间重叠了 90ms——这就是「锁提前失效」的完整事故链。加上看门狗后,锁的生命周期跟着任务走,A 完成并主动解锁,B 才接手,全程无重叠。
图 2:四条路线在三根轴上的取舍坐标。
最后收敛成一张选型表。
强一致性刚需(金融扣款、库存扣减),选 ZK 或 etcd 这类协调组件,宁可慢也要语义正确;极致性能且业务能容忍极小概率的锁失效(或能用 fencing token 兜底),选 Redis;并发不高、不想引入新组件,数据库唯一约束完全够用。没有银弹,只有坐标系——先想清楚自己的业务在三根轴上的位置,答案自然浮现。
你在项目里用的是哪条路线?有没有踩过锁提前失效或主从切换丢锁的坑?欢迎分享。