分布式一致性协议与算法工程实践:从 Raft、ZAB 到 PBFT、XA 与 TCC

分布式系统里的“一致性”并不是单一问题:复制状态机要保证节点对操作顺序达成共识,数据服务要决定读请求能否看到最新写入,跨数据库与跨业务系统还要处理事务原子性。不同问题对应的故障模型、协议机制与工程代价各不相同。本文把 Raft 与 HashiCorp Raft、ZooKeeper 的 ZAB 协议、拜占庭容错与 PBFT、MySQL XA、TCC,以及 InfluxDB Enterprise 的一致性架构连接起来,结合核心流程、源码与故障恢复,并通过分布式 KV 系统连接原理与实践。文末给出算法对比矩阵、选型决策图与分层学习路线,便于后续复习和架构设计时查阅。

分布式系统究竟在解决哪种“一致性”问题?

讨论 Raft、ZAB、XA、TCC 或 PBFT 之前,需要先把几个看似相同、实际不同的目标区分开。否则会出现这样的误判:既然数据库支持事务,为什么还需要 Raft?既然 ZooKeeper 使用共识协议,为什么读请求还能读到旧数据?既然 TCC 能回滚,为什么仍需要补偿和重试?

共识、数据访问一致性与事务原子性

**共识(Consensus)**关心的是:多个节点能否对一个提案、日志项或命令达成不会相互矛盾的决定。例如,集群应把 SET x = 7 作为第 108 条日志,而不是让不同节点在同一位置分别提交 SET x = 7 和 SET x = 9。Paxos、Raft、ZAB、PBFT 所处的核心问题域与此有关。

**数据访问一致性(Consistency Model)**关心的是:客户端在执行写操作之后,什么时候、通过哪些节点,能够观察到新值。例如,A 客户端已经收到 x = 7 的写入成功响应,B 客户端再查询时是否仍可能得到 x = 5。它涉及线性一致性(Linearizability)、顺序一致性(Sequential Consistency)、最终一致性(Eventual Consistency)等模型。

**事务原子性(Atomicity)**关心的是:一组有关联的操作是否作为一个逻辑整体完成。例如,更新数据库 A 的订单状态、扣减数据库 B 的库存,两者必须遵循共同的提交或撤销决策。XA/2PC 和 TCC 主要解决这一类问题;它们不等于用于复制日志的共识协议。

问题 典型提问 主要技术 不能直接推出的保证
共识 哪条命令被确定提交、顺序是什么? Paxos、Raft、ZAB、PBFT 所有读取都必然线性一致
数据访问一致性 写成功后,其他客户端能否立即读到新值? Leader 读屏障、Quorum 读写、版本控制、反熵 多资源事务必然原子提交
事务原子性 A、B、C 三处业务变更如何一起成功或一起撤销? XA/2PC、TCC 副本间日志已达成共识
故障容错 崩溃、断网、恶意节点出现时还能做什么? 多数派、领导者切换、BFT、重试与恢复 任何时间、任何故障下都可用

这几种机制可以叠加:一个服务可以使用 Raft 复制其事务协调器状态,在协调器内执行 XA 或 TCC;数据库集群也可以用共识日志保证元数据顺序,同时让大量业务数据采用最终一致性复制。

一致性不是一个从“弱”到“强”的简单开关

线性一致性要求每个操作仿佛在其调用与响应之间的某一瞬间生效,并尊重不同客户端操作之间真实发生的先后关系。顺序一致性要求存在符合各客户端程序顺序的全局执行顺序,但不一定保持跨客户端的实时先后关系。最终一致性则允许副本暂时不同步,在没有新写入、消息可继续传播且修复机制正常工作的前提下逐渐收敛。

需要特别区分:**保证日志提交顺序,并不等于保证任何一个本地副本读取都是最新的。**Raft 可以安全地复制日志;但如果应用允许直接从落后的 Follower 读取,客户端仍可能看到旧值。ZooKeeper 的写操作具有有序原子广播语义,普通读则可以由本地服务节点响应,也有读取旧值的可能。

CAP 定理中的 C 通常指线性一致性(原子一致性),与数据库 ACID 中约束事务前后合法状态的 Consistency 不是同一个定义。将“CAP 的 C 就是 ACID 的 C”作为通用结论,会混淆这两类语义。CAP 讨论的是出现网络分区时,系统无法同时对所有请求保证线性一致且全部可用;并不是说正常网络下只能二选一。

先判断故障模型,再讨论算法

大多数企业内网系统主要防范崩溃故障(Crash Fault):节点停止服务、网络延迟或分区、进程重启、磁盘故障。这是 CFT(Crash Fault Tolerance)问题域。Raft 和 ZAB 的安全设计依赖非拜占庭参与者遵守协议。

如果节点可能伪造响应、向不同节点发送矛盾内容、故意破坏协议,就要考虑拜占庭故障(Byzantine Fault)。数字签名、消息认证、PBFT 等机制试图在规定的恶意节点比例与网络假设下保留共识安全性。

在典型多数派 CFT 集群中,N 个投票节点要求至少 ⌊N/2⌋ + 1 个节点形成多数派,因此最多容忍 ⌊(N-1)/2⌋ 个投票节点不可用;经典 PBFT 要容忍 f 个拜占庭节点,则通常需要 N >= 3f + 1 个副本。两者不是“谁的容错率更高”的直接比较,而是防范的故障类型不同。

flowchart TD
    A[分布式系统出现不一致或不可用] --> B{真正需要解决什么}
    B -->|复制命令和顺序| C{节点可能恶意作假吗}
    C -->|否 可信环境| D[Raft / ZAB / Paxos]
    C -->|是 不可信环境| E[PBFT 等 BFT 协议]
    B -->|写后是否能读到新值| F[定义读一致性等级]
    F --> G[Leader 校验 / 读屏障 / Quorum / 修复]
    B -->|跨资源全部成功或失败| H{能否使用 XA 资源管理器}
    H -->|能且可承担锁等待| I[XA + 持久化事务协调器]
    H -->|涉及业务 API 或异构资源| J[TCC + 幂等 + 补偿]

Raft:将共识转化为可恢复的复制状态机

Raft 把复制状态机问题拆成三个相对清晰的部分:领导者选举、日志复制和安全性,再通过成员变更与快照支持长期运行。它的工程价值不只是“投票选出主节点”,而是让所有有效节点按照相同顺序执行已经达成共识的命令。

领导者、跟随者与候选人的状态变化

一个典型 Raft 节点在运行时处于 Follower、Candidate、Leader 三种角色中的一种。Follower 接收 Leader 心跳和日志;超时未收到有效通信时变成 Candidate;Candidate 增加任期(Term),为自己投票并向其他投票节点请求选票;拿到多数票才成为 Leader。

随机化选举超时的作用,是降低多个 Follower 同时发起选举、选票分散的概率。当候选人没有及时获得多数票,就在下一轮任期重新竞争。任期编号与投票约束可以防止旧领导者在新任期中继续合法提交提案。

stateDiagram-v2
    [*] --> Follower
    Follower --> Candidate: 选举超时
    Candidate --> Leader: 当前任期获多数选票
    Candidate --> Candidate: 选举超时 / 新任期
    Candidate --> Follower: 发现有效更高任期领导者
    Leader --> Follower: 发现更高任期
    Leader --> [*]: 关闭
    Follower --> [*]: 关闭

领导者选举不是简单的“谁先请求投票,谁就赢”。投票者必须检查候选人的日志是否足够新;Raft 使用最后日志项的 term,再比较 index,来限制落后节点当选。这样做的目的,是保证未来领导者不会丢弃已经按照协议提交的日志。

日志如何复制、提交、应用

日志项的关键内容通常是索引 index、任期 term 和命令 command。Leader 负责分配全局顺序;Follower 不能自行决定应该提交哪条业务命令。

典型写入路径如下:

sequenceDiagram
    autonumber
    participant C as Client
    participant L as Raft Leader
    participant F1 as Follower A
    participant F2 as Follower B
    participant M as 状态机 FSM
    C->>L: 写入命令 SET x=7
    L->>L: 追加本地日志
    par 日志复制
        L->>F1: AppendEntries(term,index,entries)
        F1-->>L: 检查前缀并确认
    and 日志复制
        L->>F2: AppendEntries(term,index,entries)
        F2-->>L: 检查前缀并确认
    end
    Note over L: 满足多数派及当前任期提交规则
    L->>M: 按日志顺序 Apply
    L-->>C: 提交与应用完成后响应
    L->>F1: 后续 AppendEntries 携带 commitIndex
    L->>F2: 后续 AppendEntries 携带 commitIndex

Follower 接收 AppendEntries 时,使用前置日志的任期与索引进行前缀一致性检查。匹配之后才能追加新日志;遇到冲突时,依照 Leader 的日志修复冲突后缀。复制成功不一定意味着已经提交,提交(Committed)与状态机应用(Applied)也并非同一步骤。

Raft 有一个容易被简化掉的安全条件:Leader 在判断提交进度时,不能任意仅靠副本计数直接提交以前任期的日志;常规算法要求对当前任期日志形成多数派提交,然后才间接使此前日志确定提交。工程实现中,matchIndex、commitIndex、lastApplied 表示的是不同阶段,排障时必须分别观察。

状态机本身要求确定性。例如节点 A、B、C 在同一个初始状态下,严格按照同一条日志顺序执行 SET x=7,最终应该得到相同结果。若状态机中直接使用各节点本地时钟、随机数或未被日志固定的外部查询结果,就可能即使日志相同也产生数据分歧。

为什么“只查询 Leader”还不够

网络分区可能出现旧 Leader 暂时认为自己仍是 Leader,而多数派已经选出新 Leader 的情况。旧 Leader 本地仍有某个旧值;它若不经校验直接响应读请求,仍会造成过期读取。因此,Raft 的共识能力与具体 API 的线性一致读实现,是两层设计。

对于严格一致的读,实现通常需要确认当前 Leader 仍与有效多数派保持关系(如 ReadIndex、合法租约或等效的 Leader 验证机制),并确保相关已提交日志已经应用到本地 FSM。VerifyLeader()、Barrier() 是 HashiCorp Raft 暴露的相关构件,但需要结合具体库版本与请求顺序设计;单独调用一个方法,并不能自动替代完整的线性一致读证明。

对于配置中心,读取的是安全开关、权限规则还是应用启动参数,会直接影响对这一层保证的要求。不能把所有 GET 都机械视为普通内存查询。

HashiCorp Raft:从协议概念走到 Go 代码

HashiCorp Raft 是可嵌入应用的 Go 共识库,负责节点角色、选举、复制日志与成员变更;它不是开箱即用的 HTTP KV 数据库。业务命令的含义、FSM 的存储方式、外部 API 与读一致性策略,仍由应用自己实现。

