Skip to content

分布式共识:Paxos、Raft、ZAB 与多数派

共识算法解决的是:多个节点可能宕机、网络可能分区、消息可能延迟和乱序时,幸存节点怎样对一串操作的顺序和结果达成一致。它是 etcd、Consul、ZooKeeper、KRaft 元数据控制器等协调系统的重要基础,但不是业务开发中所有一致性问题的通用替代品。

本章以崩溃故障和非拜占庭网络模型为主。经典 Paxos、Raft、ZAB 通常不处理节点恶意伪造消息的拜占庭故障;若业务需要抵抗恶意节点,要研究 PBFT、HotStuff 等拜占庭容错协议,不能直接套用本章结论。

学习路径:本页建立 Paxos、Raft、ZAB 与 Quorum 主线;随后进入 Raft 内部原理与生产治理,从 etcd/raft 的 Tick、Step、Ready、Progress、Commit、ReadIndex、Snapshot 和 Joint Consensus 追到真实三节点故障测试;面试标准回答单独放在 共识算法与 Raft 独立面试题

一、学习目标

学完后应能解释:

  1. 共识、复制、事务、分布式锁分别解决什么问题。
  2. Safety 与 Liveness 为什么要分开讨论。
  3. 多数派为什么要求集合相交,3/5/7 节点分别能容忍多少故障。
  4. Raft 的 Follower、Candidate、Leader、Term 和 Log 是什么。
  5. RequestVote、AppendEntries 如何完成选主、心跳和日志复制。
  6. 旧 Leader 在网络分区后为什么不能仅凭本地状态继续提交。
  7. 日志冲突怎样通过 prevLogIndex/prevLogTerm 检测和回退。
  8. Raft 何时能认为日志已提交,为什么当前任期提交规则很重要。
  9. Paxos 的 Proposer、Acceptor、Learner 和两阶段消息是什么。
  10. Multi-Paxos 为什么通常需要稳定 Leader 降低成本。
  11. ZAB 的选主恢复、数据同步和原子广播如何工作。
  12. 快照、日志压缩、成员变更和线性一致读为什么不能省略。
  13. ZooKeeper、etcd、Consul、Kafka KRaft 和普通 Redis 锁的边界。

二、共识到底在“共识”什么

对复制状态机而言,各节点不是直接讨论最终数据库长什么样,而是先对操作日志的顺序达成一致:

text
index=1: SET config.version = 7
index=2: ADD member node-4
index=3: DELETE lock/order-1001

只要每个正确节点以相同顺序执行相同确定性命令,就能得到相同状态。

mermaid
flowchart TD
    A["客户端提交命令"] --> B["共识层决定日志位置和任期"]
    B --> C["日志复制到多数节点"]
    C --> D["日志被标记为已提交"]
    D --> E["各节点按相同顺序应用到状态机"]
    E --> F["得到一致的配置、成员或元数据"]

共识不直接保证:

  • 订单库和支付库跨服务事务自动一致。
  • 一个 HTTP 请求不会被重复执行。
  • 缓存与数据库自动同步。
  • 业务补偿一定正确。
  • 客户端在超时后知道命令是否已经提交。

这些还需要事务、幂等、状态机、消息和查询确认。

三、Safety 与 Liveness

属性要求失败示例
Safety 安全性不会出现两个互相冲突的已提交结果两个分区都确认自己成功写入同一日志位置的不同命令
Liveness 活性条件恢复后,合法请求最终能继续完成网络恢复且多数节点健康,集群仍永久无法选主

安全性通常要求任何时刻都不能被破坏;活性依赖时序和网络最终恢复。网络完全异步且消息可无限延迟时,无法仅凭超时完美区分“节点宕机”和“节点很慢”。实际协议依赖随机选举超时、心跳和“最终存在一段足够稳定的通信期”取得进展。

因此发生分区时,少数派通常宁可停止写,也不能确认冲突结果;这就是 CP 协调系统常见行为。

四、先确认故障模型

本章主要讨论:

  • Crash-stop:节点停止并不再恢复。
  • Crash-recovery:节点重启并从持久化状态恢复。
  • 消息丢失、重复、延迟、乱序。
  • 网络分区。

