犀牛鸟 2026 研究笔记 estelledc.github.io

第 4 章:共识与复制——让多台机器达成一致

结论先行:分布式数据库的核心难题不是”把数据分散到多台机器”,而是”让多台机器对数据的状态达成一致”。Raft 协议和 GTM 中心化协调是两种截然不同的解决思路。理解它们的工作原理和 tradeoff,是理解后续所有项目设计决策的基础。


一次班级投票的故事

想象你是一个大学班级的班长,班上 30 个同学要投票决定周末去爬山还是去海边。如果所有同学都在同一间教室里,这件事很简单——举手表决,数人头,多数派获胜。

但如果同学们分散在 30 个不同的城市呢?你需要打 30 个电话收集投票,统计结果后再打 30 个电话通知结果。问题来了:

这三个问题,分别对应了分布式共识中的:多数派确认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 Livenessraftstoreliveness/)——不仅检测节点进程是否在线,还评估其存储层的健康状态。

为什么需要这样?想象一个场景:节点的 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 的特点

这意味着什么?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 上有准实时的列存数据,可以跑分析查询

这个设计的精巧之处在于:

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 Proxysrc/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.cgtm_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 优化:

YugabyteDBmulti_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 的好处是减少了这种事务重启的概率。

读完本章你能做什么


导读目录:README.md 上一章:第 3 章 · 四大架构模式——从改装到重造 下一章:第 5 章 · 数据分片与再均衡——把大象切成小块