这里分析的主要代码路径对应教材采用的 HashiCorp Raft v1.1.1(2020 年)。理解这些路径很有帮助,但实际升级依赖时,应以锁定版本的源码与 API 文档为准;历史代码中的函数签名、日志配置字段及第三方存储适配器不能未经核对直接复制到新工程。

以状态机为入口阅读源码

raft.go 中的 run() 可视为节点状态分发入口。在教材对应版本中,它将节点角色对应到三个主要处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (r *Raft) run() {
for {
select {
case <-r.shutdownCh:
r.setLeader("")
return
default:
}

switch r.getState() {
case Follower:
r.runFollower()
case Candidate:
r.runCandidate()
case Leader:
r.runLeader()
}
}
}

这段结构说明:选举和复制不是散落在任意业务方法里的逻辑,而是围绕节点状态组织的后台执行流程。想调查“节点为什么重新选举”,从 runFollower() 的超时判断入手;想调查“为何迟迟无法成为 Leader”,查看 runCandidate() 的投票处理;想调查“日志复制卡在哪里”,则从 runLeader() 继续追踪。

state.go 中的 RaftState、raftState 则体现了重要状态之间的区别:

字段或概念 作用 排障时关注的问题
currentTerm 当前任期 是否因网络抖动频繁增加?
commitIndex 已确定提交日志的最高索引 多数派是否形成了提交进度?
lastApplied 已应用到状态机的最高索引 是否发生 FSM 应用落后?
lastLogIndex / lastLogTerm 本地最后一条日志的位置和任期 节点是否落后、能否参与选举?
Follower / Candidate / Leader Raft 运行角色 当前是否有稳定 Leader?

从数据结构反推 RPC 语义

commands.go 中的 AppendEntriesRequest 可以直接与 Raft 论文的日志一致性检查关联起来:

1
2
3
4
5
6
7
8
type AppendEntriesRequest struct {
Term uint64
Leader []byte
PrevLogEntry uint64
PrevLogTerm uint64
Entries []*Log
LeaderCommitIndex uint64
}

PrevLogEntry 与 PrevLogTerm 是校验日志前缀的基础,Entries 承载新增日志,LeaderCommitIndex 告诉 Follower Leader 已经确认的提交位置;同一个 RPC 在没有新增日志时还可以承担心跳用途。

log.go 中,日志项除了 Index、Term、Data,还可以包含 Type 与 Extensions。其中 LogCommand 是普通业务命令,LogConfiguration 可用于配置变更。这解释了为什么添加或移除节点不能仅仅修改一份各自维护的配置文件:成员变更本身也需要经过集群认可的顺序。

复制路径与流水线优化

教材中的 Leader 侧路径可以概括为:

1
2
3
4
5
runLeader()
└─ startStopReplication()
└─ replicate()
├─ replicateTo() # 检查日志一致性并复制
└─ pipelineReplicate() # 正常运行时流水线复制

Follower 侧对应:

1
2
3
4
5
6
runFollower()
└─ processRPC()
└─ appendEntries()
├─ 校验前置日志索引和任期
├─ 持久化新增日志
└─ 根据提交索引推动 FSM 应用

流水线复制(Pipelining)主要解决每发送一批日志都等待响应而产生的吞吐限制。当日志前缀已经对齐、复制处于正常状态时,Leader 可以提高连续日志发送效率;发生冲突或异常时回到需要检查与修复的路径。它属于传输性能优化,不能跳过日志匹配和提交安全规则。

NewRaft 的六个依赖

在教材分析的 API 中,一个 Raft 节点由下列构件组装:

1
2
3
4
5
6
7
8
func NewRaft(
conf *Config,
fsm FSM,
logs LogStore,
stable StableStore,
snaps SnapshotStore,
trans Transport,
) (*Raft, error)
构件 责任 教材中的典型实现
Config 节点 ID、心跳/选举相关参数、协议配置 DefaultConfig() 后按需修改
FSM 已提交命令的应用、快照生成及恢复 应用自行实现接口
LogStore 持久化 Raft 日志 raft-boltdb
StableStore 持久化当前任期、投票等稳定状态 raft-boltdb
SnapshotStore 保存状态机快照、辅助恢复及日志压缩 FileSnapshotStore
Transport 集群内部 RPC 通道 TCPTransport 或 InmemTransport

FSM 的接口重点是 Apply(*Log)、Snapshot()、Restore(io.ReadCloser)。Apply 不能随意产生不确定性;Snapshot 要获得一致的状态视图;Restore 应能从快照重建完整状态。快照保存的是状态机基线,但并不代表稳定存储与剩余 Raft 日志可以随意删除。

内存型的 InmemTransport、InmemStore 适合单机运行多节点测试;面向跨服务器部署时,则需要网络传输和可靠磁盘存储。丢失稳定状态或将投票记录放在易失内存里,可能破坏重启后的选举安全性。日志存储与快照存储的路径、权限和数据备份同样需要纳入运维管理。

以“节点生命周期”理解常用 API

API 不需要按字母表来记,而应该沿着节点生命周期来掌握。

生命周期 主要 API 实际含义及使用约束
构造节点 NewRaft(...) 连接配置、FSM、三类存储和网络传输
首次建群 BootstrapCluster(configuration) 仅用于尚未初始化的集群;不应在每次重启时调用
增加投票节点 AddVoter(id, address, prevIndex, timeout) 由 Leader 提交成员配置变更
增加非投票节点 AddNonvoter(...) 可同步日志但不参加多数派投票
移除节点 RemoveServer(id, prevIndex, timeout) 由 Leader 执行配置变更
查看 Leader Leader() 获取当前已知 Leader 地址,不代表永久不变
查看角色 State()、RaftState.String() 判断节点处于 Follower、Candidate 还是 Leader
查看统计 Stats() 查看任期、提交进度及其他内部统计
提交业务命令 Apply(data, timeout) 将命令交给 Raft 复制并应用
验证 Leader VerifyLeader() 严格读策略可使用的验证构件之一

AddPeer()、RemovePeer() 属于早期废弃 API,不能作为新实现的默认选项。节点替换通常要考虑新节点的数据同步、成员配置变更以及旧节点移除,不能把“机器退出机架”误认为“投票成员已经被集群正式删除”。

一个容易踩的坑是多数派数量发生变化。例如三节点集群容忍一台投票节点故障;如果不经过合适的成员变更流程直接扩到四个投票节点,多数派阈值会从 2 变成 3,但它能够容忍的故障节点数依然只有 1。扩容与提高容错能力不是同一个动作。

用 Raft 设计一个分布式 KV 系统

学习算法时,最能检验理解程度的方式不是背出函数名,而是完整追踪一次用户请求:它从哪个网络入口进入、如何变成复制日志、何时到达状态机、由谁响应、发生故障时能否安全重试。一个精简的 KV 系统很适合作为验证这些问题的载体。

从架构上拆分接入、共识与业务状态

系统可以分为三部分:接入协议、KV 操作、分布式集群。三者的职责必须清晰分开。

flowchart LR
    C[客户端 curl / SDK] --> HTTP[HTTP API]
    HTTP --> R{请求类型}
    R -->|POST / DELETE| W[Store.Set / Store.Delete]
    R -->|GET| Q[Store.Get]
    HTTP -->|POST /join| J[集群管理]
    W --> A[Raft.Apply]
    J --> V[Raft.AddVoter]
    A --> L[Raft 日志及多数派复制]
    L --> FSM[确定性 FSM.Apply]
    FSM --> KV[(内存 KV 状态)]
    Q --> RC[读一致性判断]
    RC --> KV
    L --- DISK[(LogStore / StableStore / SnapshotStore)]

接入层使用 HTTP 是为了方便调试、减少私有协议开发成本。KV 业务层实现 Set、Get、Delete;Raft 层负责复制有序命令,而不负责理解“设置键值”这一业务行为。教材中的 FSM 使用 map[string]string 保存内存中的最终状态,重要数据则依赖 Raft 的持久化日志与快照恢复。

REST API 与命令进入 FSM 的路径

一个简化的接口设计如下:

方法和路径 功能 是否需要 Raft 共识
POST /key 写入一组键值 是,产生 SET 命令
GET /key/{key} 查询一个键 取决于请求的一致性等级
DELETE /key/{key} 删除一个键 是,产生 DELETE 命令
POST /join 增加投票成员 是,属于集群配置变更

对应的教材调用路径是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /key
→ ServeHTTP()
→ handleKeyRequest()
→ Store.Set()
→ raft.Apply()
→ FSM.Apply()
→ applySet()
→ 更新 Store.m

DELETE /key/{key}
→ handleKeyRequest()
→ Store.Delete()
→ raft.Apply()
→ FSM.Apply()
→ applyDelete()

GET /key/{key}
→ handleKeyRequest()
→ Store.Get()
→ 按读一致性级别进行校验或直接读取本地 KV

在路由实现上,可以根据 /key 前缀匹配 KV 路径,对 /join 采用完整路径匹配。实际工程需要更严格地处理边界:单纯 strings.HasPrefix(path, "/key") 还会错误匹配 /keyboard;至少要区分 /key 与 /key/,同时校验 HTTP 方法、路径参数、请求体和错误码。

源示例中的基本访问方式是:

1
2
3
4
5
6
7
8
9
# 写入 foo=bar
curl -X POST 'http://raft-cluster-host01:8091/key' \
-d '{"foo":"bar"}'

# 查询
curl -X GET 'http://raft-cluster-host01:8091/key/foo'

# 删除
curl -X DELETE 'http://raft-cluster-host01:8091/key/foo'

这些是用于说明接口契约的调用示例;raft-cluster-host01 是示例主机名,需要在实际环境替换为已经部署的节点地址。

Leader 地址变化后的两种写请求策略

写命令必须交给当前能够提交日志的 Leader。Follower 收到写请求时可以选择两种实现:直接转发给 Leader,或者把 Leader 地址返回给客户端,让客户端重新连接。

方式 好处 代价
Follower 代理转发 客户端不需要关心集群内部 Leader 的位置 多一跳网络路径;追踪、超时与错误处理更复杂
返回 Leader 地址并重定向 后续客户端可直连 Leader;减少中间节点负担 客户端需要支持重定向;Leader 变更时需刷新地址

教材最终采用了返回地址并进行 HTTP 307 Temporary Redirect 的方式。307 会要求客户端在重定向后保持原 HTTP 方法及请求体语义,适合重定向写请求。

sequenceDiagram
    autonumber
    participant C as HTTP Client
    participant F as Follower
    participant L as Leader
    C->>F: POST /key
    F-->>C: 307 + Location: Leader URL
    C->>L: POST /key (原请求体)
    L->>L: Raft.Apply 并等待命令执行结果
    L-->>C: 写入结果
1
2
curl -L -X POST 'http://raft-cluster-host02:8091/key' \
-d '{"foo":"bar"}'