通常假设:节点不会恶意发送伪造内容,磁盘持久化满足协议要求,消息可被校验来源和格式。若磁盘写成功实际未持久化、节点返回虚假 ACK、证书被盗或实现有 Bug,经典 crash-fault 共识结论不能自动兜底。

五、多数派与 Quorum 为什么有效

N 个投票节点的多数派大小:

text
quorum = floor(N / 2) + 1
节点数多数派可容忍同时故障数
110
321
532
743

关键不是“多数听起来民主”,而是任意两个严格多数派必然至少有一个交集节点。

mermaid
flowchart TD
    A["旧多数派:节点 A、B、C"] --> C["交集节点 C"]
    B["新多数派:节点 C、D、E"] --> C
    C --> D["新一轮决策有机会得知旧已提交信息"]

对于 2f + 1 个节点,通常可容忍 f 个崩溃故障并保持多数派。偶数节点往往不会提高同等级故障容忍:4 节点需要 3 票,只能容忍 1 个;3 节点需要 2 票,也容忍 1 个,却少一个节点成本。

多数派只是协议的一部分。若投票规则、日志新旧判断、持久化或成员变更实现错误,即使数票也可能不安全。

六、网络分区与脑裂

5 节点分成 3 节点和 2 节点两个分区:

mermaid
flowchart TD
    A["网络分区"] --> B["分区一:3个投票节点"]
    A --> C["分区二:2个投票节点"]
    B --> D["可以形成多数派并选主、提交"]
    C --> E["无法形成多数派,必须停止提交"]

少数派旧 Leader 可能仍认为自己是 Leader,但它收不到多数 ACK,不能推进 commitIndex。客户端若只看“节点角色”而不等提交确认,可能误以为写成功。

脑裂防护不能只靠“检测到两个 Leader”。在不同 Term 的短暂窗口内看到两个自称 Leader 并不一定违反安全性;关键是同一日志位置不能有两个冲突条目都被合法提交。

七、Raft 总体模型

Raft 将共识拆成三个主要问题:

  1. Leader Election:选出 Leader。
  2. Log Replication:由 Leader 复制日志。
  3. Safety:保证已提交日志不会被后续 Leader 覆盖。

节点状态:

状态行为
Follower接收 Leader 日志和心跳;超时后发起选举
Candidate增加 Term,投自己并向其他节点请求投票
Leader接收客户端命令、追加日志、复制并推进提交

每个节点持久化或维护的重要状态通常包括:

  • currentTerm:见过的最高任期。
  • votedFor:当前任期投给谁。
  • log[]:日志条目,每条有 index、term、command。
  • commitIndex:已知已提交的最高日志位置。
  • lastApplied:已应用到状态机的最高位置。
  • Leader 对每个 Follower 的 nextIndexmatchIndex

八、Term 为什么重要

Term 是逻辑任期,可理解为一次选举时代编号:

text
Term 1:node-A 是 Leader
Term 2:选举未成功
Term 3:node-C 是 Leader

规则:

  1. 节点发起选举前增加 currentTerm。
  2. RPC 带上发送方 Term。
  3. 节点看到更高 Term,应更新本地 Term 并退回 Follower。
  4. 低 Term 请求会被拒绝。

Term 让节点识别过期 Leader 和过期消息,但仅比较 Term 仍不够;投票时还要比较候选日志是否至少和自己一样新。

九、Raft 选主全过程

mermaid
flowchart TD
    A["Follower 在随机选举超时内未收到有效心跳"] --> B["currentTerm 加 1"]
    B --> C["切换 Candidate 并投自己"]
    C --> D["并行发送 RequestVote"]
    D --> E["接收方比较 Term 和候选日志新旧"]
    E --> F{"获得多数票"}
    F -- "是" --> G["成为 Leader 并立即发送心跳"]
    F -- "否且发现更高Term" --> H["退回 Follower"]
    F -- "选举超时" --> I["新一轮随机超时与更高Term"]

随机选举超时用于降低多个 Follower 同时成为 Candidate、平分选票的概率。心跳间隔应明显小于选举超时;超时过短会因短暂 GC、网络抖动频繁换主,过长会增加故障恢复时间。

十、RequestVote 的日志新旧判断

接收方通常在以下条件下投票:

  1. 候选 Term 不小于本地。
  2. 当前 Term 尚未投票,或已经投给同一候选。
  3. 候选日志至少和本地日志一样新。

