目录
一、一次没有报错的双写
一个每晚跑的结算任务,部署在两个实例上。两个实例启动后都去做同一件事:扫描当天的待结算批次,汇总金额,写回总账。为了防止重复执行,代码里加了一把 Redis 锁:
const token = crypto.randomUUID();const ok = await redis.set(`lock:settle:${batch}`, token, "PX", 30_000, "NX");if (!ok) return;
try { await settle(batch);} finally { await release(`lock:settle:${batch}`, token);}很标准。加锁、设过期、比对 token 再释放,网上能找到的示例基本都是这么写的。
然后有一天,A 实例在做汇总时遇到一次长时间停顿——可能是 Full GC,可能是虚拟机热迁移,也可能是宿主机 CPU 被别的租户抢走。停顿超过了 30 秒,锁到期了。B 实例这时拿到锁,开始结算同一个批次。A 实例恢复后并不知道自己的锁已经没了,继续把汇总结果写进总账。
| 时刻 | A 实例 | B 实例 | 数据 |
|---|---|---|---|
| 0s | 拿到锁,TTL 30s | — | 未变 |
| 5s | 开始汇总,进程被冻结 | — | 未变 |
| 30s | 锁到期 | — | 未变 |
| 31s | 仍在冻结 | 拿到锁 | 未变 |
| 40s | 恢复,继续写 | 正在汇总 | A 写入一次 |
| 45s | 释放锁(比对失败,安全地没删) | 仍在写 | — |
| 50s | — | 写入 | B 又写入一次 |
两次写入都成功,两个请求都返回 200,日志里没有任何异常。唯一能看出问题的地方,是总账对不上。
这次事故里,「解锁前比对 token」的写法起了作用:A 恢复后没有把 B 的锁删掉。但它保护的是锁本身,不是被锁保护的数据。锁过期之后,临界区里的代码还在跑,互斥已经名存实亡。 比对删除只能防止误删别人的锁,防不住两个持有者同时写。
这就是分布式锁和本地互斥量的根本区别。std::mutex 或者 pthread_mutex 不需要过期时间,因为进程死了,锁自然跟着消失;分布式锁的持有者和锁服务是两个进程,锁服务无法知道持有者是死了还是只是慢了,于是只能用租约(lease)来猜。租约一旦短于临界区,猜错就成了必然事件。
二、锁承诺的三件事
把「加锁」这个动作拆开,它其实在回答三个不同的问题:
| 问题 | 术语 | 答错的后果 |
|---|---|---|
| 同一时刻是否只有一个持有者? | 互斥 | 两个实例同时改同一份数据 |
| 这个持有资格有效多久? | 租约 | 锁提前失效,互斥破裂 |
| 被保护的资源怎么确认谁是当前持有者? | 所有权证明 | 过期的持有者继续写入,无人拦截 |
大多数「分布式锁用错了」的事故,都出在后两件事上:TTL 是拍脑袋写的,资源端没有任何令牌校验。大家把注意力放在第一件事上——SET NX 确实能保证只有一个客户端设置成功——却忘了这个「成功」只在设置的那一刻成立。
一个更准确的说法是:分布式锁不是一把锁,而是一份带截止时间的租约,加上一份可以被验证的所有权声明。 租约负责让锁服务在持有者失联后自愈,所有权声明负责让资源端拒绝过期的写入。缺了后者,租约到期就是一次静默的互斥失效。
下面按这三件事,分别看 Redis、etcd 和数据库行锁各自做到了哪一步。
三、Redis:租约很容易,所有权证明要自己补
Redis 加锁的核心就是一条命令:
SET lock:order:42 <token> NX PX 10000NX 保证只有 key 不存在时才设置成功,PX 给出毫秒级过期时间,整条命令是原子的。释放则不能直接 DEL,因为当前持有者可能已经过期,key 已经属于别人:
-- release.lua:只有值还是自己的 token 时才删除if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1])endreturn 0把这段逻辑放进 Lua 脚本,是为了让「读 token」和「删 key」在 Redis 里原子完成。用 GET 之后再 DEL 的两条命令,中间仍可能被其他客户端插入。
到这里,Redis 提供的是:单节点上的原子租约。它不提供所有权证明,也不提供「持有者失联后资源端自动拒绝写入」的能力。这两件事都得由业务层补。
常见的补法是续期:起一个定时器,在 TTL 过半时把过期时间往后推。
const timer = setInterval(() => { renew(key, token, 10_000); // 比对 token 后 PEXPIRE}, 3_000);续期能解决「临界区比 TTL 长」的问题,但解决不了「进程暂停」的问题。定时器回调和业务代码跑在同一个事件循环里,进程被冻结时,两者一起停。暂停超过 TTL,续期还没来得及执行,锁就已经过期了。用一个同样会暂停的线程去续一个会因为暂停而过期的租约,逻辑上绕不开这个洞。
Redis 作为锁服务还有两个结构性风险:
- 单节点重启会丢锁。 没开持久化,或者持久化落后于最后一次写入,重启后锁就没了。
- 主从异步复制会丢锁。 客户端在主节点加锁成功,主节点还没来得及把这条写入同步给从节点就宕机了,故障转移后新主节点上根本没有这把锁,另一个客户端可以立刻加锁成功。
第二点尤其隐蔽:加锁请求返回了成功,锁却在几秒后凭空消失。Redis 官方文档在讨论 Redlock 时也承认,单实例和主从方案在故障转移窗口内不满足互斥。这也是为什么会有 Redlock:用多个独立主节点投票,降低单个节点故障带来的影响。
顺带说一句,缓存击穿里常见的 SET NX 和这里说的分布式锁不是一回事。那篇讲 Redis 缓存设计的文章里用它来减少重复回源,重复一两次只是多打一次数据库,可以接受;而结算、扣库存这类操作,重复一次就是资损。判断能不能用「尽力而为」的锁,先看重复执行的代价,而不是看示例代码有多像。
四、Redlock:多数派加锁,仍然绕不开暂停
Redlock 的思路不复杂:
- 准备 N 个互相独立的 Redis 主节点(通常 N=5)。
- 客户端依次向每个节点发送
SET key token NX PX ttl,每个节点有单独的短超时。 - 只有在多数节点(N/2+1)上成功,并且总耗时小于 TTL,才算加锁成功。
- 锁的有效时间要减去已经消耗的时间,再考虑时钟漂移的余量。
它比单节点强的地方在于:少数节点宕机不影响加锁,多数节点同时丢数据的概率很低。但它没有改变一个前提——锁的有效性仍然建立在对时间的假设上。
Kleppmann 在 2016 年那篇《How to do distributed locking》里把问题说得很直接:Redlock 的安全性依赖三个假设——网络延迟有上界、进程暂停有上界、时钟漂移有上界。而生产环境里,GC 停顿、虚拟机迁移、CPU 限流、NTP 校时,都可能让这三个上界失效。更关键的是,即使加锁过程完全正确,客户端也可能在「加锁成功」和「开始操作资源」之间暂停;等它恢复时,锁早已过期,另一个客户端已经拿到了锁。
Antirez 随后写了回应,争论的焦点在于这些假设在工程上是否足够常见、以及 Redlock 的定位是不是「效率优先」。这场争论没有出现一方完全说服另一方,但有一个结论是双方都认可的:如果你需要的是「资源端拒绝过期持有者的写入」,那光靠锁服务不够,必须在资源端加栅栏令牌(fencing token)。
所以更实用的结论不是「Redlock 能不能用」,而是分清用途:
- 用来减少重复劳动——定时任务不要被两个实例同时跑、缓存不要被同时回源——
SET NX PX已经够用,偶尔重复一次可以接受。 - 用来保护不可重复的写——扣款、发券、状态机流转——锁只能降低概率,正确性要落在存储层的条件更新或栅栏令牌上。
五、etcd:把 revision 当栅栏令牌
etcd 的锁建立在 lease 和事务上。lease 是一个带 TTL 的租约,客户端需要定期续约;key 可以绑定到 lease,lease 过期或被撤销时,key 自动删除。
加锁的原子操作是一个事务:只有 key 的创建版本号为 0(也就是 key 还不存在)时才写入。
lease, err := client.Grant(ctx, 10) // 10 秒租约if err != nil { return err}
key := "/lock/settle/2026-11-10"txn, err := client.Txn(ctx). If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)). Then(clientv3.OpPut(key, holderID, clientv3.WithLease(lease.ID))). Commit()if err != nil { return err}if !txn.Succeeded { // 已经有人持有,转入 watch 等待 return ErrLocked}
fence := txn.Header.Revision // 单调递增,可作为栅栏令牌和 Redis 相比,etcd 多出来的东西是 txn.Header.Revision。etcd 的 MVCC 存储给每次修改分配一个全局单调递增的 revision,事务提交后的 revision 可以用作栅栏令牌:持有者把 fence 一起带给资源端,资源端只接受比已见过的更大的令牌。
etcd 的另一个区别是故障语义。它基于 Raft,写操作需要多数派确认;失去多数派时,集群会拒绝写入,而不是让两个客户端各自以为自己拿到了锁。「锁服务不可用」比「锁服务悄悄说谎」更容易处理——前者会快速失败,后者会在数据里留下痕迹。代价也在这里:每次加锁都是一次多数派写入,跨地域部署时延迟由网络往返决定,不适合高频、短临界区的场景。
等待锁通常用 watch:监听 key 的删除事件,被唤醒后重新尝试事务。这里要留意惊群——大量等待者同时被唤醒、同时发起事务,只有一个能成功,其余继续等。etcd 官方客户端库里的 concurrency.Mutex 已经封装了排队逻辑,生产环境优先用它,而不是自己拼事务和 watch。
还有一个容易被忽略的边界:revision 的单调性是针对同一个集群的。如果灾备切换到一个从快照重建的新集群,revision 可能从更小的值重新开始,而资源端还记着旧集群发出去的大令牌。跨集群使用栅栏令牌时,要把「令牌来源的身份」也纳入比较,或者干脆让资源端和锁服务在同一个故障域里。
六、数据库行锁:最短的那条路
如果被保护的数据本来就在数据库里,最省事的方案往往不是引入一个锁服务,而是直接用数据库自己的锁。
BEGIN;SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;-- 应用层计算新余额UPDATE accounts SET balance = 42.50 WHERE id = 42;COMMIT;SELECT ... FOR UPDATE 会对选中的行加排他锁,直到事务提交或回滚。它的租约就是事务本身:
- 提交、回滚、连接断开,锁都会释放,不需要 TTL,也不依赖客户端时钟。
- 锁的作用域是数据库,天然覆盖了同一数据库里的其他事务。
- 没有「锁过期了但持有者还在写」的窗口——事务没结束,锁就还在;事务结束了,写也已经提交或回滚。
这几条加起来,让行锁在「保护数据库内的一次写」这个场景里,比任何外部锁都更贴合。但它的边界同样清楚:
- 它只保护数据库里的操作。 事务里调用支付接口、发消息、写对象存储,这些副作用不受行锁保护。外部系统看到的是「事务还没提交」,而不是「这行被锁住了」。
- 长事务会放大代价。 行锁持有期间会占住连接,阻塞其他事务;连接池被长事务占满,整个服务都会受影响。锁等待超时和死锁检测是兜底,不是设计目标。
- 锁的范围取决于索引。
WHERE条件没有走索引时,InnoDB 可能扫描并锁住更多记录;在REPEATABLE READ下还涉及间隙锁。用主键或唯一索引定位到具体行,是让锁范围可控的前提。
很多「需要分布式锁」的场景,其实只是一个单行不变量。比如扣库存:
UPDATE inventorySET remaining = remaining - 1WHERE sku = 'A-100' AND remaining > 0;检查 remaining > 0 和减法在同一条语句里完成,数据库保证它对这一行是原子的。影响行数为 0,就说明库存不足或者被并发抢光了,不需要先查再改,也不需要额外加锁。能用一条条件更新表达的不变量,不要用「查—判断—写」加一把锁去模拟。
唯一约束也是同理。与其用锁保证「同一个订单只处理一次」,不如在表上建唯一索引,让重复插入直接失败。数据库的约束是持久的、对所有写入路径生效的;锁只对愿意遵守锁协议的代码生效。
PostgreSQL 还提供 advisory lock,分会话级和事务级。事务级(pg_advisory_xact_lock)会随事务结束自动释放,适合「这段逻辑只允许一个事务跑」的场景;会话级则需要自己确保连接关闭时释放,连接池复用时尤其要小心。它们不锁具体的数据行,锁的是应用自己约定的一把「命名锁」,适合保护跨表或非行级的操作。
七、栅栏令牌怎么落到存储层
如果锁服务和被保护的资源不在同一个系统里,所有权证明就必须显式传递。做法是让锁服务在发放锁的同时发放一个单调递增的令牌,资源端记住见过的最大令牌,拒绝更小的。
UPDATE ledger_batchSET status = 'settled', fencing_token = $1WHERE batch_id = $2 AND status = 'pending' AND COALESCE(fencing_token, 0) < $1;这段 SQL 里,令牌比较和状态更新在同一个原子操作里完成。三种结果要分开处理:
- 影响 1 行:令牌最新,写入成功。
- 影响 0 行且状态已是
settled:这个批次已经被处理过,属于正常的幂等命中。 - 影响 0 行且状态仍是
pending:手里的令牌比存储层见过的旧,说明锁在临界区中途过期了,另一个持有者已经先写过。这是真正的异常,应该中止并告警,而不是重试。
令牌的来源必须满足单调性。用 etcd 的 revision、数据库序列、或者带严格递增保证的 ID 生成器都可以。不要用 Redis 的 INCR 当令牌来源:主从异步复制下,故障转移后计数器可能回退,令牌不再单调,栅栏就失效了。如果锁服务本身是 Redis,至少要把令牌的持久化和单调性交给另一个有共识保证的系统。
还有一个更彻底的做法:把锁和资源放在同一个系统里。用数据库行锁保护数据库写入,用 etcd 事务保护 etcd 里的配置,锁的作用域和资源的作用域重合,就不需要跨系统传递令牌。栅栏令牌是跨系统协作的补丁,不是默认配置。
八、选型:先问临界区保护的是什么
| 临界区 | 更合适的做法 | 原因 |
|---|---|---|
| 数据库里一行的不变量(库存、余额、状态) | 条件更新、唯一约束、SELECT ... FOR UPDATE | 原子性和持久性由数据库保证,没有租约和时钟假设 |
| 同一数据库里的多行事务 | 行锁 + 明确的隔离级别 | 锁随事务释放,边界清晰 |
| 只减少重复劳动(定时任务、缓存回源) | Redis SET NX PX | 偶发重复可以接受,成本和延迟更低 |
| 需要强互斥,且已有 etcd | lease + 事务 + revision 令牌 | 多数派写入,失去 quorum 时快速失败 |
| 跨服务的外部副作用(扣款后调支付) | 幂等键 + 状态机 + 栅栏令牌 | 锁管不到外部系统,重复请求要靠幂等收敛 |
| 跨地域 active-active 写 | 分区、冲突解决或单写区域 | 跨洋加锁的延迟不可接受,锁也挡不住分区 |
这张表想说明一件事:锁的适用范围,取决于它和被保护资源是否在同一个故障域、同一个事务边界里。 锁服务越远,需要补的所有权证明就越多。
九、上线前检查清单
- TTL 是否基于实测? 至少覆盖临界区的 p99.9,而不是拍一个「10 秒应该够了」。同时要有监控:锁的持有时间分布、续期失败的次数。
- 解锁是否比对 token? 直接
DEL会在锁过期后误删新持有者的锁。 - 续期线程挂了会怎样? 续期失败要能主动中止业务,而不是继续写。
- 资源端是否校验令牌? 没有校验,锁就只是「减少并发」的礼貌约定,不是正确性保证。
- 锁服务故障时是拒绝还是放行? 正确性优先的场景选择 fail-closed:拿不到锁就不执行。放行会让互斥直接失效。
- 重试是否有上限、退避和抖动? 大量实例同时重试会形成风暴,把锁服务本身打挂。
- 是否有幂等兜底? 锁降低重复概率,幂等负责在重复发生时把损害收敛到零。
- 是否观测栅栏冲突? 令牌被拒绝的次数应该接近零。一旦持续出现,说明临界区经常超过租约,是容量或设计问题,不是偶发。
十、小结
- 分布式锁是带截止时间的租约,不是本地互斥量。 锁服务无法区分「持有者死了」和「持有者只是停了」,只能用 TTL 猜。
- 锁过期不等于临界区结束。 进程暂停超过 TTL 时,两个持有者会同时写,而且都不会报错。
- 解锁比对 token 只保护锁,不保护数据。 真正的兜底在资源端:条件更新、唯一约束或栅栏令牌。
- Redis 方案成本低,但故障转移可能丢锁。 Redlock 提高了可用性,没有消除对时钟和暂停的假设。
- etcd 用多数派写入和单调 revision 补上了所有权证明,代价是延迟和运维复杂度;失去 quorum 时它会拒绝服务,而不是分裂。
- 数据库行锁的租约就是事务。 保护数据库内的写,它是最贴合的工具;保护外部副作用,它无能为力。
- 能写成一条条件更新,就不要用「查—判断—写」加锁。 让数据库的原子性替你完成互斥。
- 先问重复执行的代价,再决定用哪种锁。 多跑一次定时任务和重复扣一笔钱,需要的不是同一套机制。
参考来源
- Redis 官方文档:Distributed Locks(
SET NX PX、比对删除、Redlock 算法与故障转移讨论) - Redis 官方文档:SET 命令(
NX、PX与原子性语义) - Martin Kleppmann: How to do distributed locking(租约、进程暂停与 fencing token)
- Antirez: Is Redlock safe?(对 Kleppmann 批评的回应)
- etcd 官方文档:API Guarantees(线性一致读、revision 与事务语义)
- etcd 客户端文档:concurrency 包(lease、Mutex 与排队等待)
- PostgreSQL 官方文档:Explicit Locking(行级锁、advisory lock 与锁等待)
- MySQL 官方文档:Locking Reads(
FOR UPDATE、SKIP LOCKED与一致性读)