真实接口还要考虑:没有 Leader 时应返回可重试错误,而不能构造空跳转地址;客户端应限制最大重定向次数;跨主机重定向要防止凭证泄露;超时重试需要请求幂等保护,不能仅靠 HTTP 状态码判断写入绝对没有发生。

stale、default 与 consistent 读语义

多种读一致性级别,是把性能与一致性的权衡显式暴露给客户端的一种设计。教材借鉴了 Consul 风格,提出三类读策略:

读等级 意图 可以从哪里读取 风险与代价
stale 优先读取速度与可扩展性 允许本地 Follower 直接读 副本可能落后,得到旧数据
default 以 Leader 为主、降低额外一致性验证成本 通常由 Leader 服务 若误把旧 Leader 当成新 Leader,仍可能读旧数据
consistent 保证满足约定的最新读语义 有效 Leader,且需要完成必要读屏障 多一次校验、等待或多数派通信,延迟更高
1
curl -L 'http://raft-cluster-host02:8091/key/foo?level=consistent'

实现时要把请求级别传进 Store.Get(key, level) 或相应的业务读取入口。在 consistent 分支,Follower 可以返回 Leader 地址;Leader 再按库版本完成合法性验证及应用进度屏障。default、consistent、stale 是示例服务约定,不是 Raft 协议本身强制定义的三个级别。

为什么共识日志不能替代幂等设计

假设客户端发送了 INCR points 100,请求已被提交,但响应在网络中丢失。客户端超时后重试,可能产生第二条内容相同、索引不同的 Raft 日志。Raft 会保证每个已提交日志位置的命令顺序,但不会自动知道这两条命令源自同一个业务请求。

因此,SET x=7 天然比较容易实现重试安全,INCR x、扣款和新增订单则需要显式处理请求去重。例如在状态机中保存业务 requestId 的处理结果,并将“检查请求是否执行过、执行变更、记录结果”作为同一条确定性的状态机操作;重试时直接返回已有结果。仅在 HTTP 层加内存去重缓存,节点切换后并不足以保障正确性。

集群启动、成员变更与快照恢复

第一次创建集群时,可以由首个节点执行 BootstrapCluster。后续节点正常启动自己的 Raft 实例,通过 /join 将其节点 ID 和 Raft 通信地址交给 Leader,再由 Leader 调用 AddVoter 提交成员变更。Bootstrap 不是每个节点启动时都要执行的初始化步骤。

运行中的常规流程还包括:日志增长到一定程度时生成状态机快照;新节点可以通过快照加剩余日志追赶;节点失效时需要在多数派仍可工作的条件下进行成员变更与替换。对配置中心这类规模相对小的状态数据,内存 FSM 很容易理解;数据体积扩大后,则需要讨论热数据放内存、较少访问的数据放 SSD、低频数据下沉普通磁盘等分层存储策略。

这里不能忽略恢复测试:重新启动后的结果应该由持久化的 Raft 稳定状态、日志与快照重建,而不是依赖上一次进程的内存还在。

ZAB 与 ZooKeeper:操作顺序、故障恢复和读写请求

Raft 的算法实现相对容易与具体业务解耦,而 **ZAB(ZooKeeper Atomic Broadcast)**是在 ZooKeeper 的实际需求下发展起来的原子广播协议。两者都使用 Leader 主导有序操作,但 ZAB 的选举、领导关系确立与恢复流程更紧密地融入了 ZooKeeper 的服务器代码。

如果把 ZAB 只理解为“选一个 Leader,然后把命令发送给 Follower”,就无法解释为什么一个候选节点已经被选举成功,却仍不能立即接收写请求;也无法解释为什么部分事务日志会被截断,另一些日志却能够保证不丢失。

三种成员身份、四种服务器状态与四个协议阶段

ZooKeeper 的服务角色包括:

成员身份 对应服务器状态 参与选举 参与提交投票 客户端请求
Leader LEADING 可以 可以 处理写事务、提供读服务
Follower FOLLOWING 可以 可以 转发写请求、本地处理普通读请求
Observer OBSERVING 不参与 不参与 提供读服务并转发写请求,不增加投票多数派规模

另一个服务器状态 LOOKING 表示当前节点正在选举 Leader。因此 LOOKING 不是第四种业务角色,而是一个过渡状态。

ZAB 的协议恢复过程又可以表示为四个阶段:

协议阶段 目标 关键机制
ELECTION 选出最合适的新 Leader 快速领导者选举、投票比较
DISCOVERY 确认新 Leader 的合法性及任期 FOLLOWERINFO / LEADERINFO / ACKEPOCH
SYNCHRONIZATION 消除不同节点的日志冲突与落后 DIFF、TRUNC、SNAP、NEWLEADER
BROADCAST 恢复正常事务广播 PROPOSAL、ACK、COMMIT

这两组状态不应混为一谈:一组描述节点目前扮演哪个角色,另一组描述 ZAB 协议当前在做哪类工作。源码中要分别寻找它们的切换时机。

Leader 崩溃后,怎样挑出新的主节点?

快速领导者选举(Fast Leader Election,FLE)强调选择拥有较新事务历史的节点,而不是简单选择启动速度最快或服务器 ID 最大的节点。

ZAB 通过事务标识 zxid 表达顺序。它可以理解为由 Leader 的 Epoch(任期相关标识)和事务序列组成的单调标识:Epoch 解决不同领导时代之间的隔离,序列标识在同一时期内区分事务先后。

为便于理解,设有 A、B、C 三台投票节点,原 Leader A 宕机;B 最新事务编号为 101,C 最新事务编号为 102,二者所处 Leader Epoch 相同。B、C 初始都投自己,在收到对方的选票后,依次比较:

  1. 候选 Leader 对应的 Epoch,较大者优先;
  2. Epoch 相同时,比较最新 zxid,较大者优先;
  3. 两者都相同,再比较服务器 ID,较大者优先。

于是 B 发现 C 的事务历史更新,改为投 C;C 继续投自己。最终 C 得到多数票,当选 Leader。

sequenceDiagram
    autonumber
    participant B as Follower B
    participant C as Follower C
    Note over B,C: 原 Leader A 故障,B 和 C 进入 LOOKING
    B->>C: 投票 B (epoch=1,zxid=101)
    C->>B: 投票 C (epoch=1,zxid=102)
    Note over B: 比较 Epoch / zxid / Server ID
    B->>C: 更新推荐为 C
    Note over B,C: C 获多数选票
    B->>B: FOLLOWING
    C->>C: LEADING

教材用“各节点不断推荐更合适候选人”的方式解释这一过程。实际实现还要考虑投票所属的选举轮次 logicalclock:老轮次投票不能覆盖新轮次结果。只比较候选者的三个数值而忽略选举轮次与法定多数,是不完整的算法。

ZooKeeper 3.6.0 对应源码的主要入口包括:

1
2
3
4
5
6
7
8
9
10
11
Follower.followLeader()
└─ readPacket() / processPacket()
└─ 连接异常或读取超时
└─ 更新服务器状态为 LOOKING
└─ FastLeaderElection.lookForLeader()
├─ logicalclock 增加
├─ updateProposal() 建议候选人
├─ sendNotifications() 广播选票
├─ recvqueue.poll() 收取投票
├─ totalOrderPredicate() 比较候选人
└─ 检测多数票并退出选举

**选举成功不代表故障恢复完成。**目前只解决了“谁最适合当 Leader”,还没有确保多数节点认可这一任期及其日志历史。因此接下来需要 Discovery 和 Synchronization。

成员发现:为什么新 Leader 必须重新确认 Epoch?

新 Leader 当选后,需要在 DISCOVERY 阶段与多数 Follower 建立领导关系。Follower 主动连接 Leader,带上此前接受的领导任期信息;Leader 为新领导时代准备新的 Epoch,返回对应的 Leader 信息;Follower 验证新任期合法后响应确认。只有形成多数认可,集群才进入数据同步阶段。

sequenceDiagram
    autonumber
    participant F as Follower B
    participant L as 新 Leader C
    Note over F,L: ELECTION 结束
    F->>L: FOLLOWERINFO (acceptedEpoch)
    L->>L: 为新任期建立更高 Epoch
    L-->>F: LEADERINFO (新 Epoch 信息)
    F->>F: 判断任期是否有效
    F->>L: ACKEPOCH (本地日志状态)
    Note over L: 等待多数 ACKEPOCH
    Note over F,L: 进入 SYNCHRONIZATION

这一步解决的是旧 Leader 与新 Leader 的领导权隔离。否则即使各节点投票得到了相同结果,仍可能因为已接受任期不同而让旧消息参与新一轮提交。

源码入口可沿 Follower.followLeader()、registerWithLeader()、LearnerHandler.run()、Leader.lead() 和 waitForEpochAck() 跟踪。在这里,LEADERINFO 与 NEWLEADER 并不是同一个消息:前者用于确认新 Epoch,后者属于随后的同步与领导关系建立过程。

数据同步:DIFF、TRUNC 与 SNAP

新 Leader 建立任期后,还要让 Follower 的日志与其确定的合法历史一致。根据双方的差异与日志保留范围,ZAB 使用三种典型策略:

同步方式 节点状态示例 主要动作 工程意义
DIFF Follower 落后,但差异日志仍可取得 补发缺失事务 避免传输完整状态
TRUNC Follower 存在与新 Leader 历史冲突的尾部 截断冲突后缀 排除未形成合法共识的分支
SNAP Follower 落后很久,无法通过保留的差异日志追赶 传输快照及后续数据 为较大差异提供恢复基线

教材在 syncFollower() 中通过 peerLastZxid、minCommittedLog 与 maxCommittedLog 展示这三类判断。minCommittedLog 和 maxCommittedLog 表示 Leader 内存里可用于快速同步的已提交事务范围。它们是理解同步策略的重要变量,但不能把“一个值大于另一个值”当作所有版本、所有异常情况下的完整判定逻辑;实际代码还涉及日志历史是否匹配、快照状态以及事务队列等条件。

flowchart TD
    A[新 Leader 确认领导关系] --> B[获取 Follower 的最新 zxid]
    B --> C{双方日志历史能否直接衔接}
    C -->|Follower 缺少近期事务| D[DIFF 追加差异事务]
    C -->|Follower 有冲突尾部| E[TRUNC 截断冲突后缀]
    C -->|差异日志不足以恢复| F[SNAP 传输状态快照]
    D --> G[同步合法事务与必要的待处理队列]
    E --> G
    F --> G
    G --> H[发送 NEWLEADER]
    H --> I[多数 ACK 确认]
    I --> J[发送 UPTODATE]
    J --> K[进入 BROADCAST]

在代码中,Leader 通过 LearnerHandler.run() 调用 syncFollower() 选择同步方案,并借助待发送队列与 startSendingPackets() 传输。Follower 使用 syncWithLeader() 处理数据,收到 NEWLEADER 后应答 ACK;Leader 收到多数确认后进入广播状态并发送 UPTODATE。只有完整走完这条链路,集群才算完成领导切换后的恢复。