日志新旧先比较最后一条日志的 Term,Term 大者更新;Term 相同再比较最后 index。

这个条件用于保证包含更多已提交信息的节点更有机会成为 Leader。不是简单比较“谁的日志条数更多”;较短但最后 Term 更高的日志可能更新。

十一、AppendEntries 同时承担心跳和复制

Leader 发送 AppendEntries,核心字段可抽象为:

text
term
leaderId
prevLogIndex
prevLogTerm
entries[]
leaderCommit

Follower 检查 prevLogIndex 位置是否存在且 Term 等于 prevLogTerm。若不匹配,拒绝并让 Leader 回退 nextIndex;匹配后删除该位置之后冲突日志,追加新条目,再根据 leaderCommit 推进本地 commitIndex。

mermaid
flowchart TD
    A["Leader 发送 prevLogIndex、prevLogTerm 和 entries"] --> B{"Follower 前置日志是否匹配"}
    B -- "否" --> C["返回失败和冲突提示"]
    C --> D["Leader 回退 nextIndex 后重试"]
    B -- "是" --> E["删除同位置后的冲突条目"]
    E --> F["追加 Leader 日志"]
    F --> G["更新 matchIndex 和 commitIndex"]

这就是旧 Leader 少数派期间追加但未提交的日志最终可能被新 Leader 覆盖的原因。未提交不代表永远保留。

十二、Raft 何时算提交

Leader 收到多数节点已持久化某日志位置的确认后,才可能推进 commitIndex。Raft 对“通过计数提交旧任期日志”有重要限制:Leader 通常只通过多数复制直接提交当前任期条目;一旦当前任期条目提交,之前前缀日志也随之确定。

mermaid
flowchart TD
    A["Leader 本地追加 index=N, term=T"] --> B["并行复制给 Followers"]
    B --> C["Followers 持久化后 ACK"]
    C --> D{"多数 matchIndex 大于等于 N"}
    D -- "否" --> E["条目保持未提交"]
    D -- "是且日志属于当前Term" --> F["Leader 推进 commitIndex"]
    F --> G["应用状态机并回复客户端"]
    G --> H["后续心跳把 leaderCommit 告知 Followers"]

“Leader 本地写盘成功”不等于集群提交;“复制到一个 Follower”在 5 节点集群也不等于提交;客户端应以协议提交结果为准。

十三、客户端超时仍然是不确定状态

可能发生:

text
客户端发送 commandId=R100
→ Leader 复制到多数并提交
→ Leader 在返回前宕机或响应丢失
→ 客户端超时

客户端不能换一个新业务 ID 再执行,否则可能重复。应使用稳定 commandId/幂等键,重试后由状态机返回同一结果,或先查询提交状态。共识保证日志一致,不自动提供业务“恰好一次”。

十四、读请求为什么也有一致性问题

直接读任意 Follower 可能读到旧数据。常见读语义:

读方式一致性代价
本地 Follower 读可能陈旧延迟低
Leader 租约读条件成立时可线性一致依赖时钟和租约假设
ReadIndex/多数确认线性一致性更稳妥多一次确认或等待应用进度
写后携带版本读可实现读己之写客户端和服务端管理版本

Leader 也可能已经失去多数派却尚未意识到。线性一致读不能只判断本地角色是 Leader,还要确认自己仍有领导权并且状态机已经应用到安全读位置。

十五、日志压缩与快照

日志无限增长会增加磁盘、重启回放和新节点同步成本。系统会在某个已应用 index 创建状态机快照,并截断更早日志。

落后 Follower 若需要的日志已被 Leader 压缩,Leader 通过 InstallSnapshot 发送快照,再继续复制后续日志。

快照必须与 lastIncludedIndexlastIncludedTerm 对齐,并保证写入原子性;损坏快照或截断未提交日志会破坏恢复。

十六、成员变更为什么危险

若直接从旧配置 {A,B,C} 切到新配置 {C,D,E},旧多数 {A,B} 和新多数 {D,E} 可能完全不相交,产生两个都自认合法的决策组。

Raft 常使用联合共识或等价的分阶段成员变更:过渡阶段同时满足旧配置和新配置的多数要求,再切换到新配置。实际产品可能限制一次只增删一个成员以简化安全实现。

