复制与共识:谁说了算¶
上一章留了个尾巴:所有客户端必须对"现在有哪些实例"达成一致,否则路由就乱了。这类"必须全体一致"的小状态——集群成员、分片方案、谁是主、配置版本——通常交给 etcd 这样的共识系统。这一章讲清楚为什么需要共识、法定人数怎么保证读到最新值、Raft 的选举和日志复制在做什么(附一个能跑的最小模拟)、脑裂和租约是怎么回事,以及推理集群里哪些东西该放进共识系统、哪些不该。
自测:能答上来就可以跳过本章
- 主从复制里,主挂了让从顶上,最简单的做法有什么问题?
- "写 W 个副本、读 R 个副本"要满足什么条件,才能保证读到最新写入的值?
- Raft 的任期(term)是干什么的?为什么候选人要检查对方的日志是不是比自己新?
- 5 个副本被网络分成 3 台和 2 台两侧,各能做什么?为什么副本数一般取奇数?
- 推理集群里哪些状态该放进 etcd,哪些绝对不该?
自测参考答案(先自己答,再展开对照)
- 谁来判断"主挂了"、谁来指定新主?如果让每个客户端自己判断,会出现一半客户端认为 A 是主、另一半认为 B 是主,两个主同时写,数据就分叉了(脑裂)。而且旧主可能只是网络抖动,恢复后还以为自己是主。要解决这两个问题,就需要共识:让多数派对"谁是主、当前是第几任"达成一致。
- W + R > N。这样任意一个写集合和任意一个读集合必定有交集,读到的副本里至少有一个包含最新的写。最常用的是多数派(W = R = ⌊N/2⌋+1),读写都能容忍 ⌊N/2⌋ 台故障。
- 任期是一个单调递增的逻辑时钟,用来识别过期的领导者:任何人收到任期比自己大的消息就退回跟随者,旧主带着旧任期回来时会被拒绝。检查日志新旧是为了保证"已提交的日志不会丢"——只有日志至少和自己一样新的候选人才能拿到选票,于是当选者必然包含所有已提交的条目。
- 只有 3 台那一侧能选出领导者、能提交(多数派是 3),2 台那侧只能读到可能过期的数据,不能写。取奇数是因为偶数不划算:4 个副本的多数派是 3,容错还是 1 台,和 3 个副本一样,却多花一台机器,还多了一种"2 : 2 谁也不能工作"的分区情形。
- 该放的是"小而关键、变化不频繁"的元数据:实例成员列表、分片方案、路由表版本、模型版本与配置、分布式锁与选主。绝不该放的是高频数据:每次请求的状态、KV Cache 的位置、指标、队列深度——这些写入量大、时效性短,放进共识系统会把它拖垮。
为什么需要共识¶
单机系统里"谁说了算"不是问题:只有一个进程,状态就在它的内存里。一旦做成多副本,麻烦就来了:
- 判断不一致:A 认为主挂了,B 认为没挂;
- 脑裂:两个节点同时认为自己是主,各自接受写入,数据分叉;
- 消息乱序与重放:旧主在网络恢复后带着过期的指令回来。
共识算法(Paxos、Raft)解决的是同一件事:让多数派对"一系列操作的顺序"达成一致,并且保证一旦某个操作被宣布提交,它就不会被推翻。有了这个能力,"谁是主""当前的成员列表是什么""这个锁归谁"都可以表达成"对一条日志的写入"。
法定人数:读写交集¶
先看不需要完整共识的情形:多副本存储,写 W 个副本算成功,读 R 个副本取最新。
# 法定人数(quorum):写 W 个副本、读 R 个副本,只要 W + R > N 就一定能读到最新的写
from itertools import combinations
def always_fresh(n, w, r):
"""任取一个写集合和一个读集合,是否总有交集"""
return all(set(a) & set(b) for a in combinations(range(n), w) for b in combinations(range(n), r))
print("N W R 读一定最新 能容忍几台故障(写)")
for n, w, r in [(3, 2, 2), (3, 3, 1), (3, 1, 3), (3, 1, 1), (5, 3, 3), (5, 4, 2), (5, 2, 2)]:
print(f"{n} {w} {r} {'是' if always_fresh(n, w, r) else '否':6s} {n - w}")
print()
print("多数派(W = R = ⌊N/2⌋+1)在不同副本数下的容错能力:")
for n in (1, 3, 5, 7):
majority = n // 2 + 1
print(f" {n} 个副本:多数派 {majority},能容忍 {n - majority} 台故障")
print()
print("为什么用奇数个副本:加一台不一定提高容错")
for n in range(2, 8):
print(f" {n} 个副本:多数派 {n // 2 + 1},容忍 {n - (n // 2 + 1)} 台")
N W R 读一定最新 能容忍几台故障(写)
3 2 2 是 1
3 3 1 是 0
3 1 3 是 2
3 1 1 否 2
5 3 3 是 2
5 4 2 是 1
5 2 2 否 3
多数派(W = R = ⌊N/2⌋+1)在不同副本数下的容错能力:
1 个副本:多数派 1,能容忍 0 台故障
3 个副本:多数派 2,能容忍 1 台故障
5 个副本:多数派 3,能容忍 2 台故障
7 个副本:多数派 4,能容忍 3 台故障
为什么用奇数个副本:加一台不一定提高容错
2 个副本:多数派 2,容忍 0 台
3 个副本:多数派 2,容忍 1 台
4 个副本:多数派 3,容忍 1 台
5 个副本:多数派 3,容忍 2 台
6 个副本:多数派 4,容忍 2 台
7 个副本:多数派 4,容忍 3 台
- W + R > N 保证读写集合必有交集,也就保证读到最新值。W=1、R=1 最快但会读到旧值(这就是"最终一致")。
- 多数派(W = R = ⌊N/2⌋+1)是最常用的配置,读写对称,能容忍 ⌊N/2⌋ 台故障。
- 偶数个副本不划算:4 个副本的多数派是 3,容错还是 1 台,和 3 个副本一样。所以集群一般是 3 台或 5 台。
法定人数只解决"读到的值不过期",不解决"并发写入谁先谁后"。要给操作定一个全局顺序,就需要共识算法。
Raft:选举与日志复制¶
Raft 把共识拆成三块:选主、日志复制、安全性约束。
- 每个节点处于三种角色之一:跟随者、候选人、领导者。任期是一个单调递增的逻辑时钟,每次选举加一。
- 跟随者在一段随机的超时时间内没收到领导者的心跳,就把任期加一、变成候选人、向所有人拉票。拿到多数票才当选。随机超时是为了避免所有人同时发起选举、反复流选。
- 领导者接受客户端的写入,追加到自己的日志,再复制给跟随者;被多数派复制的条目就可以提交并应用到状态机。
- 两条安全性约束:投票时只投给"日志至少和自己一样新"的候选人;领导者只能提交自己任期内的条目(间接提交更早的)。这两条保证已提交的内容不会丢。
下面这个最小模拟用离散时间跑一遍:选出领导者、复制几条日志、领导者宕机、重新选举:
# Raft 选举与日志复制的最小模拟:离散时间、消息立刻送达,用来看清任期、投票和提交的规则
import random
class Node:
def __init__(self, i, n):
self.i, self.n = i, n
self.term, self.voted_for, self.role = 0, None, "follower"
self.log = [] # [(任期, 命令)]
self.commit = 0 # 已提交的日志条数
self.timeout = 0 # 还有几个时钟滴答就超时发起选举
self.match = {} # 领导者视角:每个跟随者已复制到第几条
class Cluster:
def __init__(self, n, seed=1):
self.rng = random.Random(seed)
self.nodes = [Node(i, n) for i in range(n)]
self.down = set()
for node in self.nodes:
node.timeout = self.rng.randint(3, 6) # 随机超时,避免所有人同时发起选举
def alive(self):
return [x for x in self.nodes if x.i not in self.down]
def leader(self):
return next((x for x in self.alive() if x.role == "leader"), None)
def tick(self):
lead = self.leader()
if lead: # 领导者发心跳,压住跟随者的超时
for f in self.alive():
if f is not lead:
f.role, f.term, f.timeout = "follower", lead.term, self.rng.randint(3, 6)
f.log = lead.log[:] # 简化:一次把日志补齐
lead.match[f.i] = len(f.log)
acked = sorted([len(lead.log)] + [lead.match.get(f.i, 0) for f in self.alive() if f is not lead])
lead.commit = acked[len(self.alive()) // 2] # 多数派已复制的位置就是提交点
return
for node in self.alive(): # 没有领导者:等超时的人发起选举
node.timeout -= 1
if node.timeout > 0:
continue
node.term, node.role, node.voted_for = node.term + 1, "candidate", node.i
votes = 1
for other in self.alive(): # 只投给日志不比自己旧的候选人
if other is node:
continue
up_to_date = (len(node.log), node.log[-1][0] if node.log else 0) >= \
(len(other.log), other.log[-1][0] if other.log else 0)
if other.term < node.term and up_to_date:
other.term, other.voted_for = node.term, node.i
votes += 1
if votes > self.n_total() // 2: # 拿到多数票才当选
node.role = "leader"
node.match = {}
return f"第 {node.term} 任期:节点 {node.i} 当选({votes}/{self.n_total()} 票)"
node.timeout = self.rng.randint(3, 6)
return None
def n_total(self):
return len(self.nodes)
def propose(self, cmd):
lead = self.leader()
if lead is None:
return False
lead.log.append((lead.term, cmd))
return True
c = Cluster(5)
log = []
for step in range(30):
if step == 12:
old = c.leader()
c.down.add(old.i) # 领导者宕机
log.append(f"第 {step} 拍:领导者 节点 {old.i} 宕机,已提交 {old.commit} 条")
msg = c.tick()
if msg:
log.append(f"第 {step} 拍:{msg}")
if step in (6, 8, 10, 20, 22):
c.propose(f"cmd{step}")
for line in log:
print(line)
lead = c.leader()
print(f"\n最终:领导者是节点 {lead.i}(第 {lead.term} 任期),日志 {len(lead.log)} 条,已提交 {lead.commit} 条")
print("各节点日志:", {x.i: len(x.log) for x in c.nodes})
第 2 拍:第 1 任期:节点 1 当选(5/5 票)
第 12 拍:领导者 节点 1 宕机,已提交 3 条
第 14 拍:第 2 任期:节点 0 当选(4/5 票)
最终:领导者是节点 0(第 2 任期),日志 5 条,已提交 5 条
各节点日志: {0: 5, 1: 3, 2: 5, 3: 5, 4: 5}
注意最后一行:宕机的旧领导者(节点 1)日志停在 3 条,新领导者和其他节点是 5 条。旧节点恢复后会发现自己的任期落后,退回跟随者并被新领导者覆盖掉不一致的部分——这正是"任期"和"日志新旧检查"在起作用。
(这个模拟省略了真实 Raft 的大量细节:消息可能丢失和乱序、日志要逐条比对并回退、快照与日志压缩、成员变更要用联合共识。它只用来看清角色、任期和提交规则。)
分区、脑裂与租约¶
# 网络分区时谁还能工作:只有拿到多数派的那一侧能选出领导者、能提交
def can_work(sizes, total):
return [s > total // 2 for s in sizes]
cases = [
("5 个副本,3 : 2 分区", [3, 2], 5),
("5 个副本,2 : 2 : 1 三分区", [2, 2, 1], 5),
("4 个副本,两个机房各 2 台", [2, 2], 4),
("5 个副本:机房 A 2 台、机房 B 2 台、第三地仲裁 1 台,A 与其他断开", [2, 3], 5),
("3 个副本,1 台宕机后再分区 1 : 1", [1, 1], 3),
]
for name, sizes, total in cases:
flags = can_work(sizes, total)
who = "、".join(f"{s} 台那侧{'可以' if ok else '不行'}" for s, ok in zip(sizes, flags))
print(f"{name}:{who}")
print()
print("故障切换要多久(心跳 h、选举超时随机取 [t, 2t]):")
for h, t in [(50, 150), (100, 300), (500, 1500)]:
print(f" 心跳 {h} ms、选举超时 {t}~{2 * t} ms:发现故障最多 {2 * t} ms,"
f"加上一轮投票和日志追赶,通常 {t + h}~{2 * t + 2 * h} ms 内恢复写入")
5 个副本,3 : 2 分区:3 台那侧可以、2 台那侧不行
5 个副本,2 : 2 : 1 三分区:2 台那侧不行、2 台那侧不行、1 台那侧不行
4 个副本,两个机房各 2 台:2 台那侧不行、2 台那侧不行
5 个副本:机房 A 2 台、机房 B 2 台、第三地仲裁 1 台,A 与其他断开:2 台那侧不行、3 台那侧可以
3 个副本,1 台宕机后再分区 1 : 1:1 台那侧不行、1 台那侧不行
故障切换要多久(心跳 h、选举超时随机取 [t, 2t]):
心跳 50 ms、选举超时 150~300 ms:发现故障最多 300 ms,加上一轮投票和日志追赶,通常 200~400 ms 内恢复写入
心跳 100 ms、选举超时 300~600 ms:发现故障最多 600 ms,加上一轮投票和日志追赶,通常 400~800 ms 内恢复写入
心跳 500 ms、选举超时 1500~3000 ms:发现故障最多 3000 ms,加上一轮投票和日志追赶,通常 2000~4000 ms 内恢复写入
- 只有多数派那一侧能工作。少数派那侧的旧领导者会发现自己收不到多数派的心跳响应,主动退位;客户端在那一侧只能读到可能过期的数据。这就是 CAP 里选择了 CP:分区时牺牲可用性,保证一致性。
- 两机房对半部署是个陷阱:任何一侧都不是多数派,断网后谁也不能写。正确做法是三个机房(2:2:1,第三地放一个仲裁副本),或者接受"主机房可写、备机房只读"。
- 故障切换要多久:由心跳间隔和选举超时决定。心跳 50 ms、选举超时 150~300 ms 时,通常几百毫秒内恢复写入;超时调得太短会因为偶发的网络抖动频繁换主,太长则故障恢复慢。
租约(lease)是共识之上最常用的一个模式:领导者持有一个有时限的租约,在租约内它可以不经过多数派就直接响应读请求(读性能高很多);租约到期前要续租。租约的安全性依赖时钟:只要各节点的时钟漂移有上界,旧主的租约一定先过期,新主才会开始服务。用租约实现的分布式锁同样要注意——锁可能在持有者不知道的情况下过期,所以被保护的操作要么足够快,要么自己带上版本号做校验(fencing token)。
推理集群里的共识¶
| 用途 | 放在哪 | 说明 |
|---|---|---|
| 实例成员列表、健康状态 | etcd / Kubernetes | 路由器监听变化,更新一致性哈希环(上一章) |
| 分片方案、路由表版本 | etcd | 变更要原子生效,避免客户端看到不同版本 |
| 选主:谁来做全局调度、谁来跑权重更新 | etcd 的租约 + 分布式锁 | 注意锁过期,长操作要带版本号校验 |
| 模型版本、配置、灰度比例 | etcd / 配置中心 | 低频写入、全体一致 |
| 请求状态、KV Cache 位置 | 不放共识系统 | 写入量太大;用专门的 KV 存储或让路由器维护近似视图 |
| 指标、队列深度 | 不放共识系统 | 高频、可丢失、只需要近似值;走监控系统或心跳上报 |
一条经验:共识系统是用来存"小而关键"的状态的。etcd 的写入要经过多数派落盘,每秒几千次就是上限,延迟在毫秒级。推理集群里每秒几万个请求的状态绝不能往里放。
Kubernetes 本身就是这个模式的例子:所有对象存在 etcd 里,控制器监听变化并执行;推理实例的部署、扩缩容、服务发现都通过它完成(见生产部署与运维)。而推理引擎内部的多 rank 同步是另一回事——那里用的是集合通信和共享内存(见进程间通信和多 rank 同步),因为进程都在同一台机器或同一个作业里,不需要容忍任意节点故障。
再说一句一致性模型。 线性一致(读到的一定是最新已提交的值)代价最高;顺序一致、因果一致、最终一致依次放松。推理服务里绝大多数状态用最终一致就够:路由表晚几百毫秒更新,最多让几个请求发错实例,重试或回源即可;只有"谁是主"这类决定正确性的状态才需要线性一致。
怎么讲清楚
讲分布式基础:先说为什么需要共识——多副本下要避免脑裂和判断不一致,共识就是让多数派对一串操作的顺序达成一致。法定人数:W + R > N 保证读写有交集,多数派配置能容忍 ⌊N/2⌋ 台故障,偶数副本不划算所以用 3 或 5 台。Raft:任期是逻辑时钟用来识别过期领导者,随机超时避免同时发起选举,拿到多数票才当选,被多数派复制的条目才算提交;两条安全约束(只投给日志不旧于自己的候选人、只提交自己任期内的条目)保证已提交的不会丢。分区时只有多数派侧能写,两机房对半是陷阱、要三地或加仲裁;故障切换时间由心跳和选举超时决定,几百毫秒量级。租约让领导者本地读,但锁可能悄悄过期,要用 fencing token。最后落到推理:成员列表、分片方案、选主放 etcd,请求状态和指标绝不能放;引擎内部的多 rank 同步用集合通信,不是共识。
练习¶
1. 配置法定人数。 一个 5 副本的元数据存储,要求:任何时候读到的都是最新值,并且能容忍 2 台机器同时故障。W 和 R 各取多少?如果改成"读多写少、读延迟优先",还能怎么配?
参考答案
要容忍 2 台故障且写要成功,W ≤ 3;要 W + R > 5,取 W = 3、R = 3(多数派)。
读优先的配法是 W = 5、R = 1:读只要问一台,延迟最低;代价是写必须所有副本都成功,一台故障写就不可用了。折中是 W = 4、R = 2,写容忍 1 台故障、读问两台。实践中更常见的做法是保持多数派配置,再加上租约让领导者直接本地读。
2. 两机房部署。 一个 3 副本的 etcd 集群,两台在机房 A、一台在机房 B。A 与 B 之间断网时会发生什么?如果是 A 整个机房断电呢?怎么改进?
参考答案
A 与 B 断网:A 侧有 2 台,是多数派,继续工作;B 侧那台只能读旧数据。这是可以接受的。
A 整个机房断电:只剩 B 的 1 台,不是多数派,集群完全不可写——服务发现、配置变更全停,只能读缓存。
改进:部署到三个机房(1:1:1),任何一个机房整体故障都还有 2 台;或者 2:2:1,第三地只放一个仲裁副本(它不承载读写压力,但提供决定性的一票)。
3. 锁会过期。 一个"权重热更新"的任务用 etcd 的分布式锁保证同一时刻只有一个实例在更新。任务通常 30 秒,锁的租约 10 秒、自动续租。写出一个会出问题的场景,以及怎么防。
参考答案
场景:持有锁的实例发生了一次长时间的 GC 停顿或者磁盘卡顿,超过 10 秒没能续租,锁过期,另一个实例拿到锁开始更新;原来那个实例停顿结束后继续执行后半段,两个实例同时在写同一份权重。
防法:(1)fencing token——每次拿锁时 etcd 返回一个单调递增的版本号,实际写入的目标(对象存储、注册表)校验这个版本号,拒绝比已见过的更小的;(2)操作做成幂等且原子(先写到临时位置,最后一步原子改名或原子切换指针);(3)操作前重新校验自己还持有锁(但这只是缩小窗口,不能消除)。只靠"续租"是不够的。
4. 该不该放进 etcd。 判断下面四项各该放在哪:(a)每个推理实例当前的队列深度;(b)当前生效的模型版本;(c)某个前缀的 KV 块存在哪台机器上;(d)PD 分离里 prefill 与 decode 实例的配比。
参考答案
(a)不放。每秒变化多次、可以丢、只需要近似值,用心跳上报或监控系统,路由器维护一份近似视图。
(b)放。低频、全体必须一致,变更要原子生效。
(c)不放。KV 块的数量是百万级、生命周期很短,属于高频数据;应该放在专门的分布式 KV 存储里(用一致性哈希定位),或者由各实例上报事件、路由器维护近似索引。
(d)放。配比是集群级的配置,变更不频繁,但所有调度组件必须看到同一个版本;实际调整由控制器根据监控指标决定,再写回这个配置。
小结¶
- 多副本必须解决脑裂和判断不一致,共识让多数派对一串操作的顺序达成一致。
- W + R > N 保证读到最新值;多数派配置容忍 ⌊N/2⌋ 台故障;偶数副本不划算,用 3 或 5 台。
- Raft:任期识别过期领导者,随机超时避免流选,多数票当选,多数派复制才算提交;两条安全约束保证已提交的不丢。
- 分区时只有多数派侧能写;两机房对半是陷阱,要三地或加仲裁;故障切换时间由心跳与选举超时决定。
- 租约让领导者本地读,但锁可能悄悄过期,长操作要用 fencing token 或幂等原子写。
- 成员列表、分片方案、模型版本、选主放共识系统;请求状态、KV 位置、指标绝不能放。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题