还需要把日志在本地的状态与协议意义上的多数派提交分开。教材指出,在其分析的 ZooKeeper 版本中,Follower 退出正常跟随阶段时可能在本地处理某些尚未形成多数派提交的提案。这些本地状态在新 Leader 同步时仍可能被 TRUNC 删除;不能仅凭本地某处的“Committed”标记,就断言该提案已经获得可对客户端承诺的、不可撤销的多数派提交保证。真正的安全边界来自协议认可的法定多数及其在新领导时代的保留规则。

正常运行时:写请求怎样被有序提交?

在 BROADCAST 阶段,普通事务写入从任意可接入节点开始,但实际提案生成与提交由 Leader 统一处理。Follower 接收到写请求时要转交 Leader,Leader 分配 zxid、持久化与广播提案,Follower 持久化后返回 ACK,Leader 得到多数确认后广播 COMMIT,各节点再按照顺序应用事务。

sequenceDiagram
    autonumber
    participant C as Client
    participant F as ZooKeeper Follower
    participant L as ZooKeeper Leader
    participant O as 其他投票节点
    C->>F: 写请求 create /config
    F->>L: 转发写请求
    L->>L: 生成事务 / zxid / 持久化
    L->>F: PROPOSAL
    L->>O: PROPOSAL
    F->>F: 同步事务日志
    F-->>L: ACK
    O-->>L: ACK
    Note over L: 收到法定多数 ACK
    L->>F: COMMIT
    L->>O: COMMIT
    L->>L: 顺序应用事务
    F->>F: 顺序应用事务
    F-->>C: 返回执行结果

在 ZooKeeper 3.6.0 源码的请求处理链中,Leader 和 Follower 的第一处理器不同。它们共享“处理请求”这个接口,却承担不同职责:

链路 主处理链(简化) 持久化及确认链
Leader LeaderRequestProcessor → PrepRequestProcessor → ProposalRequestProcessor → CommitProcessor → ToBeAppliedRequestProcessor → FinalRequestProcessor SyncRequestProcessor → AckRequestProcessor
Follower FollowerRequestProcessor → CommitProcessor → FinalRequestProcessor SyncRequestProcessor → SendAckRequestProcessor

关键代码位置包括 FollowerRequestProcessor.run() 转发请求、ProposalRequestProcessor.processRequest() 广播提案、Follower.processPacket() 接收提案、Leader.processAck() 处理多数响应,以及 FollowerZooKeeperServer.commit() 按顺序提交。Follower 对 pendingTxns 的处理会检查待提交事务的 zxid 是否符合下一个预期值,体现了协议对全序应用的要求。

这也是为什么不能直接把 ZAB 的“Leader 广播 + Follower ACK”称为普通数据库的两阶段提交事务:两者虽然都包含确认步骤,但解决的分别是复制日志的原子广播与顺序和多个资源分支的全局提交决策,所用恢复规则并不相同。

读操作为什么可以快,但不一定最新?

ZooKeeper 的普通 getData 请求可以直接读取本地 DataTree。教材中的处理流程是 Follower 接收读请求,经 FinalRequestProcessor.processRequest() 查本地数据,再通过连接对象返回客户端,而不必将每次读都广播到多数派。

1
2
3
4
5
FollowerRequestProcessor.processRequest()
→ CommitProcessor
→ FinalRequestProcessor.processRequest()
→ DataTree 本地读取
→ cnxn.sendResponse(...)

这让读能力可以利用更多服务节点横向扩展,但在 Follower 尚未收到最新已提交事务时,读到的数据可能比其他节点落后。ZooKeeper 提供的并非“任意节点读都线性一致”;它具有更新的顺序性、单会话视图等保证,不能简单将其等同于一个只追求可用性的任意 AP 存储。

对于处于 LOOKING 状态、正在寻找 Leader 的失联节点,教材所分析的正常 ZooKeeper 服务模式不会继续对外提供有效的读写服务。它和一个即使网络分区、仍允许孤立副本自由接收写入的 AP 系统有本质差异。

如果业务要求在不同客户端之间严格读取刚刚写入的结果,不能仅靠“改为连接 Leader”就认定问题解决;还要检查实际版本所支持的同步和读取语义。ZooKeeper 文档通常建议在特定读取场景使用 sync() 辅助刷新视图,但官方对严格线性一致性也提出了边界:普通 sync() 本身不是完整的多数派读操作。需要严格保证时,应使用满足所需语义的屏障、写操作或更强的读取协议,并按实际版本验证。

ZAB 与 Raft:相似的日志复制,不同的工程形态

比较维度 Raft ZAB / ZooKeeper
Leader 选择 候选人请求投票;任期与日志新旧约束 快速选举;比较 Epoch、zxid、服务器 ID
故障恢复 通过新任期、Leader 合法性及日志对齐恢复 显式经历 Election、Discovery、Synchronization、Broadcast
日志顺序 Leader 分配日志顺序;严格按提交顺序应用 Leader 分配 zxid;原子广播并按事务顺序应用
写入路径 Leader 负责复制提交 Leader 生成提案与提交;Follower 可转发写入
读取语义 由嵌入该库的应用定义;可实现线性一致读 普通读由本地服务节点处理,可能读取旧值
观察者 可根据实现支持非投票成员 原生支持 Observer 角色
二次开发 HashiCorp Raft 可以作为独立库嵌入 ZAB 实现与 ZooKeeper 代码紧密结合

ZAB 的价值不是它比 Raft 更强或更弱,而是它证明了一件事:保证有序复制只是故障恢复的必要部分,新领导时代的合法性、日志冲突修复与客户端读语义同样属于系统设计。

InfluxDB Enterprise:为什么一个系统会同时需要不同的一致性机制?

一个真实的分布式系统不一定只能选择 CP 或 AP 中的一个方向。以 InfluxDB Enterprise v1 的集群架构为例,同一个产品中,元数据控制面和时序数据存储面面对的压力与一致性要求不同,因此采用了不同的复制与恢复方案。

META 与 DATA 为什么要拆开?

时序数据记录的是随时间变化的指标,如设备温度、CPU 利用率、服务延迟或监控事件。它通常写入频繁、数据总量很大,并且查询会跨越大量时间区间和数据分片。

在时序库里,数据点本身也说明了为什么复制策略需要考虑分片与时间维度。例如 InfluxDB v1 的行协议可以用如下形式写入一次监控值:

1
cpu_usage,host=server01,location=cn-sz user=23.0,system=57.0

其中 cpu_usage 是 Measurement,host、location 是 Tag,user、system 是 Field;时间戳可由写入端显式提供,也可由服务端在未提供时生成。大量这样的时间序列点会按数据库保留策略与 Shard 分布到 DATA 节点。

InfluxDB Enterprise v1 将职责划分为:

组件 持有的信息 主要目标 更关注的资源
META 节点 数据库、保留策略、Shard 分配、集群成员、权限与相关元数据 可靠一致的元数据视图 多数派容错、稳定性
DATA 节点 Measurements、Tags、Fields、实际时序点及其 Shard 数据 高吞吐写入、查询、数据复制与扩容 存储、网络吞吐、水平扩展

META 数据量相对有限,但发生冲突时影响整个集群的路由与管理;DATA 数据量大,需要根据分片和复制因子将数据分布到更多物理节点。META 使用 Raft 协调关键元数据,DATA 则依赖分片复制、写入一致性策略和异步修复机制。

flowchart TD
    C[监控服务与业务客户端] --> M[查询元数据 / 分片映射]
    C --> W[写入或查询时序数据]
    subgraph META[元数据控制面]
      M1[META A]
      M2[META B]
      M3[META C]
      M1 <-->|Raft 共识| M2
      M2 <-->|Raft 共识| M3
      M1 <-->|Raft 共识| M3
    end
    M --> META
    subgraph DATA[时序数据平面]
      D1[DATA A: Shard 副本]
      D2[DATA B: Shard 副本]
      D3[DATA C: 其他 Shard]
      D1 <-->|复制 / 修复| D2
    end
    META -->|提供 Shard 和副本布局| DATA
    W --> DATA
    D1 -.-> HH[Hinted Handoff 磁盘队列]
    HH -.-> D2
    D1 -.-> AE[Anti-Entropy 检测和修复]
    AE -.-> D2

这个拆分带来一项实际收益:不必为了扩展 DATA 读写吞吐,而把 META Raft 投票成员也扩大到同样数量。多数派成员过多会增加选举、复制和故障恢复的协调成本;DATA 节点数量则更多由分片规模、读写负载及故障域决定。

上述设计对应 InfluxDB Enterprise v1。不能直接推广为所有 InfluxDB 版本、其他产品形态或现今全部部署模式都具有相同的 META/DATA 进程划分。

副本数不是 Raft 投票节点数

对于普通时序数据,一个 Shard 可以有多个副本,复制因子不必等于整个集群的 DATA 节点数。这与一组固定投票成员共同维护一份 Raft 日志的场景不同。

例如五个 DATA 节点可以承载许多不同 Shard,每个 Shard 只分布到其中部分节点上。这样系统才能同时控制副本存储成本、故障容忍度和数据分布策略。但它也引出了现实问题:某一份副本暂时无法写入时,客户端请求应该失败,还是可以先成功、以后再把数据补给落后节点?

Hinted Handoff:把短暂的不可用变成可恢复的写入积压

Hinted Handoff(提示移交,HH)针对的是副本节点暂时不可达:某个 DATA 节点接收到写请求后,按照 Shard 布局向其他副本复制;如果远端节点故障或网络请求失败,接入节点可以将需要送往该副本的数据持久化到本地磁盘队列,并在之后进行重试。

sequenceDiagram
    autonumber
    participant C as 监控客户端
    participant A as DATA A
    participant B as DATA B
    participant H as 本地 Hinted Handoff 队列
    C->>A: 写入时间序列数据
    A->>B: 尝试写入副本
    B--xA: 节点故障 / RPC 失败
    A->>H: 持久化待转发数据
    Note over A,C: 是否向客户端报成功取决于写一致性级别
    loop 周期重试
        H->>B: 转发待补写数据
    end
    B-->>H: 确认持久化
    H->>H: 清理完成的队列项

在 Enterprise v1 的配置中,[hinted-handoff] 相关参数包括磁盘目录、max-size、max-age 和重试相关设置。官方文档列出的 max-size 是针对目标 DATA 节点的队列容量限制,max-age 用于限制队列数据保留时间;不能假设磁盘队列无限增长,也不能假设远端离线多久都不会丢数据。

HH 不能提供无条件的永久可靠性。若写失败持续时间太长、队列达到上限、数据超过保留期限或者暂存节点自己故障,部分缺失副本仍可能无法靠重试补齐。此外,远端节点恢复后必须同时承受正常写流量与历史积压回放,恢复阶段反而可能出现写入压力峰值。