扩容不是把配置文件加一个 IP;要考虑新节点追日志、快照、投票资格、流量和磁盘容量。

十七、Basic Paxos 核心角色

角色作用
Proposer提出带编号的提案和值
Acceptor对提案作 Promise 和 Accept,持久化承诺
Learner学习最终被多数接受的值

一个节点可以同时承担多个逻辑角色。Basic Paxos 解决一个槽位选一个值;实际复制日志需要 Multi-Paxos 等扩展对连续槽位反复达成共识。

十八、Paxos 两阶段消息

Phase 1:Prepare / Promise

Proposer 选择全局递增、唯一的 proposal number n,向多数 Acceptor 发送 Prepare(n)。Acceptor 若尚未承诺更高编号,则承诺不再接受编号小于 n 的提案,并返回自己已接受的最高编号提案和值。

Phase 2:Accept Request / Accepted

Proposer 收到多数 Promise 后:若响应中已有被接受值,必须选择最高 accepted proposal 对应的值;否则可使用自己的值。然后发送 Accept(n, value)。Acceptor 若未承诺更高编号,就接受并持久化。

mermaid
flowchart TD
    A["Proposer 发送 Prepare(n)"] --> B["多数 Acceptor 返回 Promise"]
    B --> C["选择响应中最高已接受提案的值"]
    C --> D["发送 Accept(n, value)"]
    D --> E{"多数 Acceptor 接受"}
    E -- "是" --> F["该槽位的值被选定"]
    E -- "否" --> G["使用更高编号重新开始"]

“必须沿用最高编号已接受值”是安全核心之一,它让新提案继承可能已被选定的旧值,而不是覆盖它。

十九、Multi-Paxos 为什么引入稳定 Leader

若每个日志槽位都完整执行 Prepare 和 Accept,消息和冲突成本很高。Multi-Paxos 在稳定 Leader 存在时,可在建立领导权后对后续槽位主要执行接受阶段,减少往返。

Paxos 与 Raft 都常以 Leader 提高工程效率,但概念和证明方式不同。不能简单说“Raft 就是 Paxos 换名字”,也不能因 Raft 更易理解就认为 Paxos 不安全。

二十、ZAB 为 ZooKeeper 解决什么

ZAB 是 ZooKeeper 的原子广播协议,围绕主备复制、Leader 恢复和事务顺序设计。核心目标包括:

  • 所有已提交事务按相同全局顺序交付。
  • 新 Leader 包含此前已提交历史。
  • Leader 切换时同步 Follower,处理未提交提案。
  • 广播阶段由 Leader 提议、Quorum ACK、再发送 COMMIT。

ZooKeeper 的 zxid 通常用于标识事务顺序,可理解为包含 Leader epoch 和该 epoch 内计数的 64 位标识。新 Leader 建立新 epoch,避免旧 Leader 提案混入新历史。

二十一、ZAB 的恢复与广播

mermaid
flowchart TD
    A["集群启动或 Leader 故障"] --> B["Leader Election"]
    B --> C["Discovery:确定新 epoch 和历史"]
    C --> D["Synchronization:DIFF、TRUNC 或 SNAP 对齐"]
    D --> E["多数节点完成同步"]
    E --> F["Broadcast:Leader 接收写请求"]
    F --> G["生成 Proposal 与 zxid"]
    G --> H["Followers 持久化并 ACK"]
    H --> I{"收到 Quorum ACK"}
    I -- "是" --> J["Leader 发送 COMMIT 并交付"]
    I -- "否" --> K["不能确认提交"]

Follower 落后时可能接收增量 DIFF;存在冲突的未提交尾部可能需要 TRUNC;差距过大时传输 SNAP。具体消息和实现随 ZooKeeper 版本演进,但“选主后先恢复一致历史,再开放广播”是核心。

ZAB 与 Raft 都有 Leader、epoch/term、日志和多数派,但 ZAB 是 ZooKeeper 语义下的原子广播协议,不能把其 RPC 和提交规则逐字段等同于 Raft。

二十二、Paxos、Raft、ZAB 对比

