第 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 分布式锁:

Redisetcd
一致性单机(或 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)
}

这一章的最终落脚点:

分布式锁是”单机锁”在分布式环境下的”变形”,变形的原因是”时间、死亡、网络都不可靠了”。

所以分布式锁必须:

  1. 用租约模拟”自动释放”(解决”持锁者死了”)
  2. 用续约防止”误过期”(解决”持锁者还活着”)
  3. 用 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 同步,或接受"概率正确"

这道题考察什么:

  1. 知不知道 SET NX EX 的基本用法
  2. 知不知道”误删别人的锁”这个经典 bug
  3. 知不知道 Lua 脚本原子释放
  4. 知不知道”过期时间”和”续期”的必要性

思考题 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 共识)更接近"绝对正确",
   代价是性能。

这道题考察什么:

  1. 知不知道单机锁和分布式锁的”正确性”本质不同
  2. 知不知道 GC 停顿、时钟漂移、网络分区如何破坏锁
  3. 知不知道”概率正确”这个关键认知
  4. 知不知道 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)
  - 跨服务协调(多个服务要"独占"某个资源)
  - 无状态的协调(没有数据库,或数据库不适合存锁)

而"防重复下单"、"扣库存"这类"数据层"能解决的,
  用数据库原子操作/幂等,比分布式锁更好。

这道题考察什么:

  1. 知不知道”防重复”本质是”幂等”问题
  2. 知不知道”数据层”比”协调层”更可靠
  3. 会不会用”唯一约束”实现幂等
  4. 能不能判断”什么时候才该用分布式锁”

思考题 4(开放题)

“租约(lease)“这个思想,除了分布式锁,还用在哪些地方?请举出 3 个以上的例子,并总结”租约”这个模式的本质和适用条件。

参考答案

开放题,考察你能不能把”租约”抽象成通用模式。

租约在分布式系统的应用(举例):

例子一:服务发现(服务注册 + 心跳)。

Consul / etcd / ZooKeeper 的服务发现:
  服务启动 → 注册(带租约/TTL)→ 定期心跳续约
  服务挂了 → 不续约 → 注册自动过期 → 从服务列表摘除

本质:用"续约"证明"我还活着",用"过期"自动清理"死了的服务"。

例子二:主节点选举(leader lease)。

分布式系统选主:
  主节点持"租约"(lease),定期续约
  主节点挂了 → 租约过期 → 其他节点可以竞选新主

本质:主节点的"任期"是一个租约,续约失败则退位。
(Raft 的 leader 选举,也有类似的心跳机制,第 18 章)

例子三:分布式缓存的一致性(TTL)。

缓存(第 9 章):
  数据设 TTL(租约),到期自动失效
  更新数据 → 主动失效(撤销租约)

本质:缓存的"有效期"是一个租约,靠"过期"保证最终一致。

例子四:会话管理(session)。

Web 会话(session):
  用户登录 → 创建 session(带 TTL)→ 每次操作续期
  用户长时间不操作 → session 过期 → 重新登录

本质:session 的"有效期"是租约,靠"过期"自动登出。

例子五:分布式任务调度(锁的续期)。

定时任务防重复执行:
  任务开始 → 拿锁(带 TTL)→ 执行中定期续期
  任务卡死 → 不续期 → 锁过期 → 其他节点接管

本质:任务的"执行权"是租约,靠"续约"证明任务还活着。

“租约”模式的本质:

租约 = “有时限的资源使用权” + “续约证明存活” + “过期自动回收”。

三个要素:

  1. 时限(lease time):资源的使用权有”有效期”
  2. 续约(renew):持有者定期”续期”来证明”我还活着、我还要用”
  3. 过期回收(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 与多数派。       │
└──────────────────────────────────────────────────────────────┘