Anti-Entropy:为什么还需要反熵修复?

Hinted Handoff 更像“尽可能可靠地补寄失败的写请求”。**反熵(Anti-Entropy)**则针对“两个本应相同的副本,现在实际内容不一致”的问题,通过检查数据摘要、识别不同步的 Shard 并执行必要的副本修复,让集群逐渐收敛。

时序监控场景有一个直观故障:同一时间范围的指标,在 DATA A 上查询是一条完整曲线,在 DATA B 上查询却少一段。这会造成监控仪表盘随着查询路由变化而产生不同显示结果,即 Flapping Dashboard。它不仅是视觉问题,还可能影响报警、容量判断和故障分析。

反熵的典型过程可概括为:

flowchart LR
    S[副本 Shard A] --> D1[生成或更新摘要 Digest]
    T[副本 Shard B] --> D2[生成或更新摘要 Digest]
    D1 --> CMP{摘要或 Shard 状态是否一致}
    D2 --> CMP
    CMP -->|一致| OK[无需修复]
    CMP -->|缺失或不一致| Q[加入修复队列]
    Q --> SYNC[复制必要 Shard 或差异数据]
    SYNC --> VERIFY[复查一致性]

需要准确理解它的边界:反熵依赖至少存在一个可用、正确的源副本。如果唯一副本已经丢失,反熵无法凭空恢复数据;如果数据支持更新、删除或冲突写入,修复也不能简单地把“所有能找到的旧数据合并起来”当作正确答案,还必须遵循具体的数据版本与冲突处理规则。

更重要的是,Enterprise v1 的反熵并非在所有版本、所有安装中默认自动完成全部修复。官方文档说明:从 1.7.2 开始,反熵服务默认关闭;缺失 Shard 可以由已启用的反熵服务自动补齐,而发现内容不一致的 Shard 可能需要人工排入修复队列。对此不能只理解为“安装后会自动修复一切”。

面向 Enterprise v1 的排障命令包括:

1
2
3
4
5
# 查看反熵发现的异常 Shard 与修复队列
influxd-ctl entropy show

# 将确认需要修复的 Shard 加入队列
influxd-ctl entropy repair <shardID>

实际执行前需要确认对应版本与部署已启用 [anti-entropy],并评估修复期间对磁盘、CPU、网络以及活跃 Shard 写入的影响。官方还指出,活跃 Shard 的修复存在额外限制,不能简单地对所有线上分片同时执行修复。

Quorum NWR:读写一致性如何由请求路径决定?

在多副本方案中,常用 N 表示副本总数,W 表示写成功必须获得的副本确认数,R 表示一次读需要参与并参与判定的副本数。

如果希望每个读集合与此前成功的写集合必有交集,经典的基本条件为:

[
R + W > N
]

例如 N=3, W=2, R=2,任何成功写入的两个副本和读取的两个副本必然至少共享一个副本;若业务极端读多写少,还可考虑 N=3, W=3, R=1,以牺牲写可用性换取单副本读路径的速度。

N / W / R 示例 写请求要求 读请求要求 取舍
3 / 1 / 1 一份成功即可 一份应答即可 可用性高,但容易读旧副本
3 / 2 / 2 两份成功 比较两份结果 读写都需要更多确认
3 / 3 / 1 所有副本成功 可读取单副本 写对故障敏感,读路径轻
3 / 2 / 1 两份成功 一份应答即可 读取集合可能没有交集,可能读旧

但 R + W > N 不是线性一致性的完整证明。它只有在读写正确完成、读集合正确选择最新版本、冲突更新有明确的顺序与版本规则、成员集固定或正确重配置等假设下,才能支撑相应的一致性推理。仅查询到任意一个有交集副本,而不按版本识别最新值,仍可能读到较旧数据;并发读写的实时顺序要求也需要额外机制保证。

在实际 InfluxDB Enterprise v1 中,写一致性可以通过 any、one、quorum、all 等级控制。它与抽象 NWR 模型有联系,却不能未经文档确认,就假定产品支持完全对称的任意 R/W 参数组合及其全部线性一致语义。对于生产系统,真正需要验证的是写成功到底意味着哪些副本已确认、查询从哪里读、重试和修复何时完成。

面向真实业务的架构结论

META 使用 Raft,是因为元数据操作规模相对有限,但必须避免不同节点对数据布局、配置和权限理解冲突;DATA 使用可配置副本、HH、反熵及写入等级,是因为吞吐、存储规模和故障恢复的经济成本更重要。

二者共同说明:正确的分布式架构不是先选一项“最强协议”再让所有流量迁就它,而是先识别数据的访问性质、故障成本和业务容忍范围,再在各层分别设计保证。

XA 与二阶段提交:跨数据库事务为什么会阻塞?

副本达成共识解决不了另一类常见业务问题:订单数据库更新成功,而账务数据库失败。两个数据库即使各自内部都有 Raft 或其他一致性机制,也不代表彼此对同一个业务事务做出了共同决定。

当多个支持事务的数据库都必须参与同一次原子提交时,可以考虑 XA 与二阶段提交(Two-Phase Commit,2PC)。

DTP 模型:AP、TM、RM 的责任边界

XA 是分布式事务处理中事务管理器(TM)与资源管理器(RM)交互的一组接口规范,并不等于“给 SQL 语句套上 XA 前缀就自动完成全局事务”。

  • AP(Application Program):应用程序,负责发起业务事务和具体数据操作。
  • TM(Transaction Manager):事务管理器,负责管理全局事务、记录决策并协调各个事务分支。
  • RM(Resource Manager):资源管理器,通常是数据库,负责准备、提交或回滚自己管理的事务分支。
flowchart TD
    AP[应用程序 AP] --> TM[事务管理器 TM]
    AP --> A[数据库 A: RM]
    AP --> B[数据库 B: RM]
    TM <-->|XA 协议接口| A
    TM <-->|XA 协议接口| B
    TM --> LOG[(持久化全局事务决策)]

这里有两个分离的动作:应用程序在 RM 上真正执行 UPDATE 或 INSERT;TM 决定这些已经执行但尚未最终确认的操作什么时候准备、提交或回滚。

2PC 的两阶段时序

典型流程分为:

Prepare(准备阶段):协调器通知所有 RM 执行事务分支并准备提交。RM 在完成必要的持久化、检查与资源保留后,返回能够提交或失败的结果。

Commit / Rollback(决策阶段):如果所有 RM 都准备成功,协调器持久化全局提交决策,然后通知各分支 COMMIT;只要有分支准备失败,通常就形成回滚决策,通知已参与分支撤销。

sequenceDiagram
    autonumber
    participant TM as 事务管理器 TM
    participant A as MySQL A
    participant B as MySQL B
    TM->>A: START / 数据操作 / END
    TM->>B: START / 数据操作 / END
    TM->>A: XA PREPARE
    A-->>TM: PREPARED
    TM->>B: XA PREPARE
    B-->>TM: PREPARED
    alt 全部分支准备成功
        TM->>TM: 持久化 COMMIT 决策
        TM->>A: XA COMMIT
        TM->>B: XA COMMIT
    else 任一分支无法准备
        TM->>TM: 持久化 ROLLBACK 决策
        TM->>A: XA ROLLBACK
        TM->>B: XA ROLLBACK
    end

最关键的一点在于:**PREPARED 不是最终提交。**已经准备好的分支可能持有锁与其他数据库资源,必须等待全局提交或回滚决定。如果协调器在这个阶段崩溃,而没有可靠的恢复机制,资源可能长期被占用,这就是 2PC 的典型阻塞代价。

MySQL XA 的事务状态与实际 SQL

MySQL 为 XA 提供了下列语句:

1
2
3
4
5
6
XA START xid;
-- 执行 SQL 数据变更
XA END xid;
XA PREPARE xid;
XA COMMIT xid;
-- 或者根据全局决策执行:XA ROLLBACK xid;

对应状态大致为:

stateDiagram-v2
    [*] --> ACTIVE: XA START
    ACTIVE --> IDLE: XA END
    IDLE --> PREPARED: XA PREPARE
    PREPARED --> COMMITTED: XA COMMIT
    PREPARED --> ROLLED_BACK: XA ROLLBACK
    IDLE --> COMMITTED: XA COMMIT ONE PHASE
    COMMITTED --> [*]
    ROLLED_BACK --> [*]

例如两个独立 MySQL 实例需要参加同一全局事务,事务协调器分配相同的全局事务标识 gtrid,但为不同分支使用不同的 bqual。下面的 SQL 分别在两个不同数据库连接执行,由外部 TM 控制共同决策,并不是在一个 MySQL 会话里顺序运行即可实现分布式事务。

数据库 A 上的分支:

1
2
3
4
5
6
XA START 'global-order-100', 'branch-A';
UPDATE executed_table
SET status = TRUE
WHERE id = 100;
XA END 'global-order-100', 'branch-A';
XA PREPARE 'global-order-100', 'branch-A';

数据库 B 上的分支:

1
2
3
4
5
XA START 'global-order-100', 'branch-B';
INSERT INTO operation_table (id, op)
VALUES (100, 'get-cdn-log');
XA END 'global-order-100', 'branch-B';
XA PREPARE 'global-order-100', 'branch-B';

当 TM 确认 A、B 都返回准备成功,并持久化提交决策后,分别执行:

1
2
3
4
5
-- 数据库 A 的独立连接
XA COMMIT 'global-order-100', 'branch-A';

-- 数据库 B 的独立连接
XA COMMIT 'global-order-100', 'branch-B';

若任一分支准备失败,则应根据协调器的统一回滚决策,对需要撤销的活动或已准备分支执行相应的 XA ROLLBACK。MySQL 对单分支还支持 XA COMMIT ... ONE PHASE,不能省略 XA END 后随意将其当作多 RM 的两阶段提交。

XID 在 MySQL 中由 gtrid、bqual 和可选 formatID 组成。同一全局事务中不同分支共享 gtrid,但 bqual 应不同。上述数据库表名及 SQL 代表一个示例场景,运行前需建立相应表、准备独立连接与事务管理器,并处理具体约束冲突及权限问题。

协调器故障后怎样恢复 Prepared 事务?

Prepared 的事务分支会在数据库中留下待决状态。需要在恢复时识别它,查询事务管理器持久化的全局决定,再执行补交或回滚。

1
2
3
4
5
-- 查询本 MySQL 实例处于 PREPARED 的 XA 分支
XA RECOVER;

-- 如 XID 存在二进制内容,可请求十六进制显示
XA RECOVER CONVERT XID;

MySQL 8.4 的文档规定,XA RECOVER 需要相应的 XA_RECOVER_ADMIN 权限。恢复程序必须辨认完整 XID,并防止多个协调器对同一全局事务给出冲突决策。遇到“某分支已提交、另一个分支网络超时”的情况,不能由于收到一个超时异常就随意向其余分支执行回滚;正确做法是查明全局决策并完成剩余分支,必要时等待恢复。