对比Paxos/Multi-PaxosRaftZAB
主要表达提案编号、Acceptor 多数接受Term、Leader、复制日志Epoch、Leader、原子广播
工程理解证明抽象、变体多明确拆分选主、复制、安全深度服务 ZooKeeper 语义
稳定期Multi-Paxos 常用稳定 LeaderLeader 复制 AppendEntriesLeader 广播 Proposal/Commit
恢复重点新提案继承已选值新 Leader 日志完整性新 Leader 历史同步与 zxid
常见系统Chubby 思想、许多 Paxos 变体etcd、Consul、KRaft 等 Raft 系实现ZooKeeper

具体产品可能采用改进版、库实现或额外工程协议,不应仅凭产品宣传词推断所有细节。

二十三、常见组件边界

组件共识/复制定位不应误解
etcd使用 Raft 维护一致日志与 KV 状态Follower 任意本地读不天然线性一致
Consul Server使用 Raft 管理目录和配置状态Agent 健康检查本身不是共识
ZooKeeper使用 ZAB 复制事务和恢复历史Watcher 不是持久消息队列
Kafka KRaft使用 Raft 派生协议管理集群元数据普通 Topic 数据复制不是简单等同控制器元数据 Raft
Redis Sentinel监控、故障转移和 Leader 选举协作Redis 主从异步复制不因此变成强一致共识存储
Redlock多独立 Redis 节点的多数加锁尝试不是 Raft/Paxos 强共识;关键业务需 fencing token 和事实源约束
Nacos不同一致性/实例模型使用不同协议实现不能一概说整个 Nacos 永远是 CP 或 AP

二十四、JDK 8 可运行多数派提交实验

下面是教学模拟,只演示“Leader 本地追加、Follower ACK、多数派提交、少数派不能提交”,不包含选举、持久化、日志回退、成员变更和完整 Raft 安全证明,不能用于生产。

java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class RaftQuorumDemo {

    public static void main(String[] args) {
        Cluster cluster = new Cluster(5);

        cluster.setReachableFollowers(1, 2);
        boolean first = cluster.append("SET version=7");
        System.out.println("firstCommitted=" + first
                + ", commitIndex=" + cluster.commitIndex);

        cluster.setReachableFollowers(1);
        boolean second = cluster.append("SET version=8");
        System.out.println("secondCommitted=" + second
                + ", commitIndex=" + cluster.commitIndex);

        cluster.printLogs();
    }

    static class Cluster {
        private final List<Node> nodes = new ArrayList<Node>();
        private final Node leader;
        private List<Integer> reachableFollowers =
                new ArrayList<Integer>();
        private int currentTerm = 3;
        private int commitIndex = 0;

        Cluster(int nodeCount) {
            for (int i = 0; i < nodeCount; i++) {
                nodes.add(new Node(i));
            }
            leader = nodes.get(0);
        }

        void setReachableFollowers(Integer... ids) {
            reachableFollowers = Arrays.asList(ids);
        }

        boolean append(String command) {
            int index = leader.log.size() + 1;
            Entry entry = new Entry(index, currentTerm, command);
            leader.log.add(entry);

            int acknowledgements = 1;
            for (Integer followerId : reachableFollowers) {
                Node follower = nodes.get(followerId);
                follower.appendCopy(entry);
                acknowledgements++;
            }

            int quorum = nodes.size() / 2 + 1;
            if (acknowledgements >= quorum) {
                commitIndex = index;
                markCommitted(index);
                return true;
            }
            return false;
        }

        void markCommitted(int index) {
            for (Node node : nodes) {
                for (Entry entry : node.log) {
                    if (entry.index <= index) {
                        entry.committed = true;
                    }
                }
            }
        }

        void printLogs() {
            for (Node node : nodes) {
                System.out.println("node-" + node.id + " " + node.log);
            }
        }
    }

    static class Node {
        private final int id;
        private final List<Entry> log = new ArrayList<Entry>();

        Node(int id) {
            this.id = id;
        }

        void appendCopy(Entry source) {
            log.add(new Entry(
                    source.index, source.term, source.command));
        }
    }

    static class Entry {
        private final int index;
        private final int term;
        private final String command;
        private boolean committed;

        Entry(int index, int term, String command) {
            this.index = index;
            this.term = term;
            this.command = command;
        }

        @Override
        public String toString() {
            return "{" + index + ",t=" + term + ",cmd="
                    + command + ",committed=" + committed + "}";
        }
    }
}

