第 17 章 锁的分布式变体:租约、栅栏与 fencing
前面十六章,我们都在”单机”的范围内讨论锁、并发、一致性。这一章开始,我们跨出单机,进入”分布式”的世界。
第一件事,就是把第 5 章讲的”锁”,搬到分布式环境。然后你会发现:单机锁的一切假设,在分布式环境下全都失效了。
其中最大的敌人,是一个你在单机里从没担心过的东西:时间。
引子:单机锁在分布式下,第一个”水土不服”的地方
在单机里,锁是这样工作的:
var mu sync.Mutex
func criticalSection() {
mu.Lock() // 拿锁
defer mu.Unlock()
doSomething() // 临界区
}它有一个隐含的假设:持锁的进程是”可靠”的。
- 进程活着 → 锁被正确持有、正确释放
- 进程死了 → 锁自动释放(因为锁在内存里,进程没了锁也没了)
但在分布式环境里,这个假设碎了:
问题一:持锁者"死"了,但锁没释放。
用 Redis 做分布式锁:
进程 A 拿到锁(SET key value NX)
然后 A 挂了(崩溃、被 kill、网络分区)
→ 锁(Redis 里的 key)还在!没人释放!
→ 其他进程永远拿不到锁 → 死锁
在单机里,进程死 = 锁自动释放(内存没了)。
在分布式里,进程死 ≠ 锁释放(锁在独立的 Redis 里)。
问题二:持锁者"以为"自己还持有锁,其实已经"过期"了。
进程 A 拿到锁,开始干活
但 A 突然"卡住"了(GC 停顿、CPU 抢占、网络延迟)
→ 锁"过期"了(TTL 到了),Redis 把锁给了进程 B
→ A 恢复,以为自己还持有锁,继续干活
→ A 和 B 同时认为"我持有锁" → 两个进程同时进临界区!
这两个问题,是单机锁”想都没想过”的。而它们都指向一个词:时间。
- 问题一是”锁不会自动释放”(需要租约/过期)
- 问题二是”锁过期了但持锁者不知道”(需要 fencing token)
这一章,我们逐个拆解分布式锁的这些”水土不服”,以及它的解药。
给你一句总纲:
分布式锁的核心难题,是”持锁者死了/卡了,锁怎么办”。
单机锁靠”进程死 = 锁释放”自动解决,分布式锁没有这个”自动”,只能用租约(lease)来模拟”自动释放”,用fencing token来防止”过期后继续用”。
而这一切的根源,是分布式环境下**“时间”和”死亡”都变得不可靠了**。
本章路线图
第一幕:分布式锁的难题
Q1 单机锁搬到分布式,哪些假设失效了?
Q2 为什么分布式锁需要"租约"(lease),而不是"永久持有"?
第二幕:fencing —— 防止"过期后继续用"
Q3 fencing token 是什么?为什么它是必需的?
Q4 GC 停顿和时钟漂移,是怎么让锁"失效"的?
第三幕:Redlock 之争
Q5 Redlock 到底在争什么?Martin Kleppmann 的批评是什么?
第四幕:工程实战
Q6 什么时候你不该用分布式锁?(乐观锁 CAS 的替代)
Q7 etcd 的 lease 机制是怎么做分布式锁的?
Q8 设计一个分布式锁,你要回答哪几个问题?
第一幕:分布式锁的难题
Q1 单机锁搬到分布式,哪些假设失效了?
单机锁(sync.Mutex)隐含的假设,在分布式下逐一失效:
假设一:锁和持锁者”同生共死”。
单机:锁在进程内存里,进程死 = 锁自动释放
分布式:锁在独立的存储(Redis/etcd),进程死 ≠ 锁释放
→ 需要"租约"(过期自动释放)
假设二:持锁者是”可靠”的(不会突然卡住)。
单机:持锁的线程,除非被抢占,否则持续运行
分布式:持锁的进程可能 GC 停顿、网络分区、被 kill
→ 持锁者可能"卡住"比锁的过期时间还久
→ 需要"fencing token"防止过期后继续用
假设三:时间是一致的。
单机:所有线程共享同一个时钟(CPU 时钟)
分布式:每个节点有自己的时钟,可能漂移
→ "锁什么时候过期"在不同节点看来不一样
→ 时钟漂移导致"锁提前过期"或"锁迟迟不过期"
假设四:锁的获取和释放是”同步”的。
单机:Lock() 和 Unlock() 是同步的(进程内)
分布式:锁的获取(SET NX)和释放(DEL)是网络操作
→ 网络延迟、消息丢失,让"获取"和"释放"都可能失败
一张表总结:
| 假设 | 单机 | 分布式 | 分布式的问题 |
|---|---|---|---|
| 锁的生命周期 | 进程死=锁释放 | 进程死≠锁释放 | 需要租约 |
| 持锁者可靠性 | 线程持续运行 | 可能 GC 停顿/分区 | 需要 fencing |
| 时间一致性 | 共享时钟 | 时钟漂移 | 过期时间不确定 |
| 同步性 | 进程内同步 | 网络异步 | 获取/释放可能失败 |
核心:分布式锁的复杂性,几乎全部来自”分布式环境下,时间、死亡、网络都不可靠”。
单机锁之所以简单,是因为它享受了”进程内”的三个便利:锁和进程同生共死、时间一致、操作同步。 分布式锁失去这三个便利,就必须用”租约、fencing、容错”来弥补。
Q2 为什么分布式锁需要”租约”(lease),而不是”永久持有”?
问题:持锁者死了,锁怎么办?
单机:进程死 = 锁自动释放(内存没了)
分布式:锁在 Redis 里,进程死了,锁还在 → 死锁
解法:租约(lease)——锁带一个"过期时间"(TTL):
SET lock_key my_id NX EX 30 # 30 秒过期
进程 A 拿到锁,30 秒内要"续期"(renew),
否则锁自动过期,别人可以拿。
A 死了 → 不续期 → 30 秒后锁过期 → 别人能拿
→ 避免了"永久死锁"
租约的本质:
用”自动过期”模拟”自动释放”。
单机锁靠”进程死 = 锁释放”这个硬保证, 分布式锁没有这个保证,所以用”过期”来模拟: 持锁者必须”定期续期”来证明”我还活着”, 不续期就视为”死了”,锁自动释放。
租约的三个关键设计:
1. 过期时间(TTL)设多长?
太长:持锁者死了,锁迟迟不释放(别人等太久)
太短:持锁者还活着,但续期没跟上,锁被"误释放"
经验:TTL = 正常操作时间的 2-3 倍,
且必须"自动续期"(后台线程定期 renew)
2. 续期(renew)怎么做?
持锁者后台定期续期(比如每 TTL/3 续一次)
续期失败(网络抖动)→ 要"放弃"锁(不能继续用)
3. 释放(unlock)怎么做?
释放时要"验证是自己"(防止误删别人的锁):
- 用 Lua 脚本原子地"检查 value == my_id 再删除"
- 否则可能删掉别人的锁(A 过期了,B 拿到,A 恢复后删了 B 的锁)
租约的经典应用(不只是锁):
1. 分布式锁:租约 = 锁的过期时间
2. etcd 的 lease:给 key 绑定租约,租约到期 key 自动删除
3. 服务发现:服务注册带租约,心跳续期,心跳停了自动摘除
4. 主节点选举:主节点持租约,续期失败则退位
租约的思想:用”定期续约”来证明”我还活着”,用”不续约 = 死亡”来自动清理。
这个思想在分布式系统里无处不在(服务发现、锁、选举、会话管理), 本质都是处理同一个问题:在分布式环境下,“死亡”是不可靠的,只能用”心跳/续约”来探测。
第二幕:fencing —— 防止”过期后继续用”
Q3 fencing token 是什么?为什么它是必需的?
问题:锁过期了,但持锁者”不知道”,还在继续用锁。
场景:
进程 A 拿到锁(TTL 30 秒),开始写数据库
A 突然"卡住"(GC 停顿 40 秒)
→ A 的锁过期了(30 秒到了),Redis 把锁给了进程 B
→ B 拿到锁,也开始写数据库
→ A 恢复(40 秒后),以为自己还持有锁,继续写数据库
→ A 和 B 同时写数据库!数据损坏!
fencing token(栅栏令牌)的解法:
思路:每次"获取锁",都得到一个"递增的序号"(token)。
操作共享资源时,要带上这个 token,
共享资源只接受"最新的 token"。
具体:
1. A 拿到锁,得到 token = 1
2. A 卡住,锁过期
3. B 拿到锁,得到 token = 2
4. A 恢复,带 token=1 写数据库
5. 数据库检查:token=1 < 最新 token=2 → 拒绝!
6. B 带 token=2 写数据库 → 接受
→ 即使 A "以为"自己还持有锁,它的 token 已经"过时"了,
共享资源会拒绝它 → 防止了"A 和 B 同时写"
fencing token 的本质:
给锁加一个”单调递增的版本号”,让”过期的持锁者”无法操作共享资源。
锁过期了,但持锁者不知道,这是”无法避免”的(GC 停顿、网络分区)。 但可以通过 fencing token,让”过期的持锁者”即使”继续操作”,也被共享资源”拒绝”。
关键:光有”锁”不够,还必须有”fencing”来兜底——锁保证”同时只有一个持锁者”,fencing 保证”过期的持锁者不能捣乱”。
fencing token 的实现:
1. 锁服务(Redis/etcd)在"发锁"时,返回一个递增的 token
- Redis:用 INCR 生成递增 ID
- etcd:用 revision(全局递增版本号)
2. 共享资源(数据库、存储)记录"当前最新的 token"
3. 持锁者操作共享资源时,带上自己的 token
- 共享资源拒绝"token < 最新 token"的操作
例(etcd):
锁的 key 的 revision,就是天然的 fencing token
(revision 是 etcd 全局递增的版本号)
fencing token 的启示(很重要):
在分布式系统里,“锁”本身不够,还需要”操作的版本号”来防止”过期者捣乱”。
这个思想,和数据库的”乐观锁(version 字段)“、第 14 章的”幂等键”是同一个: 给操作加一个”版本/序号”,让”过时的操作”被拒绝。
而 fencing token 是”分布式锁”这个场景的”版本号”。
Q4 GC 停顿和时钟漂移,是怎么让锁”失效”的?
GC 停顿(GC pause):
Java/Go 的 GC 会"停顿"应用(STW,stop-the-world)。
场景:
A 拿到锁(TTL 30 秒)
A 的 GC 停顿了 40 秒(大堆、频繁 GC)
→ A 的锁过期了(30 秒 < 40 秒停顿)
→ B 拿到锁
→ A 恢复,继续用锁 → 和 B 冲突
问题:A 的"停顿时间"超过了锁的"过期时间",
导致"A 在不知情的情况下,锁被别人抢走了"。
时钟漂移(clock drift):
分布式节点的时钟,会"漂移"(不同节点的时钟不一致)。
场景:
节点 A 的时钟"快"了 5 秒(漂移)
A 拿到锁,TTL 30 秒(按 A 的时钟算,30 秒后过期)
但"真实的" 30 秒,A 的时钟已经过了 35 秒
→ A 的锁"提前过期"了(按真实时间算)
问题:时钟漂移让"锁的过期时间"在不同节点看来不一样,
导致"锁提前过期"或"锁迟迟不过期"。
为什么这两个问题”无法根本解决”?
GC 停顿:
你无法保证"GC 停顿 < 锁过期时间"(GC 停顿不可控)
→ 只能"尽量"(调大 TTL、优化 GC),但无法"保证"
时钟漂移:
你无法保证"所有节点时钟一致"(NTP 只能减小漂移,不能消除)
→ 只能"尽量"(用 NTP 同步),但无法"保证"
所以:分布式锁的"正确性",永远是"概率性的"(尽最大努力),
而不是"绝对的"(单机锁是绝对的)。
这个”无法根本解决”,是分布式锁的”宿命”:
在分布式环境里,你无法获得”单机锁那种绝对的正确性”。
因为”时间”和”死亡”都不可靠了:
- GC 停顿让”持锁者”不可靠
- 时钟漂移让”过期时间”不可靠
所以分布式锁的正确性,建立在”概率”上(GC 停顿大概率小于 TTL、时钟大概率同步), 而不是”绝对”上。
这正是 Martin Kleppmann 批评 Redlock 的核心(下一问)。
第三幕:Redlock 之争
Q5 Redlock 到底在争什么?Martin Kleppmann 的批评是什么?
这是分布式系统领域一个著名的争论,理解它能帮你深入理解”分布式锁的本质”。
背景:Redlock 是什么?
Redlock 是 Redis 官方提出的"更可靠的分布式锁算法":
不是单机 Redis 加锁,而是"N 个 Redis 实例"(比如 5 个),
加锁时,要向大多数(3 个)实例成功加锁,才算成功。
目的:避免"单机 Redis 挂了,锁就没了"的单点问题。
Martin Kleppmann(《数据密集型应用系统设计》作者)的批评:
他的核心论点:Redlock 基于”时间”(TTL),而分布式环境下”时间不可靠”(GC 停顿、时钟漂移),所以 Redlock 无法保证”互斥”这个锁的根本性质。
Kleppmann 的论证:
1. 锁的根本性质是"互斥"(同时只有一个持锁者)
2. Redlock 靠 TTL 保证"锁过期后别人能拿",
但 TTL 依赖"时间":
- GC 停顿:持锁者停顿 > TTL,锁过期了,但持锁者不知道
- 时钟漂移:不同节点时钟不一致,TTL 计算错误
3. 这些"时间问题"无法用 Redlock 解决(Redlock 也依赖 TTL)
4. 结论:Redlock 无法保证"互斥",所以它不是"正确的锁"
举例(GC 停顿):
客户端 A 拿到 Redlock 锁(TTL 30s)
A 的 GC 停顿 40s
→ 锁过期,客户端 B 拿到锁
→ A 恢复,继续操作 → A 和 B 同时操作 → 互斥被破坏
→ Redlock 无法防止这个(它不知道 A 停顿了)
Kleppmann 的替代建议:用 fencing token。
与其纠结"锁",不如用 fencing token(Q3):
锁服务发锁时,附带递增的 token,
共享资源拒绝"旧 token"的操作。
这样即使"过期者继续操作",也会被共享资源拒绝。
(但 Kleppmann 也说 fencing 依赖"共享资源支持 token 检查",不是所有资源都支持)
Redlock 作者(Antirez/Salvatore)的回应:
Antirez 的论点:
1. "时钟漂移"可以用 NTP 控制在一个小范围
2. "GC 停顿"对大多数应用来说,概率极低
3. 分布式锁本来就是"工程上的折中",不是"理论上绝对正确"
(这场争论的本质:理论正确性 vs 工程实用性)
Redlock 之争的启示(比结论重要):
1. “分布式锁”和”单机锁”的正确性,本质不同。
单机锁:绝对正确(硬件保证互斥) 分布式锁:概率正确(依赖时间、网络,可能出错)
2. 理解”你的锁依赖什么假设”:
任何分布式锁,都隐含假设(TTL 够长、时钟同步、GC 停顿短)。 假设不成立,锁就”失效”。 所以用分布式锁,要清楚”它在什么情况下会失效”。
3. fencing token 是”锁”的兜底:
锁保证”尽量互斥”,fencing 保证”过期者不能捣乱”。 两者配合,才接近”可靠”。
4. 实用主义:
大多数业务场景(秒杀、防止重复下单), “概率正确”的分布式锁 + fencing + 幂等,已经足够。 真正需要”绝对正确”的场景(金融转账), 往往不用分布式锁,而用数据库事务 + 乐观锁。
第四幕:工程实战
Q6 什么时候你不该用分布式锁?(乐观锁 CAS 的替代)
分布式锁不是银弹,很多场景它反而”用错了”。
不该用分布式锁的场景一:可以用数据库乐观锁。
场景:更新一条记录(比如扣库存)
用分布式锁:
1. 拿分布式锁(Redis)
2. 读库存
3. 扣库存
4. 释放锁
→ 复杂,且锁有"过期"风险
用数据库乐观锁(CAS):
UPDATE stock SET count = count - 1
WHERE id = 123 AND count > 0
→ 一条 SQL,数据库原子地"检查 + 扣减"
→ 简单,且数据库事务保证原子性
关键:如果共享资源本身是"数据库",用数据库的原子操作
(乐观锁、条件更新),比"分布式锁"更简单可靠。
不该用分布式锁的场景二:共享资源支持”幂等”。
场景:防止重复下单
用分布式锁:
拿锁 → 检查 → 下单 → 释放锁
用幂等键(第 14 章):
下单接口带"订单号"(幂等键)
数据库唯一约束保证"同一个订单号只插入一次"
→ 重复请求,第二次插入失败 → 幂等
关键:如果能用"幂等"解决,就不需要"锁"。
幂等是"数据层"的保证,锁是"协调层"的保证,
数据层的保证更可靠。
不该用分布式锁的场景三:锁的代价大于收益。
场景:低频、低并发的操作
用分布式锁:
Redis 加锁、续期、释放,一堆代码 + Redis 依赖
直接操作:
低频操作,冲突概率极低,
加锁的复杂度 > 冲突的代价
关键:先评估"冲突的概率"和"冲突的代价",
别为了"万一冲突"引入"必然的复杂度"。
什么时候才该用分布式锁?
1. 共享资源是"外部"的,无法用数据库原子操作
(比如操作文件、操作第三方 API)
2. 需要"跨进程、跨服务"的互斥
(多个服务实例,都要"独占"某个资源)
3. 冲突概率高,且必须互斥
(不互斥会出严重问题)
4. 且能接受"概率正确"(不是金融级绝对正确)
核心判断:分布式锁是”协调层的互斥”,而很多场景可以用”数据层的原子性/幂等”来替代。
数据层的保证(数据库事务、唯一约束、条件更新)比协调层的锁更可靠, 因为数据层是”绝对正确”的(事务保证),而分布式锁是”概率正确”的。
所以:能用数据库原子操作,就别用分布式锁;能用幂等,就别用锁。
Q7 etcd 的 lease 机制是怎么做分布式锁的?
etcd 是分布式锁的”标准实现”,理解它的机制,能帮你理解”正确的分布式锁”长什么样。
etcd 分布式锁的核心:lease(租约)+ revision(版本号)。
1. 创建租约(lease):
lease = grant(TTL=30) # 30 秒租约
2. 用租约加锁:
put(key, value, lease) # 把锁的 key 绑定到租约
# 如果 key 不存在,put 成功 = 拿到锁
# 如果 key 已存在,put 失败 = 别人持锁
3. 续约(keepalive):
后台 goroutine 定期续约(keepalive)
# 保证租约不过期(我还活着)
4. 释放锁:
delete(key)
# 或者撤销租约(revoke lease)
5. 租约过期(持锁者死了):
不续约 → 租约过期 → key 自动删除 → 锁自动释放
etcd 锁的精妙之处:
1. 自动释放:租约过期,key 自动删除(不用手动释放)
→ 解决了"持锁者死了锁不释放"的问题
2. 续约机制:keepalive 自动续期
→ 解决了"锁提前过期"的问题
3. revision(fencing token):etcd 的全局递增版本号
→ 每次加锁,key 的 revision 递增
→ 可以用作 fencing token
4. 一致性:etcd 是 Raft 共识(第 18 章),
加锁操作是"线性一致"的(所有节点一致)
→ 不会出现"两个客户端同时拿到锁"
etcd 锁的完整实现(Go 简化版):
import (
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
func acquireLock(cli *clientv3.Client, key string) (*concurrency.Mutex, error) {
// 1. 创建会话(自动续约)
session, err := concurrency.NewSession(cli,
concurrency.WithTTL(30)) // 30 秒租约
// 2. 创建锁
mu := concurrency.NewMutex(session, key)
// 3. 加锁(阻塞直到拿到锁)
if err := mu.Lock(context.Background()); err != nil {
return nil, err
}
// 4. 释放锁(或 session.Close 撤销租约)
// defer mu.Unlock()
// defer session.Close()
return mu, nil
}etcd vs Redis 分布式锁:
| Redis | etcd | |
|---|---|---|
| 一致性 | 单机(或 Redlock 多数派) | Raft 共识(强一致) |
| 自动释放 | TTL 过期 | 租约过期 |
| 续约 | 手动(或 Redisson 自动) | keepalive 自动 |
| fencing | 需自己实现 | revision 天然支持 |
| 性能 | 高(内存) | 中(共识开销) |
| 正确性 | 概率正确 | 更接近绝对正确 |
etcd 分布式锁的启示:
“正确的”分布式锁,需要三个东西:租约(自动释放)、续约(防止误过期)、fencing(防止过期者捣乱)。
etcd 用”lease + keepalive + revision”把这三样都提供了, 所以它是”更可靠”的分布式锁(代价是性能不如 Redis)。
而 Redis 锁需要”自己补”这三样(手动续期、自己实现 fencing), 所以它”更快但更不可靠”。
Q8 设计一个分布式锁,你要回答哪几个问题?
这一章的落地总结,设计分布式锁的”检查清单”。
问题一:锁的”过期时间”怎么处理?
必须有过期(租约),否则持锁者死了 = 死锁
过期时间 = 正常操作时间 × 2-3
必须"自动续期"(防止误过期)
续期失败 → 主动放弃锁(不能继续用)
问题二:锁的”释放”怎么保证正确?
释放时要"验证是自己"(防止误删别人的锁)
- Redis:Lua 脚本原子检查 value == my_id 再删除
- etcd:delete 自己的 key
释放失败的兜底:靠租约过期自动释放
问题三:要不要 fencing token?
如果"过期后继续用"会导致严重问题(写坏数据),
必须用 fencing token:
- 锁服务返回递增 token
- 共享资源拒绝旧 token
如果"过期后继续用"的后果可接受(比如只是重复读),
可以不用(简化)。
问题四:用 Redis 还是 etcd?
Redis:快,但正确性依赖"时间"(概率正确)
etcd:慢一点,但更可靠(共识 + revision)
选择:
- 性能优先、能容忍"概率正确" → Redis
- 正确性优先 → etcd
问题五:真的需要分布式锁吗?
先问:
- 能不能用数据库原子操作(条件更新、事务)替代?
- 能不能用幂等键替代?
- 冲突概率高吗?冲突代价大吗?
能用替代方案,就别用分布式锁。
一个完整的”可靠分布式锁”设计(Redis 版,带 fencing + 续期):
// 伪代码:可靠的 Redis 分布式锁
type Lock struct {
key string
token int64 // fencing token
ttl time.Duration
}
func Acquire(client *redis.Client, key string, ttl time.Duration) (*Lock, error) {
// 1. 生成递增 token(fencing)
token, _ := client.Incr(key + ":token").Result()
// 2. 加锁(SET NX,value = token)
ok, _ := client.SetNX(key, token, ttl).Result()
if !ok {
return nil, errors.New("lock held by others")
}
lock := &Lock{key: key, token: token, ttl: ttl}
// 3. 启动自动续期(后台 goroutine)
go lock.renew(client)
return lock, nil
}
func (l *Lock) renew(client *redis.Client) {
ticker := time.NewTicker(l.ttl / 3)
for range ticker.C {
// Lua 脚本:验证 token 是自己,才续期
script := `if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else return 0 end`
ok, _ := client.Eval(script, []string{l.key}, l.token, int(l.ttl.Seconds())).Int()
if ok == 0 {
// 锁已经不是我的了(过期了),停止续期
return
}
}
}
func (l *Lock) Release(client *redis.Client) {
// Lua 脚本:验证 token 是自己,才删除
script := `if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else return 0 end`
client.Eval(script, []string{l.key}, l.token)
}这一章的最终落脚点:
分布式锁是”单机锁”在分布式环境下的”变形”,变形的原因是”时间、死亡、网络都不可靠了”。
所以分布式锁必须:
- 用租约模拟”自动释放”(解决”持锁者死了”)
- 用续约防止”误过期”(解决”持锁者还活着”)
- 用 fencing 防止”过期者捣乱”(解决”过期了不知道”)
而即使这样,分布式锁也只能”概率正确”,不是”绝对正确”。
所以最高的境界是:尽量不用分布式锁,用数据层的原子性/幂等来替代。
思考题
思考题 1
面试官问:“用 Redis 实现一个分布式锁,你会怎么做?有哪些坑?“请回答,并说明”误删别人的锁”这个经典 bug 怎么解决。
参考答案
核心答案:SET NX EX 加锁 + 唯一 value + Lua 脚本原子释放。坑包括:不设过期时间(死锁)、误删别人的锁、锁过期后继续用(fencing)。
基本实现:
# 加锁:SET key value NX EX 30
# NX:key 不存在才设置(互斥)
# EX 30:30 秒过期(防止死锁)
# value:一个唯一 ID(标识"我是谁")
# 释放:用 Lua 脚本原子地"验证 value 再删除"为什么 value 要唯一?
如果 value 不唯一(比如都设 1),
释放时无法区分"锁是谁的",可能误删别人的锁。
“误删别人的锁”这个经典 bug:
场景:
1. A 拿到锁(value = "A",TTL 30s)
2. A 卡住(GC 停顿 40s)
3. A 的锁过期(30s 到了),Redis 删了锁
4. B 拿到锁(value = "B")
5. A 恢复,执行释放:DEL key
→ 删掉的是 B 的锁!(A 误删了 B 的锁)
6. C 拿到锁 → B 和 C 同时持锁 → 互斥被破坏
问题:释放时没验证"锁是不是自己的"
解决:Lua 脚本原子验证再删除。
-- 释放锁的 Lua 脚本
-- 只有当 value 匹配(锁是我的)才删除
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end为什么用 Lua 脚本(而不是 get + del)?
get + del 是两个操作,中间可能被插队:
1. A get 到 value == "A"(锁是我的)
2. 此时 A 的锁过期,B 拿到锁
3. A del key → 删掉了 B 的锁
Lua 脚本是"原子"的(get + del 在一个原子操作里),
避免了"get 和 del 之间被插队"。
其他坑:
坑一:不设过期时间 → 持锁者死了,锁永久不释放(死锁)
解法:必须 SET EX(带过期)
坑二:锁过期了,但业务没做完 → 锁被抢,业务错乱
解法:自动续期(Redisson 的 watch dog),
或 fencing token
坑三:主从切换,锁丢失:
Redis 主从,主挂了,从提升为主,但锁(在主上的)丢了
解法:Redlock(多实例),或接受"概率正确"
坑四:时钟漂移 → 过期时间不准
解法:NTP 同步,或接受"概率正确"
这道题考察什么:
- 知不知道 SET NX EX 的基本用法
- 知不知道”误删别人的锁”这个经典 bug
- 知不知道 Lua 脚本原子释放
- 知不知道”过期时间”和”续期”的必要性
思考题 2
“分布式锁的正确性,为什么无法达到单机锁的水平?“请从”GC 停顿、时钟漂移、网络分区”三个角度说明,并解释这意味着”用分布式锁时,你应该注意什么”。
参考答案
核心答案:单机锁的互斥由”硬件/内核”保证(绝对正确),分布式锁的互斥依赖”时间、时钟、网络”(这些在分布式下不可靠),所以只能”概率正确”。
单机锁为什么”绝对正确”:
sync.Mutex 的互斥,由硬件原子指令(CAS)+ 内核 futex 保证:
- 原子指令:硬件保证"读-改-写"不可分割
- futex:内核调度保证"同时只有一个线程持锁"
这些是"硬保证":只要硬件和内核正常,互斥就是绝对的。
而且进程死 = 锁自动释放(内存没了),没有"持锁者死了锁还在"的问题。
分布式锁的三个”不可靠”:
GC 停顿:
分布式锁依赖 TTL(过期时间),TTL 依赖"时间"。
但持锁者的 GC 停顿,可能超过 TTL:
A 拿到锁(TTL 30s)
A 的 GC 停顿 40s
→ 锁过期,B 拿到锁
→ A 恢复,继续用锁 → A 和 B 同时持锁 → 互斥被破坏
你无法保证"GC 停顿 < TTL"(GC 停顿不可控),
所以无法保证"持锁者不会在锁过期后继续用锁"。
时钟漂移:
分布式节点的时钟会漂移(不同节点的时钟不一致)。
A 节点时钟快了 5s
A 拿到锁,TTL 30s(按 A 的时钟,30s 后过期)
但"真实时间"的 30s,A 的时钟已经过了 35s
→ A 的锁"提前过期"(按真实时间)
你无法保证"所有节点时钟一致"(NTP 只能减小,不能消除漂移),
所以无法保证"过期时间的计算准确"。
网络分区:
分布式锁的"加锁/释放"是网络操作,网络可能分区:
A 和锁服务(Redis)之间网络分区
A 以为自己拿到锁了(实际 SET 请求可能没到 Redis)
或者 A 释放锁的请求丢失了
你无法保证"网络操作一定成功",
所以无法保证"加锁/释放的可靠性"。
这意味着”用分布式锁时,你应该注意什么”:
1. 接受"概率正确":
分布式锁不是"绝对互斥",是"大概率互斥"。
所以别把它用在"绝对正确"的场景(金融转账),
而用在"大概率够用"的场景(防重复下单、限流)。
2. 配 fencing token:
锁过期了持锁者不知道,这是"无法避免"的,
但用 fencing token 让"过期者"被共享资源拒绝,
把"互斥被破坏"的后果降到最低。
3. 配幂等:
即使"互斥被破坏"(两个持锁者同时操作),
如果操作幂等,后果也可控。
4. 用更强一致性的锁(etcd):
Redis 锁是"概率正确",etcd 锁(Raft 共识)更接近"绝对正确",
代价是性能。
这道题考察什么:
- 知不知道单机锁和分布式锁的”正确性”本质不同
- 知不知道 GC 停顿、时钟漂移、网络分区如何破坏锁
- 知不知道”概率正确”这个关键认知
- 知不知道 fencing + 幂等这些”兜底”手段
思考题 3
你的服务要”防止用户重复点击下单按钮”(同一个订单被提交两次)。你有三种方案:A)Redis 分布式锁;B)数据库唯一约束(订单号唯一);C)幂等接口(幂等键)。请分析三者的优劣,并给出推荐。
参考答案
核心结论:推荐 B(数据库唯一约束)或 C(幂等键),而不是 A(分布式锁)。因为”防止重复下单”本质是”幂等”问题,用”数据层的原子性”比”协调层的锁”更可靠。
方案 A:Redis 分布式锁。
// 用分布式锁防止重复下单
func placeOrder(userID int) error {
lockKey := fmt.Sprintf("order_lock:%d", userID)
ok := redis.SetNX(lockKey, "1", 10*time.Second).Result()
if !ok {
return errors.New("请求处理中,请勿重复提交")
}
defer redis.Del(lockKey)
// 下单逻辑
return doPlaceOrder(userID)
}优点:能防止"同一用户并发重复下单"
缺点:
1. 依赖 Redis(引入一个外部依赖)
2. 锁有"过期"问题(GC 停顿、时钟漂移导致锁失效)
3. 锁的"释放"可能误删(第 Q1 的坑)
4. 复杂度高(续期、fencing、Lua 释放)
5. 锁只能防"并发重复",不能防"顺序重复"(用户 10 秒后再次点击)
方案 B:数据库唯一约束。
-- 订单表,订单号唯一
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64) UNIQUE, -- 唯一约束!
user_id BIGINT,
...
);// 下单时,用唯一约束防重复
func placeOrder(orderNo string, userID int) error {
err := db.Insert(&Order{
OrderNo: orderNo,
UserID: userID,
})
if isDuplicateKey(err) {
return errors.New("订单已存在") // 重复提交
}
return nil
}优点:
1. 数据库唯一约束是"绝对可靠"的(事务保证)
2. 简单(一个 UNIQUE 约束 + 处理重复插入)
3. 天然幂等(同一个订单号,第二次插入失败)
4. 不用额外依赖(没有 Redis)
缺点:
1. 需要客户端"生成唯一的订单号"(幂等键)
2. 唯一约束只防"同一个订单号",不防"不同订单号的重复下单"
(用户可能生成两个不同的订单号,下两单——但那不是"重复点击"了)
方案 C:幂等接口(幂等键)。
// 接口带幂等键,服务端用唯一约束保证幂等
type PlaceOrderReq struct {
IdempotencyKey string // 幂等键(客户端生成,UUID)
UserID int64
...
}
func PlaceOrder(ctx context.Context, req PlaceOrderReq) error {
// 1. 用幂等键查是否处理过
if order := db.GetByIdempotencyKey(req.IdempotencyKey); order != nil {
return nil // 处理过,幂等返回
}
// 2. 插入(幂等键唯一约束)
err := db.Insert(&Order{
IdempotencyKey: req.IdempotencyKey, // 唯一约束
UserID: req.UserID,
})
if isDuplicateKey(err) {
return nil // 并发重复,幂等返回
}
// 3. 下单逻辑
return nil
}本质:方案 B 的"幂等键"版本,更通用(幂等键不限于订单号)
优点:同方案 B,且更通用(可以用于任何"要防重复"的操作)
缺点:客户端要生成幂等键
三者的本质区别:
分布式锁(A):协调层的互斥("我先来,你等着")
唯一约束(B):数据层的原子性("同一个键,只插入一次")
幂等键(C):数据层的幂等("同一个键,重复请求返回相同结果")
关键:防"重复下单",本质是"幂等"问题,
而"幂等"用"数据层"(唯一约束、幂等键)解决,比"协调层"(锁)更可靠。
我的推荐:
首选:方案 C(幂等键 + 唯一约束)
理由:
1. 数据层保证,绝对可靠
2. 简单(唯一约束 + 处理重复插入)
3. 天然幂等(重试安全,第 14 章)
4. 不引入 Redis 依赖
次选:方案 B(订单号唯一约束)
理由:如果订单号本身就是天然的幂等键,直接用唯一约束
不推荐:方案 A(分布式锁)
理由:分布式锁是"概率正确",且复杂度高,
而"防重复下单"完全可以用"数据层"更可靠地解决
什么时候才用分布式锁?
分布式锁适合"无法用数据层原子性解决"的场景:
- 操作外部资源(文件、第三方 API)
- 跨服务协调(多个服务要"独占"某个资源)
- 无状态的协调(没有数据库,或数据库不适合存锁)
而"防重复下单"、"扣库存"这类"数据层"能解决的,
用数据库原子操作/幂等,比分布式锁更好。
这道题考察什么:
- 知不知道”防重复”本质是”幂等”问题
- 知不知道”数据层”比”协调层”更可靠
- 会不会用”唯一约束”实现幂等
- 能不能判断”什么时候才该用分布式锁”
思考题 4(开放题)
“租约(lease)“这个思想,除了分布式锁,还用在哪些地方?请举出 3 个以上的例子,并总结”租约”这个模式的本质和适用条件。
参考答案
开放题,考察你能不能把”租约”抽象成通用模式。
租约在分布式系统的应用(举例):
例子一:服务发现(服务注册 + 心跳)。
Consul / etcd / ZooKeeper 的服务发现:
服务启动 → 注册(带租约/TTL)→ 定期心跳续约
服务挂了 → 不续约 → 注册自动过期 → 从服务列表摘除
本质:用"续约"证明"我还活着",用"过期"自动清理"死了的服务"。
例子二:主节点选举(leader lease)。
分布式系统选主:
主节点持"租约"(lease),定期续约
主节点挂了 → 租约过期 → 其他节点可以竞选新主
本质:主节点的"任期"是一个租约,续约失败则退位。
(Raft 的 leader 选举,也有类似的心跳机制,第 18 章)
例子三:分布式缓存的一致性(TTL)。
缓存(第 9 章):
数据设 TTL(租约),到期自动失效
更新数据 → 主动失效(撤销租约)
本质:缓存的"有效期"是一个租约,靠"过期"保证最终一致。
例子四:会话管理(session)。
Web 会话(session):
用户登录 → 创建 session(带 TTL)→ 每次操作续期
用户长时间不操作 → session 过期 → 重新登录
本质:session 的"有效期"是租约,靠"过期"自动登出。
例子五:分布式任务调度(锁的续期)。
定时任务防重复执行:
任务开始 → 拿锁(带 TTL)→ 执行中定期续期
任务卡死 → 不续期 → 锁过期 → 其他节点接管
本质:任务的"执行权"是租约,靠"续约"证明任务还活着。
“租约”模式的本质:
租约 = “有时限的资源使用权” + “续约证明存活” + “过期自动回收”。
三个要素:
- 时限(lease time):资源的使用权有”有效期”
- 续约(renew):持有者定期”续期”来证明”我还活着、我还要用”
- 过期回收(expire):不续约,视为”死亡”,自动回收资源
解决的问题:在分布式环境下,“死亡”是不可靠的(进程崩了、网络断了),无法”显式地”通知”我死了”,只能用”不续约 = 死亡”来”隐式地”探测。
“租约”模式的适用条件:
1. 资源是"有限的、需要独占的"(锁、主节点、会话)
2. 持有者"可能静默死亡"(崩溃、网络分区,无法通知)
3. "过期回收"的代价可接受(资源被误回收,可以重试/重新获取)
4. "续约"的成本低(心跳很轻量)
满足这些条件,租约就是"管理'死亡探测'"的最佳方案。
一个统一的视角:
“租约”的本质,是处理”分布式死亡探测”——在无法显式确认”对方死了吗”的情况下,用”超时 + 续约”来近似判断。
这和”超时”(第 16 章)是同一个思想:
- 超时:给”等待”设上限,超时视为”失败”
- 租约:给”持有”设上限,过期视为”死亡”
都是”在不确定的环境里,用’时间’来近似’状态’”。
而”租约”的局限,也是”时间不可靠”(时钟漂移)——所以租约的”过期时间”要谨慎设置,太短误判,太长拖延。
这道题的价值:
它让你看到,“租约”是分布式系统的”基础模式”,和”超时”、“重试”、“幂等”一样,是处理”分布式不确定性”的基本工具。
理解了”租约”,你就理解了服务发现、主节点选举、会话管理、分布式锁等一系列机制的共同本质。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 17 章 · 锁的分布式变体:租约、栅栏与 fencing │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【单机锁 → 分布式锁:假设失效】 │
│ 进程死=锁释放 → 分布式:进程死≠锁释放(要租约) │
│ 持锁者可靠 → 分布式:GC 停顿/分区(要 fencing) │
│ 时间一致 → 分布式:时钟漂移(过期不准) │
│ │
│ 【租约 lease】 │
│ 用"自动过期"模拟"自动释放" │
│ 持锁者定期续约(证明活着),不续约=死亡=自动释放 │
│ 本质:分布式下的"死亡探测" │
│ │
│ 【fencing token】 │
│ 锁过期了持锁者不知道 → 无法避免 │
│ 用递增 token,让共享资源拒绝"过期的持锁者" │
│ 本质:给操作加版本号(同乐观锁、幂等键) │
│ │
│ 【Redlock 之争】 │
│ Kleppmann:基于时间的锁,无法保证互斥(GC 停顿、时钟漂移) │
│ Antirez:工程折中,概率正确已够用 │
│ 启示:分布式锁是"概率正确",不是"绝对正确" │
│ │
│ 【何时不用分布式锁】 │
│ 能用数据库原子操作/幂等,就别用锁 │
│ 数据层的保证 > 协调层的锁(更可靠) │
│ │
│ 【etcd 锁 = 更可靠的锁】 │
│ lease(自动释放)+ keepalive(续约)+ revision(fencing) │
│ 基于 Raft 共识(强一致) │
│ │
│ 【一句话】 │
│ 分布式锁的敌人是"时间"和"死亡"的不可靠; │
│ 租约解决死亡,fencing 解决时间,而最高境界是"尽量不用锁"。 │
│ │
│ 【下一章预告】 │
│ fencing 依赖"一个递增的版本号",而"版本号"的共识—— │
│ 下一章讲复制与共识:WAL 思想的远距离投射,Raft 与多数派。 │
└──────────────────────────────────────────────────────────────┘