XA 能降低应用自行编写跨库补偿的成本,但代价同样明确:长事务可能持续锁定资源,故障恢复依赖事务协调器的可靠性,大量并发的准备与提交会增加数据库压力。对于高并发或跨多个无法支持 XA 的第三方业务接口,通常需要换一种设计。

此外,在 MySQL 的外部 XA 模型中,RM 主要由支持 XA 的事务引擎承担;MySQL 8.4 的外部 XA 事务支持以 InnoDB 为前提。不能假设使用任意存储引擎或任意 HTTP 服务,都天然参与 XA 协议。

TCC:把数据库层原子提交转化为业务层可恢复的操作

如果一个指令要求同时完成“生成网页截图、调用安全系统封禁域名、更新多个数据库”,只有最后一个操作适合直接使用数据库 XA。截图是文件资源,封禁是远程 API,数据库则是事务资源。它们无法天然参加同一个数据库事务。

TCC(Try-Confirm-Cancel)提供的办法,是把每个业务操作拆为资源预留、最终确认与释放撤销三个动作,让应用层协调这些动作。

Try、Confirm、Cancel 的实际含义

  • Try:检查条件并预留将来确认所需的资源,例如冻结余额、锁定座位、准备尚未生效的配置。
  • Confirm:在所有必要 Try 都成功后,执行最终业务确认,例如真正扣款、出票或启用配置。
  • Cancel:如果无法继续提交,释放 Try 预留的资源并撤销其可逆影响。
flowchart TD
    S[接收全局业务指令] --> T1[Try 资源 A]
    T1 --> T2[Try 资源 B]
    T2 --> T3[Try 资源 C]
    T3 --> OK{全部 Try 成功?}
    OK -->|是| DEC1[持久化确认决策]
    DEC1 --> CONF[逐个 Confirm,失败则重试]
    OK -->|否| DEC2[持久化撤销决策]
    DEC2 --> CANCEL[对相关分支 Cancel,失败则重试]
    CONF --> END1[所有参与者最终确认]
    CANCEL --> END2[所有相关预留最终释放]

TCC 与 2PC 都在两个逻辑阶段处理“预留—最终决定”,但最大的差别是 TCC 的参与者必须按业务语义实现接口。TCC 不是数据库自带的锁协议,也不要求一个数据库事务从 Try 一直悬挂到 Confirm;一个成熟实现通常会把 Try 的预留信息保存在本地事务中,然后释放数据库连接与锁,等待之后的确认或撤销请求。

以跨航司订票理解业务原子性

假设用户需要预订两段航程:深圳 → 上海、上海 → 北京。只成功订到其中一段,没有实际完成预定的行程目标。

sequenceDiagram
    autonumber
    participant C as 订票业务协调器
    participant A as 深圳航空
    participant B as 上海航空
    C->>A: Try 预留深圳到上海的座位
    A-->>C: 预留成功
    C->>B: Try 预留上海到北京的座位
    alt 两段预留成功
        B-->>C: 预留成功
        C->>C: 持久化 Confirm 决策
        C->>A: Confirm 正式出票
        C->>B: Confirm 正式出票
    else 第二段预留失败
        B-->>C: 预留失败
        C->>C: 持久化 Cancel 决策
        C->>A: Cancel 释放第一段座位
    end

真正困难的地方不是把方法命名为 Try、Confirm、Cancel,而是能否保证:已经预留的座位不会被另一个订单占用;确认接口可以安全重试;客户端超时后仍能查到这次预留是否存在;撤销的资源不会被错误地释放给别的业务事务。

文件、远程 API 与数据库怎么组合成一笔业务事务?

可以将一条复杂指令拆成三个参与者:

参与者 Try Confirm Cancel
网页截图生成 将图片生成到临时区域并保存关联 ID 将结果标记完成、转为正式可见 删除临时图片并清理预留记录
域名访问控制系统 在目标系统登记待生效变更 正式生效域名禁用策略 放弃待生效变更并释放预留
多个 MySQL 数据库 创建或准备所需事务资源 对已准备的分支执行 XA COMMIT 对相关事务分支执行 XA ROLLBACK

这种方案表明,TCC 和 XA 可以嵌套在不同抽象层次。业务协调器负责整个指令的 TCC;对于其中的数据库参与者,可以在受控条件下用 XA 来实现对应分支。

不过,表中的文件操作、域名禁用与 XA 都有资源生命周期上的代价。尤其把数据库 XA 的 PREPARED 状态长时间留到远程业务 TCC 确认,会重新引入长时间锁定资源的风险。实际设计常应优先让数据库分支使用状态字段、冻结额度或业务预留表,把 Try 完结为短本地事务;是否需要复用 XA,要结合锁等待与外部系统响应时间做权衡。

TCC 的关键不是“回滚成功”,而是状态最终收敛

在分布式环境中,协调器无法保证每次 Confirm 或 Cancel 的网络调用一定成功。如果 A 已经确认,而给 B 发送确认消息时超时,协调器必须依赖持久化的全局决定继续重试 B,而不能未经额外补偿设计就改成撤销已经确认的 A。

实现时至少需要处理三个经典问题:

问题 可能发生的情况 常见处理思路
空回滚(Empty Rollback) Try 根本没有成功,但 Cancel 先到达 记录分支事务状态,允许安全的空回滚
幂等(Idempotency) Confirm / Cancel 重试多次 使用全局事务 ID、分支 ID 和条件状态更新保证重复结果一致
悬挂(Hanging) Cancel 已结束,迟到的 Try 又到达 维护终止状态或防悬挂机制,拒绝重新预留

一个典型的参与者状态转换可设计为:

stateDiagram-v2
    [*] --> INIT
    INIT --> RESERVED: Try 成功
    INIT --> CANCELED: Cancel 先到达 / 空回滚
    RESERVED --> CONFIRMED: Confirm 成功
    RESERVED --> CANCELED: Cancel 成功
    CANCELED --> CANCELED: 重复 Cancel
    CONFIRMED --> CONFIRMED: 重复 Confirm
    CANCELED --> CANCELED: 迟到 Try 被拒绝

在这种设计里,全局事务 ID、分支 ID、操作状态和资源预留数据通常需要持久化。状态变化应使用事务性条件更新,避免并发的 Confirm 与 Cancel 互相覆盖;协调器需持久化最终决策,按可恢复的重试任务继续推进。如果某项外部操作根本不存在可逆的资源预留或补偿,不能仅靠给它包上 TCC 接口,就获得严格的业务原子性。

TCC 最终提供的是一个基于可执行的业务约束、幂等确认/撤销与持续恢复的全局业务结果。当中间过程有部分预留或部分确认时,外界看到的状态未必时时刻刻都像单数据库事务那样瞬间一起改变,所以更适合从最终收敛的业务一致性角度评估,而不是把它与线性一致读或 XA 的数据库锁机制混为一谈。

在 XA 与 TCC 之间如何选择?

判断因素 XA / 2PC TCC
主要资源 支持 XA 的事务型数据库等 RM 多数据库、业务 API、文件、库存、支付等可设计预留机制的资源
业务侵入程度 通常较低,协调器与 RM 的事务能力承担更多责任 较高,每个参与者需设计 Try / Confirm / Cancel
等待成本 Prepared 阶段可能持有数据库锁 常可使用短本地事务,但要承担预留资源管理成本
故障恢复 TM 事务日志、XA RECOVER、补交/回滚 全局状态、分支幂等、重试、空回滚、防悬挂
主要风险 阻塞、长事务和分支恢复 业务补偿不完备、状态设计复杂、最终收敛时间
适用情况 数据库能力完备、事务规模可控、重视较低接入改造成本 需要跨系统业务原子效果且能控制或改造业务接口

如果业务本来可以把强原子性变更收敛到同一数据库或同一个一致性聚合边界内,那么减少跨资源事务通常比事后选择一种复杂的协调协议更划算。

拜占庭容错:当某个节点不只是宕机,而是故意作恶

Raft 和 ZAB 主要在节点遵守协议、只是可能崩溃或失联的前提下建立安全性。但如果一个节点向不同参与者发送不同的决定,甚至冒充其他节点,单纯的多数投票不一定能解决问题。拜占庭将军问题试图说明:在参与者可能说谎、传递矛盾信息的情况下,忠诚节点怎样仍能就共同指令达成一致。

数字签名能约束什么,不能约束什么?

数字签名可以帮助接收者验证:一条消息确实由持有对应私钥的一方签发,而且被签名覆盖的内容在传输过程中没有被悄悄篡改。它同时为后续审计提供来源证明,但“不可抵赖”仍要依赖密钥保护、可信公钥绑定和具体制度或信任模型。

典型的签名与验证过程如下:

sequenceDiagram
    autonumber
    participant A as 消息发送者
    participant B as 消息接收者
    A->>A: 使用私钥对消息或其摘要签名
    A->>B: 发送原文 + 签名 + 身份相关信息
    B->>B: 获取并验证可信公钥
    B->>B: 验证签名及完整性
    alt 验证成功
        B->>B: 接受签名来源和内容完整性
    else 验证失败
        B->>B: 拒绝该消息
    end

假设叛徒转发了一条忠诚节点已经签过名的“进攻”命令。它可以选择不转发、延迟转发,也可以凭自己的身份另发一条矛盾消息;但只要没有忠诚节点的私钥,就不能把原来的签名有效地改成支持“撤退”的签名。这把问题从“真假消息无法区分”缩小为“消息可能被遗漏或有人主动制造分歧”。

**签名并不能证明消息内容一定正确,也不能防止持有签名私钥的节点主动说谎。**若叛徒自己的私钥泄露给同伙,其他节点只会看到这套身份下的有效签名,无法单凭签名判定背后是谁按下的发送按钮。协议还需要处理身份、轮次、消息转发、超时及决策规则。

历史讲解可能使用 MD5 + RSA 私钥加密摘要 来解释数字签名思想,但 MD5 不应再作为新的数字签名系统的抗碰撞摘要算法;现代实现通常使用经标准定义的签名方案,例如 RSA-PSS 与 SHA-256、ECDSA 与 SHA-256、Ed25519 等。密钥交换算法(如 Diffie–Hellman)也不能直接等同于数字签名算法。

签名消息型拜占庭协议如何形成一致决定?

经典的签名消息型拜占庭方案可以这样理解:

  1. 发起者给命令附上签名,将其发给所有参与者。
  2. 接收者验证签名,将有效命令纳入集合,再附上自己的签名,转发给尚未在签名链中的参与者。
  3. 按协议要求进行后续传播轮次,让忠诚节点有机会获得同一组可验证的命令。
  4. 所有忠诚节点对相同的命令集合使用一致的确定性决策函数,得到同样的结果。
