第 4 章:共识与复制——让多台机器达成一致
结论先行:分布式数据库的核心难题不是”把数据分散到多台机器”,而是”让多台机器对数据的状态达成一致”。Raft 协议和 GTM 中心化协调是两种截然不同的解决思路。理解它们的工作原理和 tradeoff,是理解后续所有项目设计决策的基础。
一次班级投票的故事
想象你是一个大学班级的班长,班上 30 个同学要投票决定周末去爬山还是去海边。如果所有同学都在同一间教室里,这件事很简单——举手表决,数人头,多数派获胜。
但如果同学们分散在 30 个不同的城市呢?你需要打 30 个电话收集投票,统计结果后再打 30 个电话通知结果。问题来了:
- 如果有 5 个同学的电话打不通怎么办?等所有人都联系上才公布结果(太慢),还是只要联系上大多数就做决定(有人不知道结果)?
- 如果两个同学同时给你打电话说”我也想当组织者”,怎么决定听谁的?
- 如果你(班长)的手机也没电了,谁来继续组织投票?
这三个问题,分别对应了分布式共识中的:多数派确认、Leader 选举、容错和自动恢复。
什么是共识,为什么需要共识
在分布式数据库中,同一份数据通常存储在多台机器上(副本/Replica)。当一台机器收到写请求时,它需要确保其他副本也更新了——否则不同的用户可能从不同的副本读到不同的数据。
共识(Consensus)就是解决这个问题的协议:让多台机器就”接下来要做什么”达成一致。
为什么不直接让一台机器做主(Primary),其他机器无条件听它的?问题是:如果这台主机器挂了,谁来接替?接替的机器怎么保证自己的数据是最新的?如何防止出现”脑裂”——两台机器都认为自己是主?
这就是共识协议要回答的核心问题。
Raft 协议:民主投票的共识
Raft 是分布式数据库中最常用的共识协议(CockroachDB、TiDB、YugabyteDB 都用它)。它的核心思想用一句话概括就是:选一个 Leader,大事小事 Leader 说了算,但 Leader 是大家投票选出来的,而且随时可以换。
Raft 的三个角色
每台参与共识的机器(称为节点)在任意时刻处于三种角色之一:
Leader(班长):负责接收所有写请求,把操作日志分发给 Follower,收到多数派确认后提交。一个复制组(Raft Group)在同一时刻只有一个 Leader。
Follower(普通同学):被动接收 Leader 发来的日志,写入本地后回复确认。不主动发起任何操作。
Candidate(竞选者):当 Follower 超过一定时间没收到 Leader 的心跳时,它会认为 Leader 挂了,自己变成 Candidate 发起竞选。
正常状态:
┌──────────┐ 日志复制 ┌──────────┐
│ Leader │ ──────────► │ Follower │
│ (节点 A) │ │ (节点 B) │
└──────────┘ └──────────┘
│ 日志复制 ┌──────────┐
└────────────────► │ Follower │
│ (节点 C) │
└──────────┘
Leader 挂了后:
┌──────────┐ ┌──────────┐
│ 挂了 ✗ │ │Candidate │
│ (节点 A) │ │ (节点 B) │ ← 发现没收到心跳
└──────────┘ └──────────┘ 发起竞选
┌──────────┐
│ Follower │ ← 投票给节点 B
│ (节点 C) │
└──────────┘
Raft 的写入流程
让我们追踪一次写操作在 Raft 中的完整流程:
1. 客户端发送写请求给 Leader
客户端 ──"写入 x=5"──► Leader (节点A)
2. Leader 把操作追加到自己的日志
Leader 日志: [..., (term=3, index=7, "x=5")]
3. Leader 并行发送日志条目给所有 Follower
Leader ──AppendEntries──► Follower B
Leader ──AppendEntries──► Follower C
4. Follower 写入日志后回复确认
Follower B ──ACK──► Leader
Follower C ──ACK──► Leader(网络慢,还没回)
5. Leader 收到多数派确认(自己 + B = 2/3),提交
Leader: 2/3 确认,提交!x=5 生效
6. Leader 回复客户端"成功"
Leader ──"写入成功"──► 客户端
7. 下一次心跳时,Leader 通知 Follower 已提交
Follower C 最终也会追上
注意第 5 步:Leader 不需要等所有 Follower 确认,只需要多数派。这就是 Raft 的关键设计——在安全性(数据不丢)和性能(不等最慢的)之间取得平衡。3 个节点只需要 2 个确认,5 个节点只需要 3 个确认。
Leader 选举
当 Follower 在 election timeout(通常是 150-300ms 随机值)内没收到 Leader 的心跳时,它会发起选举:
节点 B 的选举过程:
1. 递增自己的 term(任期号),变成 Candidate
term: 3 → 4
2. 给自己投一票
3. 并行发送 RequestVote RPC 给其他节点
B ──RequestVote(term=4)──► A(挂了,没回应)
B ──RequestVote(term=4)──► C
4. C 检查:
- B 的 term (4) > 我的 term (3)?是
- B 的日志至少和我一样新?是
→ 投票给 B
5. B 收到多数派投票(自己 + C = 2/3),成为新 Leader
6. B 开始发送心跳给所有节点,宣告自己是 Leader
关键安全保证:每个节点在同一个 term 内只投一票。这防止了”脑裂”——不可能同时选出两个 Leader。
CockroachDB 的深度 Raft 定制
CockroachDB 不是简单地拿 etcd 的 Raft 库来用——它 fork 了 etcd Raft 并做了大量深度定制。pkg/raft/doc.go 有 398 行的设计文档,详细记录了每一个改动的原因。
Fortification:防止不必要的选举
标准 Raft 中,如果一个 Follower 暂时与 Leader 断开连接(比如网络抖动几秒),它会认为 Leader 挂了并发起选举。这可能导致频繁的无意义选举,影响性能。
CockroachDB 引入了 Fortification 机制(tracker/fortificationtracker.go):Leader 在收到 Follower 的确认时会”加固”自己的 Leadership——只要 Leader 能确认多数派 Follower 在一定时间内还认可自己,就不会被轻易推翻。
标准 Raft:
Follower 超时 → 立刻发起选举(可能是网络抖动造成的误判)
CockroachDB Fortification:
Follower 超时 → 检查 Leader 是否被 Fortified
├── 是 → 延长等待时间(给网络恢复的机会)
└── 否 → 发起选举
Store Liveness:比心跳更精细的存活检测
传统 Raft 用简单的心跳检测节点是否存活——一段时间没收到心跳就认为节点挂了。CockroachDB 引入了 Store Liveness(raftstoreliveness/)——不仅检测节点进程是否在线,还评估其存储层的健康状态。
为什么需要这样?想象一个场景:节点的 CPU 和网络都正常(心跳能发),但它的硬盘出了问题,写入延迟从 1ms 飙到 10 秒。传统心跳检测认为它”活着”,但它实际上已经无法正常服务了。Store Liveness 能检测到这种”活着但不好使”的状态。
异步存储写入
标准 Raft 中,Leader 必须先把日志写入本地磁盘,然后才发送给 Follower。这意味着 Leader 的磁盘 IO 在关键路径上——Leader 写得慢,整个复制组都慢。
CockroachDB 的异步存储写入(AsynchronousStorageWrites)允许 Leader 在写本地磁盘的同时发送日志给 Follower,而不是串行等待。
标准 Raft 写入流程(串行):
Leader 写本地磁盘(5ms) → 发送给 Follower → 等确认
总延迟 = 5ms + 网络延迟
CockroachDB 异步写入(并行):
┌── Leader 写本地磁盘(5ms) ──┐
│ │ 并行
└── 发送给 Follower ─────────┘
总延迟 = max(5ms, 网络延迟)
TiDB 的 Raft Learner:HTAP 的关键
TiDB 对 Raft 最重要的扩展是引入了 Learner 角色。标准 Raft 只有 Leader 和 Follower,TiDB 加了第三种:Learner。
Learner 的特点:
- 接收 Leader 的日志复制(和 Follower 一样)
- 不参与投票(与 Follower 不同)
- 不计入多数派(Leader 不等 Learner 的确认就可以提交)
这意味着什么?Learner 对写入性能零影响。 Leader 写入时只需要等 Follower 的多数派确认,完全不管 Learner。但 Learner 上的数据是准实时的(通常延迟在秒级以内)。
TiDB 用 Learner 做了一件精彩的事:TiFlash(列存引擎)以 Raft Learner 的身份加入复制组,异步接收 TiKV(行存引擎)的数据,转换成列存格式。 这样就不需要传统的 ETL 流程,分析查询可以直接读 TiFlash 上的准实时列存数据。
正常 Raft 复制:
Leader(TiKV) ──日志──► Follower1(TiKV) ← 参与投票
──日志──► Follower2(TiKV) ← 参与投票
写入提交需要 2/3 确认 = Leader + 1个Follower
加入 Learner 后:
Leader(TiKV) ──日志──► Follower1(TiKV) ← 参与投票
──日志──► Follower2(TiKV) ← 参与投票
──日志──► Learner(TiFlash) ← 不参与投票!
写入提交仍然只需要 2/3 确认(不算 Learner)
但 TiFlash 上有准实时的列存数据,可以跑分析查询
这个设计的精巧之处在于:
- 不增加写入延迟(Learner 不在关键路径上)
- 不影响写入可靠性(多数派确认仍然只算 Voter)
- 提供了准实时的分析能力(秒级延迟 vs 传统 ETL 的小时级延迟)
- 资源隔离(分析查询在 TiFlash 上跑,不影响 TiKV 上的事务处理)
OpenTenBase 的 GTM:中心化的另一条路
与 Raft 的去中心化共识完全不同,OpenTenBase 采用了中心化的 GTM(Global Transaction Manager)来保证一致性。
为什么选择中心化
回到班级投票的类比:Raft 的做法是”民主选举 + 多数派投票”;GTM 的做法是”校长指定一个教导主任,所有事情都由教导主任统一协调”。
中心化的优势是简单直接——不需要复杂的选举和共识协议,所有决策一个人说了算。劣势也很明显——教导主任请假了(GTM 挂了),所有事情都停摆。
OpenTenBase 选择 GTM 的原因是务实的:它基于 Postgres-XL,而 Postgres-XL 的设计目标是”在 PG 内核上做最小改动实现分布式”。GTM 是实现全局一致性最直接的方式——不需要改动 PG 的存储引擎或复制协议。
GTM 的工作原理
GTM 做三件关键事情:
全局事务 ID 分配:每个分布式事务开始时,Coordinator 向 GTM 请求一个全局唯一的事务 ID(GXID)。这保证了所有 Datanode 看到的事务顺序是一致的。
Coordinator 1: "我要开始一个事务" → GTM 回复 "你的 GXID 是 1001"
Coordinator 2: "我也要开始一个事务" → GTM 回复 "你的 GXID 是 1002"
所有 Datanode 看到 GXID 1001 < 1002,知道事务 1001 在 1002 之前
全局快照管理:当一个事务需要读数据时,它需要知道”哪些事务已经提交了、哪些还在进行中”。GTM 维护了一个全局的活跃事务列表,用来生成快照。
// gtm_snap.c 中快照生成的简化逻辑
typedef struct GTM_SnapshotData {
GlobalTransactionId sn_xmin; // 最老的活跃事务 ID
GlobalTransactionId sn_xmax; // 下一个将分配的事务 ID
int32 sn_xcnt; // 活跃事务数量
GlobalTransactionId *sn_xip; // 活跃事务 ID 列表
} GTM_SnapshotData;
全局序列管理:PostgreSQL 的 SEQUENCE(自增 ID)在单机上是本地的。在分布式环境下,如果两个 Datanode 各自维护 SEQUENCE,可能生成重复的 ID。GTM 统一管理全局 SEQUENCE。
GTM 的性能瓶颈和优化
GTM 最大的问题是它是单点——所有事务都要经过它。如果集群有 1000 个并发事务,GTM 每秒可能收到上万次请求。
src/gtm/main/gtm_txn.c(4029 行)是 GTM 的核心文件。它维护了一个全局事务数组 GTM_Transactions,每次事务开始/提交/回滚都要修改这个数组——这涉及到锁竞争。
为了缓解瓶颈,OpenTenBase 设计了 GTM Proxy(src/gtm/proxy/):
没有 GTM Proxy 的情况:
Coordinator 1 ──事务请求──► GTM
Coordinator 1 ──事务请求──► GTM
Coordinator 1 ──事务请求──► GTM
Coordinator 2 ──事务请求──► GTM ← 每个请求都是一次网络往返
...
有 GTM Proxy 的情况:
Coordinator 1 ──事务请求──► GTM Proxy 1 ──批量发送──► GTM
(本地聚合多个请求)
Coordinator 2 ──事务请求──► GTM Proxy 2 ──批量发送──► GTM
(本地聚合多个请求)
网络往返次数大幅减少
GTM Proxy 的工作方式类似于”批量快递”——不是每件包裹单独寄,而是攒一批一起寄。这减少了网络往返次数,但没有从根本上消除 GTM 的单点限制。
GTM 的高可用
GTM 支持主备模式——GTM Master 挂了之后,GTM Standby 可以接替。src/gtm/main/replication.c 和 gtm_xlog.c 实现了 WAL 日志复制,确保 Standby 的数据与 Master 一致。
┌───────────┐
│ GTM Master│ ← 正常服务
└─────┬─────┘
│ WAL 复制
┌─────▼─────┐
│GTM Standby│ ← 热备
└───────────┘
Master 挂了后:
┌───────────┐
│ GTM Master│ ✗ 挂了
└───────────┘
┌───────────┐
│GTM Standby│ → 提升为新的 Master
└───────────┘
全局时钟:Raft 之外的另一个挑战
共识协议解决了”多台机器就某个操作达成一致”的问题,但还有一个问题没解决:事件的先后顺序。
想象两个用户几乎同时修改同一条数据:用户 A 在北京设置 price = 100,用户 B 在上海设置 price = 200。最终 price 应该是多少?取决于谁”更后面”。
在单机上这很简单——用本地时钟就能排序。但在分布式环境下,不同机器的时钟可能不一致(时钟漂移),网络传输有延迟——你无法简单地用”哪个请求先到”来判断先后顺序。
Google TrueTime:用硬件解决软件问题
Google Spanner 的解决方案是 TrueTime——在每个数据中心部署 GPS 接收器和原子钟,提供高精度的全局时间。TrueTime 不是给你一个时间点,而是给你一个时间区间 [earliest, latest],保证真实时间一定在这个区间内。
TrueTime API:
TT.now() → [earliest, latest]
例如: TT.now() → [10:00:00.001, 10:00:00.003]
真实时间在 10:00:00.001 到 10:00:00.003 之间
不确定区间 = latest - earliest ≈ 几微秒
Spanner 的事务提交时会等待一个 commit wait(等到不确定区间过去),确保后面的事务一定能看到自己的修改。代价是每次提交多等几微秒。
CockroachDB 没有 GPS 和原子钟(它是面向公有云的产品),所以它用了不同的方案。
CockroachDB 的 HLC + Uncertainty Interval
CockroachDB 使用 HLC(Hybrid Logical Clock)——结合物理时钟和逻辑计数器。
HLC 时间戳 = (物理时间, 逻辑计数器)
比较规则:
先比较物理时间,物理时间相同时比较逻辑计数器
例如:
(10:00:00.001, 0) < (10:00:00.001, 1) < (10:00:00.002, 0)
但问题是:不同机器的物理时钟可能有偏差。CockroachDB 用 Uncertainty Interval(不确定区间)来处理这个问题:
节点 A 的时钟:10:00:00.001
节点 B 的时钟:10:00:00.004(比 A 快 3ms)
最大时钟偏差:假设 5ms
当节点 A 上的事务在 10:00:00.001 开始时,
它不确定 10:00:00.001 到 10:00:00.006 之间的数据
是在它之前还是之后写的。
如果它读到一个时间戳落在不确定区间内的值,
事务需要以更高的时间戳重启(restart)。
这是一个”用性能换正确性”的设计——在极端情况下(时钟偏差大),事务可能需要多次重启,但在正常情况下(时钟偏差小),开销很小。
YugabyteDB 的 Hybrid Time
YugabyteDB 同样使用 HLC(它叫 Hybrid Time),但实现细节不同。src/yb/common/hybrid_time.h 定义了时间戳格式:
YugabyteDB Hybrid Time:
64-bit timestamp = (物理时间微秒, 逻辑计数器)
物理部分:微秒级精度
逻辑部分:用于打破物理时间相同时的顺序
没有 GPS/原子钟意味着不确定窗口更大(毫秒级 vs Spanner 的微秒级),事务等待时间也更长。但对大多数应用场景来说,这个延迟可以接受。
OpenTenBase 的全局时钟:GTM 天然提供
OpenTenBase 的 GTM 天然解决了全局时钟问题——因为所有事务 ID 都由 GTM 分配,事务的先后顺序由 GTM 决定。不需要 HLC 或 TrueTime。
这是中心化方案的一个意外好处:全局排序”免费获得”,因为 GTM 本身就是全局的排序器。 但代价是 GTM 成为单点——一个组件同时承担了事务管理和时钟服务的职责。
全局时钟方案对比
| 方案 | 使用项目 | 精度 | 依赖硬件 | 不确定窗口 | 单点风险 |
|---|---|---|---|---|---|
| TrueTime | Spanner | 微秒 | GPS+原子钟 | 极小(~7μs) | 无 |
| HLC+Uncertainty | CockroachDB | 毫秒 | NTP 时钟同步 | 中(~500ms) | 无 |
| Hybrid Time | YugabyteDB | 微秒 | NTP | 中(~500ms) | 无 |
| GTM 全局分配 | OpenTenBase | N/A | 无 | 无(确定的) | 有(GTM) |
数据复制策略对比
除了共识协议和时钟,数据怎么复制也是一个重要的设计决策:
同步复制 vs 异步复制
同步复制(CockroachDB, TiDB Voter):
Leader 写 → Follower 确认 → 才返回成功
优点:数据不丢
缺点:写入延迟 = 网络延迟
异步复制(TiDB Learner, MySQL 主从):
Leader 写 → 立即返回成功 → 后台复制给 Follower
优点:写入快
缺点:可能丢数据(Leader 挂了,未复制的数据丢失)
半同步复制(Raft 多数派):
Leader 写 → 等多数派确认 → 返回成功
优点:平衡了安全和性能
缺点:少数派可能暂时落后
各项目的复制策略
| 项目 | 复制协议 | 副本数 | 写入确认 |
|---|---|---|---|
| CockroachDB | Raft | 3(默认) | 多数派 |
| TiDB (TiKV) | Raft | 3(默认) | 多数派 |
| TiDB (TiFlash) | Raft Learner | 按需 | 不参与确认 |
| YugabyteDB | Raft | 3(默认) | 多数派 |
| Vitess | MySQL 复制 | 按配置 | 按配置 |
| Citus | PG 复制 | 按配置 | 按配置 |
| OpenTenBase | GTM 协调 + PG 复制 | 按配置 | GTM 确认 |
Raft 的 Multi-Raft:当一个集群有上万个数据分片
一个真实的分布式数据库集群可能有成千上万个数据分片(Range/Region/Tablet)。如果每个分片都运行一个独立的 Raft 组,一台机器上可能同时有数百个 Raft 组在运行。
这带来了一些工程挑战:
消息爆炸:每个 Raft 组的 Leader 都要给 Follower 发心跳。100 个 Raft 组 × 2 个 Follower = 每秒 200+ 心跳消息。
磁盘 IO 竞争:每个 Raft 组都要写自己的日志。如果不做批量优化,每次写入都是一次磁盘 sync 操作。
CPU 调度:每个 Raft 组都有自己的状态机,需要 CPU 时间来处理日志和状态转换。
各项目的 Multi-Raft 优化:
YugabyteDB 的 multi_raft_batcher.cc 实现了多 Raft 组的消息批处理——同一个节点上发往同一个目标节点的 Raft 消息会被合并成一个网络包发送。
CockroachDB 的 Raft 实现把多个 Range 的日志写入合并成一次磁盘 IO,大幅降低了磁盘 sync 的次数。
TiDB/TiKV 的 Raft Store 使用 batch system——多个 Region 的 Raft 消息在同一个事件循环中处理,避免了每个 Region 一个线程的开销。
没有批量优化:
Raft组1 ──心跳1──► 节点B
Raft组2 ──心跳2──► 节点B
Raft组3 ──心跳3──► 节点B
= 3次网络调用
有批量优化(Multi-Raft Batching):
Raft组1 ┐
Raft组2 ├── 合并 ──► 一个批量包 ──► 节点B
Raft组3 ┘
= 1次网络调用
常见误区
误区一:”Raft 保证数据永远不丢” → 正确理解:Raft 保证的是已提交的数据不丢(只要多数派节点存活)。如果 Leader 收到写请求但还没来得及复制给 Follower 就挂了,这个写操作会丢失——因为它还没被提交。
误区二:”GTM 一定比 Raft 差” → 正确理解:GTM 和 Raft 解决的问题维度不同。GTM 是事务级别的协调(谁的事务 ID 是多少、当前有哪些活跃事务),Raft 是数据级别的复制(确保多个副本的数据一致)。GTM 的单点问题是真实的,但它的简单性也是真实的优势——实现正确一个 GTM 比实现正确 Raft 容易得多。
误区三:”CockroachDB 不用 TrueTime 所以一致性比 Spanner 差” → 正确理解:CockroachDB 通过 Uncertainty Interval 和事务重启机制仍然实现了 Serializable 隔离级别。代价是极端情况下的事务重启,而不是一致性的降低。Spanner 用 TrueTime 的好处是减少了这种事务重启的概率。
读完本章你能做什么
- 用”班级投票”的类比向朋友解释 Raft 协议的工作原理
- 画出 Raft 写入流程的时序图,解释为什么只需要多数派确认
- 解释 CockroachDB 的 Fortification 和 Store Liveness 相比标准 Raft 的改进
- 解释 TiDB 的 Raft Learner 如何在不影响写入性能的情况下实现 HTAP
- 解释 OpenTenBase 的 GTM 工作原理,以及它与 Raft 的核心差异
- 对比 TrueTime、HLC、GTM 三种全局时钟方案的 tradeoff
导读目录:README.md 上一章:第 3 章 · 四大架构模式——从改装到重造 下一章:第 5 章 · 数据分片与再均衡——把大象切成小块