5 节点多数派是 3。第一次 Leader 加两个 Follower 共 3 个 ACK,可以提交;第二次仅 Leader 加一个 Follower 共 2 个 ACK,日志可以局部存在但不能提交。后续新 Leader 可能覆盖未提交尾部。

实际运行的预期输出:

text
firstCommitted=true, commitIndex=1
secondCommitted=false, commitIndex=1
node-0 [{1,t=3,cmd=SET version=7,committed=true}, {2,t=3,cmd=SET version=8,committed=false}]
node-1 [{1,t=3,cmd=SET version=7,committed=true}, {2,t=3,cmd=SET version=8,committed=false}]
node-2 [{1,t=3,cmd=SET version=7,committed=true}]
node-3 []
node-4 []

注意 node-2 没有第二条日志,node-3、node-4 连第一条也还没有,但第一条仍可提交,因为提交要求多数派而不是所有节点。落后节点恢复连接后需要通过日志复制或快照追平。第二条只存在于 Leader 和 node-1,不能应用到状态机,也不能向客户端承诺成功。

二十五、共识集群生产排查

25.1 无 Leader 或频繁选主

检查节点间网络延迟、丢包、GC 停顿、CPU、磁盘 fsync、选举超时、时钟告警、证书和成员地址。频繁换主不是简单“把超时调大”;要先证明是网络、磁盘还是资源停顿。

25.2 有 Leader 但写不进去

检查是否仍拥有多数派、Follower 复制延迟、磁盘满、日志持久化失败、成员配置、请求是否发到只读节点。Leader 名称存在不代表能获得 Quorum。

25.3 Follower 长期落后

检查网络带宽、快照传输、日志积压、磁盘吞吐、单条日志大小和状态机应用速度。不要让共识日志承载大块业务文件。

25.4 读到旧值

确认读 API 的一致性级别:是否允许串行一致/陈旧读,是否直接读 Follower,线性一致读是否需要 Leader/ReadIndex,多数据中心代理是否读了不同集群。

25.5 扩容后反而变慢

多数派增大、复制目标增多、新节点追日志和快照都会增加成本。3 扩 4 通常不提高故障容忍,3 扩 5 才从容忍 1 个提升到 2 个,但写确认和运维成本也增加。

二十六、常见误区

误区正确理解
多数派就是任意过半机器在线还要合法任期、日志新旧、持久化和成员配置
Leader 本地写成功就是提交必须满足协议的 Quorum 提交条件
Raft 自动保证业务恰好一次客户端超时仍要幂等 ID 和结果查询
任意 Follower 读都是最新取决于读一致性模式
节点越多越可靠且越快多数派和复制成本会增加
Redlock 等同 RaftRedlock 不是复制状态机共识协议
ZAB 就是 Raft有相似结构,但协议目标、消息和恢复实现不同
共识能替代分布式事务共识日志与跨业务资源事务是不同层次

二十七、面试标准回答

共识算法让多个非拜占庭节点在宕机、网络分区、消息延迟和乱序下,对日志顺序达成安全一致。多数派的核心是任意两个 Quorum 必有交集。Raft 使用 Term、Follower/Candidate/Leader、RequestVote 和 AppendEntries:候选日志必须足够新才能获票,Leader 通过 prevLogIndex/prevLogTerm 对齐日志,条目复制到多数并满足提交规则后推进 commitIndex。少数派旧 Leader 即使仍存活也无法提交。Paxos 通过 Prepare/Promise 和 Accept 两阶段,让新提案继承可能已经被选定的值;Multi-Paxos 使用稳定 Leader 降低连续日志槽位成本。ZAB 为 ZooKeeper 提供 Leader 恢复、历史同步和 Proposal/ACK/COMMIT 原子广播。它们解决复制状态机一致性,不自动解决业务幂等和跨服务事务。

更深的实现追问、场景题和标准回答见 共识算法与 Raft 独立面试题;持久化、冲突回退、线性读、快照和成员变更原理见 Raft 内部原理与生产治理

二十八、关联知识点

本章小结

共识的核心不是“Leader 把数据复制给 Follower”一句话,而是多数派交集、任期/提案编号、日志新旧比较、冲突回退、提交规则、状态机应用和恢复共同保证安全性。生产系统还必须处理客户端超时幂等、线性一致读、快照、成员变更、磁盘与网络故障;少任何一项,都不能仅凭“用了 Raft/ZAB”宣称强一致。