flowchart LR
    C[命令发起者] -->|签名命令| A[忠诚节点 A]
    C -->|签名命令| B[忠诚节点 B]
    C -->|可能发送不同命令| X[恶意节点 X]
    A -->|保留签名链并转发| B
    B -->|保留签名链并转发| A
    X -.->|篡改忠诚节点签名将被拒绝| A
    A --> D[完整有效命令集合]
    B --> D
    D --> E[相同的确定性决策规则]

教材使用“两忠两叛”场景演示签名消息在数轮传播后如何保持忠诚将军的行动一致,并介绍容忍 m 个叛徒时的 m+1 轮传播思路。理解这个结论时,必须把它放在经典模型的假设下:可验证身份与签名、规定的消息传递轮次、相应的网络时序条件,以及协议所要求的忠诚参与者数量。不能由此推导出一个任意异步互联网系统“签了名就能容忍任意多个恶意节点”。

这一类方案着重保障忠诚节点最终使用相同的决定,并不意味着该决定必然符合现实世界的最佳选择。例如如果发起者本身恶意,忠诚节点可能一致同意一条并不合理的命令。共识安全与业务决策质量是两回事。

PBFT 的正常路径:为什么需要多阶段确认?

实用拜占庭容错算法 PBFT(Practical Byzantine Fault Tolerance)面向更接近真实网络服务的请求处理。经典 PBFT 使用 3f+1 个副本来容忍最多 f 个拜占庭故障,运行时包括一个 Primary(主节点)和多个 Backup(备份节点)。

其典型共识路径可以概括为:

阶段 核心动作 希望排除的风险
PRE-PREPARE Primary 为客户端请求分配视图和序号并广播摘要 请求没有明确的排序提议
PREPARE 副本传播并核对同一视图、序号及摘要 恶意主节点可能向不同副本发出冲突提案
COMMIT 副本形成足够确认后,再传播提交确认 某个请求只被局部节点接受就被错误执行
REPLY 已执行的副本向客户端返回结果 客户端需要识别单个恶意节点伪造的结果

在经典 PBFT 的多数派推理中,准备、提交的条件建立在正确的视图、序号、消息匹配以及足够多的副本证明之上。通常可以看到 2f 个来自其他副本的匹配消息、加上本节点自身相关消息形成 2f+1 的法定集合;客户端可以等待 f+1 个一致的回复,保证其中至少有一份来自诚实副本。不同文献在是否将本节点消息计入阈值方面表述略有不同,阅读协议和源码时必须留意计数口径。

sequenceDiagram
    autonumber
    participant C as Client
    participant P as Primary
    participant A as Backup A
    participant B as Backup B
    C->>P: Request
    P->>A: PRE-PREPARE(v,n,digest)
    P->>B: PRE-PREPARE(v,n,digest)
    A->>B: PREPARE(v,n,digest)
    B->>A: PREPARE(v,n,digest)
    Note over P,B: 满足 prepared 的法定消息条件
    A->>B: COMMIT(v,n,digest)
    B->>A: COMMIT(v,n,digest)
    Note over P,B: 满足 committed-local 条件并有序执行
    A-->>C: REPLY
    B-->>C: REPLY
    Note over C: 确认 f+1 个一致响应

与 Raft 相比,PBFT 要额外防备参与者说谎和发送冲突信息,因此往往需要更多副本间通信与更复杂的状态证明。这也是经典 PBFT 相比 CFT 共识在通信规模和 CPU 验证方面成本较高的原因。

Primary 作恶时,视图变更如何继续推进?

如果恶意 Primary 收到请求后什么也不做,或者只向部分节点广播预准备消息,其他诚实节点可能长时间无法推进正常协议。这时需要 View Change(视图变更)。View 表示一次主节点任期;在经典实现思路中,可以用 primary = view mod N 确定该视图的主节点,视图增加后轮换为下一个候选主节点。

视图变更不只是换一个进程名称。新 Primary 必须收集足够的视图变更证明,安全地继承旧视图已经准备或提交的请求信息,否则可能在切换时推翻旧视图的合法决定。

sequenceDiagram
    autonumber
    participant C as Client
    participant P as 恶意 Primary
    participant A as Backup A
    participant B as Backup B / 新 Primary
    participant D as Backup C
    C->>P: 请求
    Note over P: 不广播或发送异常消息
    C-->>C: 等待回复超时
    C->>A: 向副本重发请求
    C->>B: 向副本重发请求
    C->>D: 向副本重发请求
    A->>B: VIEW-CHANGE(newView,证明)
    D->>B: VIEW-CHANGE(newView,证明)
    Note over B: 收集足够的法定证明,校验待继承历史
    B->>A: NEW-VIEW(newView,证明与安全提案)
    B->>D: NEW-VIEW(newView,证明与安全提案)
    Note over A,D: 进入新视图并恢复正常共识流程

以四个副本、容忍一个拜占庭故障为例,法定视图变更证明通常需要覆盖 2f+1=3 个副本的相应信息。图中假设新 Primary B 也有自己的有效视图变更状态,因此接收到来自 A、D 的消息后才可能满足这类阈值;不能把“收到了两条消息”误记成 PBFT 一般情况下只需要两票。

触发视图变更的原因不只包括客户端没有在超时窗口收到足够一致的响应,还包括:备份节点没有按期收到足够的准备或提交消息、发现同一视图和序号下的异常摘要,或者主节点与网络连接出现故障。进入视图变更阶段的相关副本会暂停推进旧视图普通业务请求,优先处理检查点和视图变更类消息。

经典 PBFT 可以通过后续视图继续尝试绕开故障或恶意主节点,但它并不是在任何网络条件下都能立即选出诚实 Leader。活性仍依赖网络最终具有足够的通信稳定性、超时策略和不超过假设范围的故障比例。安全性要求的阈值也不能因系统着急恢复服务而降低。

Checkpoint 与认证机制:为什么 PBFT 必须优化开销?

PBFT 节点需要保存预准备、准备、提交和视图变更相关的信息,以便证明已经进行过哪些决策。但这些记录不能无限累积。**检查点(Checkpoint)**用来确认稳定状态,允许安全清理不再需要的历史协议记录,并为故障恢复提供状态基线。

密码学操作也是重要成本。原始 PBFT 的经典工程思路是将数字签名与消息认证码(MAC)配合使用:更需要非对称可验证证明的消息采用签名,其他频繁交互使用更便宜的消息认证机制。具体哪些消息采用何种认证方式,需要以所研究的 PBFT 实现和威胁模型为准,而不应假定每个 PBFT 系统都严格复刻同一套签名分配。

PBFT 在许可型、成员集合受控的环境中更容易满足身份与副本规模的假设。PoW(工作量证明)则是另一类依靠经济与算力假设抵御开放参与环境下攻击的机制;它的概率性最终性、资源消耗与安全模型不应直接和 PBFT 的确定性提交阈值做简单的“高、中、低”排名。

从机制回到系统:故障发生时应该检查哪一层?

判断故障时,最重要的不是先猜“Raft 有 Bug”还是“事务出问题”,而是定位这次异常处于领导者选举、复制与提交、状态机应用、读路径、数据副本修复,还是跨资源全局决策中的哪一个环节。

典型现象与调查入口

观察到的现象 优先排查 进一步确认
Raft 写请求大量超时 Leader 是否稳定、是否形成多数派 任期持续增长、投票状态、网络 RTT、日志持久化延迟
Raft 写成功但读取旧值 是否读 Follower 或旧 Leader 读取级别、commitIndex 与 lastApplied、读屏障
ZooKeeper 主节点切换后暂时不能写 选举后恢复是否完成 LOOKING、Epoch 协商、NEWLEADER 与 UPTODATE
ZooKeeper 节点差异很大 DIFF/TRUNC/SNAP 同步路径 当前 Leader 日志范围、快照传输、磁盘空间
监控同一时间段呈现不同曲线 InfluxDB DATA 副本缺失或不一致 HH 队列、反熵状态、Shard 所有者和复制因子
MySQL 事务分支长期锁表 是否存在悬挂的 Prepared XA XA RECOVER、事务协调器持久化决策
TCC 某项业务一直处于冻结状态 全局 Confirm / Cancel 是否未完成 幂等状态、重试日志、空回滚与防悬挂机制
PBFT 客户端超时且无足够一致回复 主节点停滞、认证错误或消息分叉 视图编号、PREPARE/COMMIT 数量、VIEW-CHANGE 证明

故障测试应该覆盖“请求结果不确定”的场景,例如在命令已经写入多数派、但客户端尚未收到成功响应时杀死 Leader;在 XA 准备完成后停止事务协调器;或在 TCC 的第一项 Confirm 成功、第二项尚未成功时断网。这些边界往往比正常路径更能暴露分布式系统是否正确。

几个最容易产生误判的地方

**多数派可用不等于无延迟。**集群虽然还剩法定多数,但 Leader 重新选举、日志追赶与磁盘刷写仍会影响延迟;不应该因为“理论上能容忍一台机器故障”就承诺完全零中断。

**Raft 日志达成共识不等于业务命令只执行一次。**同一业务请求可能因为客户端重试形成不同日志项,必须在业务层设计稳定请求 ID 与幂等结果。

**最终一致性不是“允许永远不一致”。**HH 队列可能过期,反熵可能关闭或修复源已经丢失,数据收敛需要具体恢复机制及其运行条件。

**TCC 不等于数据库 ROLLBACK。**有些 Confirm 已经触发外部不可逆操作,Cancel 无法像 SQL 一样撤销;需要预留语义、持久化状态与补偿边界。

**签名消息不等于抗所有攻击。**签名解决消息来源与完整性证明,不解决恶意合法签名者、密钥泄露、重放、拒绝服务或网络活性问题。

版本边界与工程实践补充

本文所涉及的教材案例主要来自 2020 年前后的软件实现:HashiCorp Raft 的源码分析以 v1.1.1 为基础,ZooKeeper 代码跟踪以 3.6.0 为背景,InfluxDB 架构以 Enterprise v1 为对象。它们对于理解协议及系统结构仍然具有参考价值,但不应由此假定当下所有发行版、所有软件分支、所有默认配置均保持不变。

尤其需要保留如下边界意识:

  • HashiCorp Raft 新工程应重新核对所选版本的 Config、FSM、存储后端及成员管理 API,避免直接复制旧版本示例。库本身只提供共识能力,是否支持严格一致读取决于应用如何组合 API。
  • ZooKeeper 的“普通读取可以在 Follower 本地完成”不意味着可以把它描述成没有约束的 AP 数据库。官方定义了更细致的会话顺序、写入线性化与读取模型。
  • InfluxDB Enterprise v1 的反熵服务需要按版本检查启用状态;提示移交队列也受到容量、保留期限和故障持续时间影响。
  • MySQL XA 的 XID、事务状态、权限和恢复机制须以目标 MySQL 版本的 SQL 规范为准,不能忽略分支标识 bqual 和已准备事务的恢复。
  • PBFT、签名消息与 PoW 的安全结论都有明确的网络、身份、算力或故障比例假设;脱离假设比较“最多能容忍多少坏节点”没有意义。

用于核对实际项目行为的一手文档入口包括 HashiCorp Raft 源码与说明、Apache ZooKeeper Internals、MySQL 8.4 XA 事务语句、InfluxDB Enterprise v1 集群架构 和 Enterprise v1 Anti-Entropy 服务。在真实工程里,应当把这些文档与所用软件的确定版本、源码标签及故障验证结果一起看。

总结:用四个维度构建协议选择矩阵

分析分布式协议时,最有用的不是将所有算法排成一个“性能第一、可用性第二”的榜单,而是按照故障模型、一致性目标、性能成本和可用性前提四个维度来比较。下面沿用这四个维度,将常见机制放在同一张表中。

四维能力对照表

协议 / 机制 故障模型 主要一致性目标 性能与成本特点 典型可用性限制
2PC / XA CFT;协调者和 RM 可靠恢复 多资源分支的全局提交/回滚决策 Prepared 阶段可能锁定资源;协调开销较高 协调器失效可能使事务阻塞待决
TCC 业务系统故障与网络超时;默认非恶意 业务资源最终确认或撤销 需要预留、补偿与重试逻辑;适合短本地事务 依赖分支可恢复与业务接口设计,无法保证即时全部完成
Paxos / Multi-Paxos CFT 对提案或日志达成安全共识 消息与实现复杂度依具体方案而定 一般需满足法定多数
Raft CFT 有序复制日志;可在读协议配合下提供线性一致访问 单组写入受 Leader 与复制持久化约束 投票多数派不可用时不能正常提交
ZAB / ZooKeeper CFT 原子广播、事务顺序;普通本地读可能旧 集中写入、有序提交;读可由多个节点承载 需要合法 Leader 与投票多数派,切换期间有恢复成本
Gossip + 反熵 通常面向 CFT 及网络波动 传播与副本最终收敛 通常利于水平扩展,但要承担收敛与冲突处理成本 可用性取决于产品读写策略,不能由 Gossip 单独判断
Quorum NWR 常用于 CFT 多副本读写 通过读写集合交叠辅助控制旧读风险 增大 W 或 R 会增加写或读等待 无法达到所需确认数时对应操作失败
PBFT BFT,经典模型 N>=3f+1 面对部分恶意副本达成有序执行共识 多阶段消息与认证开销明显 依赖足够诚实副本和最终稳定的通信条件
PoW 面向开放参与的对抗模型,依赖算力等假设 概率式链上共识与确认 计算/能源消耗、确认延迟和安全成本较高 安全与活性取决于参与算力、网络与攻击假设

这张表是设计选型的概念矩阵,不是跨不同实现环境的性能测评。表中 2PC 与 TCC 属于事务协调机制,Quorum NWR 是副本读写策略,Gossip 是信息传播与反熵常用基础,不能把它们全部当作与 Raft 完全同类型的“共识算法”。

从故障容忍和业务需求反向选择

flowchart TD
    A[明确业务不变量与故障假设] --> B{需要保证的核心目标}
    B -->|跨系统业务事务| C{参与者是否都支持 XA}
    C -->|是,且可接受锁等待| XA[XA / 2PC]
    C -->|否,但能设计预留及撤销| TCC[TCC]
    C -->|无法提供可逆操作| RED[重新划分事务边界或设计补偿]
    B -->|复制有序指令| D{允许恶意参与者吗}
    D -->|否| E{是否直接使用 ZooKeeper 语义}
    E -->|是| Z[ZAB / ZooKeeper]
    E -->|否| R[Raft / Paxos 系方案]
    D -->|是| PB[PBFT 类 BFT / 根据场景研究其他 BFT]
    B -->|大规模数据副本收敛| F{读到旧数据是否可接受}
    F -->|允许短暂旧读| GA[Gossip / HH / 反熵]
    F -->|不能接受| QU[一致读屏障或经过验证的 Quorum 读写]
    GA --> G[明确冲突解决与失效修复策略]
    QU --> H[确认实时顺序、版本判定与降级行为]

从工程角度看,“哪个算法最好”远没有“系统具体要对什么承诺,以及故障时哪些请求可以失败”重要。例如权限配置、财务交易、实时监控指标、历史日志和开放区块链虽然都是分布式场景,但对错误的容忍方式和延迟预算完全不同。

知识全景图:把看似零散的协议放回同一体系

flowchart TB
    ROOT[分布式协议与算法工程体系]
    ROOT --> MODEL[基础模型与边界]
    ROOT --> CONS[崩溃容错与复制状态机]
    ROOT --> EVENT[最终一致性与副本修复]
    ROOT --> TRAN[分布式事务]
    ROOT --> BFT[拜占庭容错]
    ROOT --> PRACTICE[工程实现]

    MODEL --> M1[故障模型 CFT / BFT]
    MODEL --> M2[CAP 和网络分区]
    MODEL --> M3[Consensus / Consistency / Atomicity]

    CONS --> C1[Paxos / Multi-Paxos]
    CONS --> C2[Raft: 选举 → 复制 → 应用]
    CONS --> C3[ZAB: 选举 → 发现 → 同步 → 广播]

    EVENT --> E1[Gossip 与异步传播]
    EVENT --> E2[Hinted Handoff / 反熵]
    EVENT --> E3[Quorum NWR 与读写等级]

    TRAN --> T1[2PC / XA: 资源事务]
    TRAN --> T2[TCC: 业务预留与补偿]

    BFT --> B1[签名消息与身份认证]
    BFT --> B2[PBFT 多阶段共识]
    BFT --> B3[View Change / Checkpoint]
    BFT --> B4[PoW 等开放环境方案]

    PRACTICE --> P1[HashiCorp Raft: API / FSM / LogStore]
    PRACTICE --> P2[分布式 KV: HTTP / Apply / Read]
    PRACTICE --> P3[ZooKeeper: zxid / 请求链 / 恢复]
    PRACTICE --> P4[InfluxDB: META Raft / DATA 复制]
    PRACTICE --> P5[故障注入 / 监控 / 恢复验证]

这个视图体现了多个重要依赖关系:读一致性并不是孤立协议,它建立在日志提交或副本版本规则上;事务协调不替代副本共识;密码学认证使拜占庭容错成为可能,却不直接提供活性;真正可部署的系统还需要路由、存储、重试与恢复。

按知识难度组织学习路径

对于系统性学习,可以沿“概念 → 协议 → 源码 → 架构实践”逐层推进。下表保留了经典学习路径中的主题分组与难度分布,并补上每个阶段应该完成的工程验证。星级表示相对学习复杂度,不是算法优劣。

学习主题 相对难度 需要掌握的关键内容 可验证的学习产出
拜占庭将军问题 ★☆☆☆☆ CFT 与 BFT、口信模型、签名消息 推演不同忠诚/恶意节点组合下的消息传播
CAP 理论 ★★☆☆☆ 网络分区、可用性与线性一致性的权衡 对同一个业务提出 CP 和偏 AP 的故障响应策略
分布式事务 ★★☆☆☆ 2PC、XA、Try/Confirm/Cancel 模拟 XA 协调器故障和 TCC 空回滚
分布式强一致性与共识 ★★★★★ Paxos、Raft、日志提交、读一致性 实现三节点 Raft KV,并验证 Leader 故障切换
分布式最终一致性 ★★★☆☆ Gossip、Quorum、HH、反熵 模拟副本不可达与恢复后收敛
ZAB / ZooKeeper ★★☆☆☆ 选举、Epoch、DIFF/TRUNC/SNAP、读写链 观察 ZooKeeper 选举与节点重新追赶
拜占庭容错协议 ★★☆☆☆ PBFT 正常路径、视图变更、检查点 手工推导 f=1 时消息阈值及恶意主节点切换
分布式系统实战 ★★★★☆ HashiCorp Raft、KV 架构、状态机与故障注入 运行多节点服务,排查一次真实的超时与恢复

推荐的推进关系不是每个主题孤立学完再看下一个,而是先建立最低限度的模型理解,然后围绕 Raft → HashiCorp Raft → KV 实战形成第一条可运行主线,再回过头深入 ZooKeeper/ZAB 和 InfluxDB 的多副本方案,最后补足 XA/TCC 与 PBFT 的不同问题域。

flowchart LR
    A[基础: 故障模型 + CAP + 一致性语义] --> B[共识基础: Paxos / Raft]
    B --> C[源码: HashiCorp Raft]
    C --> D[实践: 三节点 KV + 一致性读]
    D --> E[对照: ZAB / ZooKeeper]
    D --> F[扩展: InfluxDB 数据副本 / 反熵]
    A --> G[事务: XA / 2PC]
    G --> H[业务事务: TCC]
    A --> I[签名消息与拜占庭问题]
    I --> J[PBFT / View Change]
    E --> K[统一故障注入与架构选型]
    F --> K
    H --> K
    J --> K

工程复习速查表

想解决的问题 应该重温的原理 最值得看的入口或证据
为什么旧 Leader 不能继续提交? Raft 任期、投票与多数派 runFollower、runCandidate、term 与 quorum
为什么日志复制成功仍不能直接返回? 提交规则与状态机应用 commitIndex、lastApplied、FSM.Apply
为什么 ZooKeeper 切主之后仍要等待? Discovery 与 Synchronization LearnerHandler、ACKEPOCH、NEWLEADER
为什么 Follower 可能读旧数据? 读一致性与本地副本进度 本地 DataTree、读屏障、Leader 验证
为什么 InfluxDB 的监控曲线有时不一样? HH、反熵、分片副本 Shard 布局、HH 磁盘队列、entropy 状态
为什么 MySQL 长时间不能释放锁? XA Prepared 与协调器崩溃 XA RECOVER、TM 决策日志
为什么 TCC 取消后仍有资源被冻结? 空回滚、悬挂与幂等 全局事务 ID、分支状态、重试任务
为什么恶意主节点能阻塞 PBFT? PRE-PREPARE 阻断与视图变更 VIEW-CHANGE / NEW-VIEW 证明

要真正掌握这些内容,最终应能够在同一个问题上完成三件事:说清楚系统对外承诺的语义,指出支撑该承诺的状态与消息流程,再设计一个能尝试打破它的故障实验。理解协议从来不是只记住一张流程图,而是知道每个确认消息、持久化状态和多数派阈值究竟防止了哪一种错误。


分布式一致性协议与算法工程实践:从 Raft、ZAB 到 PBFT、XA 与 TCC
https://allendericdalexander.github.io/2026/10/13/archtect/distribute/Distributed-Consensus-Protocols-And-Algorithmic-Engineering-Practice/
作者
AtLuoFu
发布于
2026年10月13日
许可协议