分布式系统入门:从故障模型到一致性、共识与事务

从一笔余额扣减出发,理解网络分区、复制、一致性模型、CAP、Raft、2PC、Saga、幂等与分布式系统验证。

一台应用服务器连接一台数据库时,很多问题可以交给本机时钟、进程锁和数据库事务。把服务拆到多台机器后,这些默认条件一起消失了:消息可能晚到或丢失,进程可能停顿后恢复,两台机器的时钟也不会严格一致。

分布式系统的难点来自这种不确定性。调用超时了,服务端可能没有收到,也可能已经执行成功,只是响应没有回来。副本没有响应,可能宕机了,也可能只是网络拥塞。两个节点都认为自己应该继续服务时,系统甚至会产生两份互相冲突的“正确结果”。

本文用一笔余额扣减贯穿这些问题。初始余额是 100 元,用户发起一笔 30 元的支付。我们的目标看起来很简单:支付成功后余额为 70 元,同一个请求无论重试多少次都只能扣一次,任何已经收到成功响应的结果都不能凭空消失。

围绕这个目标,可以一直追问四件事:系统保存了几份状态,这些状态按什么顺序变化,发生故障后谁有权继续写,以及客户端怎样确认一次操作到底有没有生效。

先看一张问题地图

分布式领域有许多容易混用的词。它们实际处理的是不同层次的问题。

问题 典型机制 它能回答什么 它不能单独保证什么
数据怎样跨节点保存 分片、主从复制、Quorum 数据放在哪里,几份副本参与读写 多个节点怎样对同一次变更达成唯一决定
客户端允许看到什么 最终一致、因果一致、线性一致 哪些并发历史被系统接受 多个对象能否作为一个事务提交
节点怎样作出共同决定 Raft、Paxos 等共识算法 谁是 Leader,哪条日志已经提交 业务操作是否可重试,跨服务如何补偿
多个资源怎样一起完成 2PC、Saga、Outbox 跨库提交或业务补偿如何组织 网络分区期间同时获得零阻塞和强保证
故障后怎样安全重试 幂等键、状态查询、Fencing Token 重复请求、迟到进程怎样处理 底层副本本身是否一致
怎样知道实现满足承诺 历史记录、故障注入、模型检查 实际执行是否违反不变量 证明所有未来故障都不会出错

一个可运行的系统通常会组合多项机制。Raft 让三个副本就日志顺序达成一致,数据库事务保证一次扣款内部的原子性,幂等键防止客户端重试造成重复扣款。少掉其中任何一层,其他机制都不会自动补位。

分布式系统的五层问题地图

上图从底向上阅读。故障模型决定算法必须面对什么,复制与共识建立可靠状态,事务把状态变化组合起来,应用协议再处理重试和补偿。最上面的验证层贯穿所有部分,因为设计文档里的保证需要由运行历史和故障实验来检查。

一、先定义系统会怎样失败

讨论“高可用”或“强一致”之前,需要先说明系统模型。一个算法只会在它假设的故障范围内成立。

节点不是只有运行和宕机两种状态

工程监控常把实例标成健康或不健康,协议面对的状态更多:

  • 进程可能直接崩溃,重启后丢失内存状态;
  • JVM 可能因为 Stop-The-World 暂停几十秒,恢复后继续执行旧任务;
  • 磁盘写入可能成功返回,也可能只进入了页缓存;
  • 节点可能还能访问部分依赖,却无法访问其他副本;
  • 软件缺陷或数据损坏可能让节点返回错误结果。

大多数业务数据库和 Raft 实现主要处理 crash fault,也就是节点可能停止或失联,但不会故意伪造互相矛盾的消息。区块链和部分安全系统还要处理 Byzantine fault:节点可以任意作恶或产生不一致输出。这两类模型需要的副本数和协议不同,不能用“都有多个节点”把它们归为一类。

网络没有可靠的故障判定

客户端调用扣款服务,等待 800 毫秒后超时。至少存在四种历史:

1. 请求没有离开客户端
2. 请求到达服务端,但尚未执行
3. 扣款已经提交,响应丢失
4. 响应仍在路上,只是超过了客户端超时

客户端只观察到“800 毫秒内没有响应”,无法从这个现象判断属于哪一种。超时是本地策略,不是远端状态证明。

同样的问题也出现在节点间。Follower 在选举超时内没有收到 Leader 心跳,只能怀疑 Leader 不再可用。它无法知道 Leader 已经崩溃,还是中间网络暂时丢包。故障检测器输出的是怀疑,协议必须保证怀疑错误时也不会破坏安全性。

时钟能测时间,却不能天然提供全局顺序

机器 A 的时钟可能比真实时间快 20 毫秒,机器 B 慢 30 毫秒。如果 A 在物理世界里先提交,B 随后提交,两条日志上的时间戳仍可能显示相反顺序。NTP 会逐步校准时钟,但网络延迟和本地振荡器误差不会消失。

因此,普通墙上时钟适合记录“事件大约何时发生”,不能直接充当跨节点并发控制协议。Google Spanner 能利用时间提供 external consistency,是因为 TrueTime API 显式返回时间不确定区间,并在提交协议里等待这个不确定区间过去;这不是给每台机器装 NTP 后自然得到的性质。Spanner 论文把时钟误差当成协议输入,而不是假设它不存在。

二、安全性与活性是两份不同的承诺

分布式算法通常分别讨论 Safety 和 Liveness。

Safety 表示坏事不会发生。例如同一任期不会提交两条位置相同、内容不同的日志,余额不会因为一次请求被扣两次。安全性一旦被破坏,之后恢复服务也无法抹掉已经产生的矛盾历史。

Liveness 表示好事最终会发生。例如网络恢复并且多数副本可用后,新的扣款最终能够完成。活性常常依赖“网络最终稳定”“超时最终足够长”之类的条件。

这一区分解释了许多看似保守的系统行为。节点无法确认自己仍是合法 Leader 时暂停写入,会降低一段时间的可用性,却保护了日志只有一条合法主线。为了“先让请求成功”而允许两个分区各自提交,恢复时就必须面对冲突合并;余额扣减通常没有安全的自动合并规则。

1985 年发表的 FLP 结果证明:在完全异步的消息系统里,只要允许一个进程崩溃,就不存在一个确定性共识协议能够保证每次运行都终止。它没有证明共识无法实现,也没有说 Raft 一定会卡住。结论的边界是“完全异步模型”“确定性协议”和“保证终止”。实际协议使用超时、随机化和最终同步假设获得进展,同时始终保护安全性。FLP 原论文给出的正是这个模型下的不可能性结果。

三、没有全局时钟,事件如何排序

一笔支付会跨过网关、支付服务、账户服务、数据库和消息队列。日志里虽然都有时间戳,仅按时间排序经常会拼出一条从未发生过的调用链。

happens-before 只记录可以证明的因果关系

Lamport 在 1978 年用 happens-before 关系描述事件偏序。若同一进程中事件 A 发生在 B 前,或者 A 是发送消息、B 是接收该消息,那么可以写作 A → B。这个关系还满足传递性。

如果 A 与 B 之间没有任何因果路径,它们就是并发事件。并发不要求物理时间完全相同,只表示系统现有信息无法证明谁影响了谁。

假设支付服务先写入扣款记录,再发送“支付成功”消息:

写入扣款记录 → 发送消息 → 消费消息 → 发放权益

这条链可以确定因果顺序。另一个用户同时修改头像,与本次支付没有通信路径,两个事件可以视为并发。强行用一个全局序号排列所有无关事件会增加协调成本,却未必给业务带来收益。

Lamport 的原始论文给出了逻辑时钟:进程在本地递增计数,发送消息时携带计数,接收方把本地值推进到大于双方计数的位置。若 A → B,逻辑时间一定满足 L(A) < L(B);反过来不成立,两个逻辑时间的大小不能证明存在因果关系。

向量时钟保存每个参与者观察到的进度,因此可以进一步判断两个版本是否并发。代价是元数据会随参与者数量增长,成员变化和版本压缩也更复杂。Dynamo 用向量时钟识别并发版本,再把部分冲突交给应用合并。Dynamo 论文明确把高可用与应用参与冲突解决放在同一套设计里。

顺序还必须和业务语义绑定。

“最后写入获胜”听起来简单,最后却取决于采用哪种顺序:服务端接收时间、数据库提交时间、客户端时间还是逻辑版本。用客户端物理时间做 Last-Write-Wins 时,一台快钟设备可能覆盖后来发生的更新。

购物车添加商品可以按集合并集处理,并发添加通常都能保留。余额扣减的语义不同:两个副本分别从 100 扣 30,合并时既不能简单取 70,也不能把两个结果相加。选择一致性机制之前,要先写清楚状态的合并函数和业务不变量。

四、复制先带来冗余,再带来分歧

保存三个副本可以承受一台机器丢盘,但副本不会自动拥有相同状态。任何复制方案都必须定义写入发给谁、何时向客户端确认、读取从哪里取,以及落后副本怎样追赶。

同步复制与异步复制在确认点上不同

最简单的 Primary-Replica 流程是:客户端把请求发给 Primary,Primary 写入本地日志,再把日志传给 Replica。

异步复制可以在 Primary 本地提交后立即返回。它延迟低,但 Primary 在复制前永久损坏时,客户端已经收到成功的写入可能丢失。

同步复制会等待一个或多个 Replica 确认再返回。数据丢失窗口缩小了,写延迟和故障时的阻塞概率随之增加。这里的“同步”还要继续追问:副本确认的是收到内存、写入操作系统缓存,还是稳定落盘。协议文档如果只写“已同步到副本”,仍然缺少关键的持久性边界。

复制确认点决定客户端能相信什么

一次成功响应等于系统对外作出承诺。图中确认点越靠后,需要等待的组件越多;故障后保留结果的条件也越强。确认规则和恢复规则决定可靠语义,副本数量只说明系统有多少份状态。

Quorum 是交集条件,不是共识的替代品

一些多主或 Leaderless 系统用 N、W、R 描述读写:

  • N 是一个数据项的副本数;
  • W 是一次写入等待的副本数;
  • R 是一次读取查询的副本数。

当 W + R > N 时,读集合与最近一次成功写集合至少有一个副本相交。若 N = 3、W = 2、R = 2,读取理论上会接触到一个保存新版本的副本。

这个算式本身不足以证明线性一致。系统还要正确识别版本、处理并发写、保证参与集合没有被故障转移规则偷偷改变,并在读取时解决多个版本。Sloppy Quorum 为可用性选择临时节点时,名义上的 W + R > N 甚至可能没有落在同一组固定副本上。

Quorum 描述集合交集。共识还要解决提案竞争、成员变更、Leader 任期和唯一提交历史。两者会一起出现,却回答不同问题。

五、一致性模型定义客户端允许看到的历史

“数据一致”太含糊。两个副本最终相同、同一用户读到自己的写入、所有用户都按同一顺序观察更新,都是一致性要求,强度和成本差别很大。

一致性模型可以理解为一组合法历史。系统承诺某个模型,就是承诺客户端观察到的调用与返回能够被解释为这组历史中的一个。Jepsen 的一致性模型图把单对象模型和事务模型分成两条轴,也提醒我们:部分模型不可直接按强弱排成一条直线。

最终一致只承诺没有新写入时会收敛

最终一致允许副本暂时返回不同值。只要更新停止、消息最终送达,副本会收敛到同一状态。这个定义没有天然包含“读己之写”“单调读”或冲突语义。

用户刚把昵称改成 B,刷新页面又看到 A,就可能是读取落后副本。通过会话粘滞、版本令牌或读取 Primary,可以额外提供 read-your-writes。把这些会话保证写出来,比笼统声称“我们是最终一致,所以偶尔不一致”更有操作价值。

最终一致也不等于随便覆盖。CRDT 为一类数据结构定义了可交换或可合并的状态,使副本在不协调接收更新后仍能确定性收敛。CRDT 原始论文给出了 state-based 和 operation-based 两类方法的充分条件。计数器、集合和协同编辑可以利用这些结构;余额上限、唯一用户名和库存不能因为套上 CRDT 名称就自动获得正确业务语义。

因果一致保留事件之间的依赖

如果用户先发布文章,再发表评论,其他观察者不应该先看到评论、后看到文章。两次写入存在因果关系。另一个用户同时发布无关文章时,系统不必让所有人同意两篇文章的绝对先后顺序。

因果一致保留 happens-before 关系,同时允许并发事件按不同顺序出现。它比最终一致提供更多直觉保证,又避免为所有无关操作建立全局顺序。实现需要携带依赖信息,并确保读取不会越过尚未满足的依赖。

线性一致让单个对象看起来只有一份实时副本

线性一致要求每个操作看起来在调用与返回之间某个瞬间原子生效,并尊重实时先后。如果写操作已经返回成功,之后开始的读取必须看到该写或更新的值。

Herlihy 和 Wing 的线性一致性论文把这种性质定义为并发对象的正确性条件。它很适合锁、Leader 租约、配置版本和余额这类需要单对象实时语义的状态。

线性一致不等于所有请求都经过一台机器,也不要求并发调用按墙上时钟排序。两个操作时间窗口重叠时,系统可以选择其中任一合法顺序;只要 A 已经返回、B 才开始,就必须把 A 排在 B 前面。

串行化处理事务,线性一致处理单次操作的实时顺序

Serializable 要求多个事务的效果等价于某种串行执行顺序。这个串行顺序不一定尊重现实时间:事务 T1 已经完成,稍后开始的 T2 在等价串行历史里仍可能排在 T1 前,只要结果满足串行化。

Strict Serializable 同时要求事务可串行化并尊重实时顺序。它可以理解为事务世界里的强实时模型。谈数据库“一致性”时,必须说明是单 Key 线性一致、事务串行化、快照隔离,还是某个会话保证。

对于转账,单独保证账户 A 和账户 B 的余额读写都线性一致,仍不能保证“从 A 扣 30、给 B 加 30”作为一个整体出现。多对象原子性属于事务问题。

六、CAP 只描述网络分区时的一次硬选择

CAP 经常被简化成“一致性、可用性、分区容错三选二”。这个口号丢掉了定理的条件和术语定义。

Gilbert 与 Lynch 形式化的模型中,Consistency 指单一读写寄存器的原子一致性,可近似理解为线性一致;Availability 要求每个发送到非故障节点的请求最终都得到响应;Partition 表示网络可以丢失或无限延迟节点间消息。CAP 证明说明,在发生分区时,系统无法同时保证这两项定义下的一致性和可用性。

假设账户余额有 A、B 两个副本,网络把它们隔开:

客户端 1 → 副本 A:扣款 30
客户端 2 → 副本 B:读取余额

如果 A 接受扣款,而 B 必须立即响应读取,B 不知道 A 是否已经提交。返回 100 会违反线性一致,猜 70 没有证据,等待网络恢复又违反 CAP 定义的可用性。

CP 路线通常让无法形成多数派的一侧拒绝写入或读取,保存单一历史。AP 路线允许两侧继续处理,再用版本与业务规则合并。选哪条取决于状态语义。商品评论可以容忍暂时分叉,余额和唯一所有权很难接受两边都成功。

CAP 没有规定无分区时期的延迟,也没有覆盖数据模型、事务隔离和故障恢复成本。正常运行时,系统仍要在更强一致性和更低延迟之间选择。PACELC 用一句经验性表达补充了这点:有 Partition 时在 Availability 与 Consistency 间选择,Else 在 Latency 与 Consistency 间选择。它适合做设计提问,不应代替对具体协议的分析。

七、共识让多个节点维护一条可恢复的决定历史

共识问题要求参与节点对某个值达成一致,并满足几项基本性质:已经决定的值不能互相矛盾,决定值来自合法提案,满足进展条件时最终能作出决定。

复制状态机把业务状态变化写成确定性命令序列。只要所有副本按相同顺序执行相同命令,它们就能得到相同状态。于是问题转成:怎样让副本对日志顺序达成共识。

Raft 把问题拆成任期、选举和日志复制

Raft 集群中的节点处于 Follower、Candidate 或 Leader 状态。时间被划分成单调递增的 term。Follower 在选举超时内没有收到有效心跳后增加 term、转为 Candidate,并向其他节点请求投票。拿到多数票的 Candidate 成为该 term 的 Leader。

客户端只向 Leader 提交写入。Leader 把命令追加到本地日志,再通过 AppendEntries 复制给 Follower。条目被当前任期的多数副本保存后,Leader 可以推进 commitIndex,把命令应用到状态机并向客户端返回。

Raft 中一条日志从接收到提交的路径

Raft 的完整安全性依赖多项约束:同一 term 每个节点最多投一票;日志较新的候选人才可能当选;Leader 用前一条日志的 index 和 term 检查前缀;新 Leader 只能通过提交当前 term 的条目,间接确认旧 term 条目已经提交。只记住“写到多数派就成功”会漏掉这些任期与日志匹配条件。

Raft 扩展论文把 Leader Election、Log Replication 和 Safety 分开说明,并用重叠多数派处理成员变更。它适合作为共识入门,因为协议结构明确;实际实现仍要处理快照、日志压缩、磁盘持久化、批处理、流控和线性一致读。

多数派保护的是交集

三个节点的多数派是两个,五个节点的多数派是三个。任意两个多数集合必然相交,交点会把已经接受的历史带入下一轮决策。

三个节点可以在一个节点故障时继续形成多数。两个节点故障后剩余节点不能继续提交,但已提交数据仍应保持安全。容忍 f 个 crash fault 并保持可用,通常需要 2f + 1 个投票成员。

增加偶数个投票节点不一定提高可容忍故障数:四节点仍需要三个多数,只能容忍一个节点失效,却让每次提交多等待一个确认。副本部署还要考虑机架、可用区和共同依赖。五个进程都在同一宿主机上,协议成员数看起来足够,故障域仍只有一个。

Leader 租约需要 Fencing Token 才能挡住旧进程

节点 A 获得锁后发生长时间 GC。租约到期,节点 B 获得新租约并开始写。A 恢复时并不知道自己已经失去资格,继续向下游写入就形成僵尸 Leader。

仅在锁服务中检查租约不够,下游资源必须拒绝旧持有者。常用做法是给每次授权分配单调递增的 Fencing Token:A 持有 41,B 持有 42;存储服务记录已见到的最大 Token,之后拒绝 41 的写入。

Token 的比较发生在真正产生副作用的资源处。如果下游 API 不接收或不校验 Token,“我们用了分布式锁”仍不能证明旧进程无法写。

八、共识与分布式事务解决不同问题

Raft 可以让一个分片的副本同意日志顺序。一次转账同时修改账户 A 和账户 B,若两个账户属于不同分片,还要决定两个参与者是一起提交,还是一起回滚。

2PC 把提交分成 Prepare 与 Commit

Two-Phase Commit 中,Coordinator 先向所有 Participant 发送 Prepare。每个参与者检查约束、锁定资源、把“已准备”记录持久化,再返回 Yes 或 No。只有全部返回 Yes,Coordinator 才记录 Commit 并通知所有参与者;任何一个返回 No 都应 Abort。

Coordinator        账户分片 A        账户分片 B
    | Prepare           |                 |
    |------------------>|                 |
    | Prepare           |---------------->|
    |              写 prepare 日志   写 prepare 日志
    |<------------------| Yes             |
    |<------------------------------------| Yes
    | 写 commit 决定
    | Commit            |                 |
    |------------------>|---------------->|

参与者一旦投 Yes,就不能自行决定 Abort,因为其他参与者可能已经收到 Commit。此时 Coordinator 故障会让参与者持锁等待,经典 2PC 因此可能阻塞。

2PC 的 Atomic Commit 和共识的提案模型并不相同。Gray 与 Lamport 的Consensus on Transaction Commit解释了两者关系:经典 2PC 可以看成 F = 0 的 Paxos Commit 特例;用共识复制每个参与者的决定可以消除单个 Coordinator 故障造成的阻塞点,但会增加参与者和消息成本。

Saga 用可补偿步骤换取长事务的可运行性

跨服务业务往往不适合长期持有数据库锁。订单、支付、库存和履约各自拥有数据,强行拉进一个全局 2PC 会扩大故障域,也要求所有资源都支持同一事务协议。

Saga 把长事务拆成多个本地事务,每一步提交后触发下一步;中途失败时执行已经定义好的补偿事务。1987 年的 Saga 论文讨论的正是长事务持锁导致的并发问题。

补偿不是数据库回滚。退款可能有手续费,已发送的短信无法撤回,只能再发送更正;库存释放前也可能已经触发仓库作业。每个步骤需要明确可补偿性、补偿截止点和人工处理入口。

Outbox 解决业务提交与发消息之间的裂缝

服务先提交数据库、再发送消息时,进程可能在两步之间崩溃,产生“余额已扣但事件没发”。先发消息、后提交数据库则会产生相反问题。

Transactional Outbox 把业务变更和待发送事件写入同一个本地事务。独立 Relay 扫描 Outbox 并投递消息。它把原来的原子性问题缩小到单库事务内,但投递通常是 at-least-once,消费者仍须幂等。

BEGIN;

UPDATE account
SET balance = balance - 30
WHERE account_id = 'A'
  AND balance >= 30;

INSERT INTO outbox(event_id, topic, payload, status)
VALUES ('pay-20261003-001', 'payment.debited', '{...}', 'NEW');

COMMIT;

Relay 发送成功后还没来得及标记 SENT 就崩溃,会再次发送同一 event_id。消费者用事件 ID 建立去重记录,或者让处理操作本身具备幂等条件,才能把重复投递转成可接受行为。

九、把一笔扣款完整走一遍

现在给开头的支付请求补上工程约束:账户余额初始为 100,扣款 30,请求 ID 为 pay-20261003-001。账户分片由三个 Raft 副本组成,写入必须由 Leader 接收。

第一步:客户端生成业务幂等键

幂等键要绑定业务动作,而不是一次 HTTP 调用。客户端重试时继续使用同一个 ID,服务端还要校验同一个 ID 的关键参数一致,防止调用方误把两笔不同金额的支付复用成一个请求。

服务端在同一数据库事务中写入幂等记录和余额变化:

BEGIN;

WITH claimed AS (
    INSERT INTO payment_request(request_id, account_id, amount, status)
    VALUES ('pay-20261003-001', 'A', 30, 'PROCESSING')
    ON CONFLICT (request_id) DO NOTHING
    RETURNING request_id
), debited AS (
    UPDATE account
    SET balance = balance - 30,
        version = version + 1
    WHERE account_id = 'A'
      AND balance >= 30
      AND EXISTS (SELECT 1 FROM claimed)
    RETURNING account_id
)
UPDATE payment_request
SET status = CASE
    WHEN EXISTS (SELECT 1 FROM debited) THEN 'SUCCEEDED'
    ELSE 'FAILED'
END
WHERE request_id = 'pay-20261003-001'
  AND EXISTS (SELECT 1 FROM claimed);

COMMIT;

这段 PostgreSQL 风格的示例只有在 claimed 插入成功时才会扣款。重复请求应在事务外读取原记录并校验账户与金额是否相同。真实实现还要根据隔离级别、受影响行数和失败状态细分结果。幂等占位和业务写入必须共享原子边界;若先查 request_id 不存在,再单独扣款,两个并发请求都可能穿过检查。

第二步:Leader 复制命令并等待提交

Leader 把包含请求 ID 和条件更新的命令追加到日志。多数副本持久化当前 term 的条目后,Leader 推进提交位置并应用状态机。Follower 只能按已提交顺序执行,不能凭本地收到一条日志就提前对外暴露结果。

如果 Leader 在本地追加后、多数复制前崩溃,这条未提交日志可能被新 Leader 覆盖,客户端应查询状态或重试。若多数已经提交、响应发送前 Leader 崩溃,新 Leader 会保留该日志;客户端重试时由幂等记录返回原结果。

两种场景在客户端都表现为超时,所以 API 最好提供按幂等键查询结果的接口。客户端状态机可以写成:

NEW → SUBMITTING → SUCCEEDED
                 ↘ FAILED
                 ↘ UNKNOWN → QUERYING → SUCCEEDED / FAILED / RETRYABLE

UNKNOWN 是分布式 API 的正常状态。把它粗暴映射成失败并立刻换一个请求 ID 重试,会绕开服务端去重。

第三步:跨账户转账选择原子提交或业务补偿

若 A 与 B 在同一分片,一个数据库事务和同一条复制日志就能原子修改两行。若两个账户位于不同分片,系统需要明确选项:

方案 适合场景 主要代价
2PC / 分布式 SQL 事务 必须同时提交,参与者支持协议 跨分片锁、额外往返、故障时等待
Saga 步骤可以补偿,允许中间状态 补偿语义复杂,需要对账和人工兜底
资金账本 + 异步派生余额 金融类状态,需要完整审计 读取模型和账本投影更复杂
调整分片键,让关联账户共置 高频事务的参与者可预先确定 热点、迁移与数据模型受约束

不能只在接口文档里写“最终一致”。需要说明中间状态对谁可见,最长可能持续多久,失败后由谁推动恢复,以及对账发现永久差异时怎样处理。

第四步:响应之后仍要保留证据

一次扣款至少应留下请求 ID、客户端、账户、金额、路由分片、Leader term、日志 index、提交时间、结果状态和关联事件 ID。日志需要能把客户端超时、Leader 切换和最终账本记录串起来。

这些字段不是为了让日志显得完整。发生“用户说扣了两次”时,排障者要回答:两次 HTTP 是否共享业务 ID,两个请求是否进入同一分片,哪条日志被提交,数据库唯一约束是否生效,Outbox 是否重复投递,以及下游是否重复消费。

十、最常见的错误都发生在边界上

超时后直接重试写操作

读请求通常可以安全重试,写请求要先判断幂等性。支付、创建订单和发券使用随机新 ID 重试,相当于创建新的业务动作。正确流程是复用业务幂等键,或先查询原操作状态。

指数退避与随机抖动只能减少重试风暴,不能提供 exactly-once。网络中的 exactly-once 往往由“至少一次发送 + 接收端持久化去重 + 幂等业务操作”组合出来,而且去重记录必须覆盖可能的最晚重试窗口。

把健康检查当成 Leader 权威证明

旧 Leader 可能通过本地健康检查,却已经失去多数派。写路径必须校验当前 term、租约或 Fencing Token。Kubernetes Pod Ready 只能说明容器当前满足就绪探针,不代表它仍有权修改共享资源。

用副本数推断数据安全

三个副本若都异步接收、都位于同一故障域,可能同时丢失已确认数据。需要检查确认点、刷盘语义、机架与可用区分布、备份恢复,以及成员变更期间是否仍保持 Quorum 交集。

把最终一致当作冲突处理方案

最终收敛只规定结果会靠拢,没有告诉应用哪个结果正确。并发更新同一收货地址时可以选 LWW,但要接受覆盖;购物车可以集合合并;库存不能把两个都售罄的分支直接并起来。冲突策略属于数据类型和业务规则。

把消息队列投递语义当成业务处理语义

Broker 的 at-least-once 描述消息可能重复送达。消费者读到消息、写数据库、提交 Offset 跨越了多个状态。若数据库已提交而 Offset 未提交,消息会再次投递。业务表的唯一键、Inbox 表或条件更新才决定重复消费是否安全。

用分布式锁包住所有问题

锁服务本身需要共识或其他一致性基础。锁拿到后还会遇到租约过期、持有者暂停、网络分区和下游不校验 Token。锁适合协调访问资格,不替代事务、幂等和资源端的并发控制。

十一、怎样验证一个分布式系统的承诺

单元测试可以验证状态机逻辑,常规集成测试很难覆盖消息重排、节点暂停和提交响应丢失。分布式验证需要同时保存操作历史、制造故障并检查不变量。

先把承诺写成可检查的不变量

对余额系统,可以从这些不变量开始:

  • 同一个 request_id 最多产生一笔已提交扣款;
  • 任何成功返回的扣款,在允许的故障模型内都能从多数副本恢复;
  • 转账完成后,借贷两侧总额守恒,手续费等外部流量单独入账;
  • 版本号与账本序号单调增加,旧 Fencing Token 的写入被拒绝;
  • 系统返回 Unknown 时,最终可以通过状态查询收敛到确定结果。

“接口可用”“数据基本一致”无法自动检查。把承诺写成输入、历史和期望关系,测试程序才能寻找反例。

记录调用与完成,而不只记录最终值

线性一致检查需要知道每次操作的 invocation、completion、输入、输出和客户端进程。只有最终数据库快照,无法判断一个读是否发生在写返回之前,也无法区分并发历史。

Jepsen 把历史表示为调用与完成事件,并在运行中注入网络分区、进程终止和时钟异常,再用模型检查器判断历史是否合法。Jepsen 项目既能检查线性一致寄存器,也能用 Elle 分析事务依赖。

故障注入要落在协议窗口里

随机杀进程有价值,但命中关键窗口的概率可能很低。更有效的测试会针对协议阶段:

Leader 本地追加后、多数复制前
多数复制后、响应客户端前
Participant 写入 PREPARED 后、收到 COMMIT 前
Outbox 发送成功后、标记 SENT 前
租约过期后、旧持有者恢复时

每个窗口都对应一种结果未知或权威转移。测试需要验证恢复路径,而不只验证服务能重新启动。

可观测性要能解释协议状态

CPU、内存和 QPS 无法解释一次提交为什么停住。共识层至少应暴露当前角色、term、commit index、applied index、各 Follower match index、选举次数和提案延迟。事务层应记录 Prepare 数量、阻塞事务、锁等待、恢复队列和补偿失败。应用层要用业务 ID 串起调用链。

告警也要贴近用户承诺。Leader 每次切换不一定需要报警,但长时间无法形成多数、提交延迟持续上升、已确认写在恢复后缺失,都直接触及系统保证。

十二、从单机系统演进时怎样做选择

分布式机制有实际成本。多一次网络往返会增加尾延迟,多一个副本会增加写放大和运维状态,跨分片事务会把局部故障扩散到更多参与者。只有在容量、故障域或组织边界已经提出要求时,复杂度才有回报。

第一步是写不变量和故障预算。

先回答数据丢一条是否可重建、允许读到多旧、重复执行会造成什么、最长可以不可用多久。把“高可用”改写成可量化条件:允许哪个区域故障,恢复时间目标是多少,恢复点目标是多少。

第二步是尽量缩小协调范围。

能在单库事务完成的操作,不急着拆成跨服务 Saga。必须分片时,让强一致操作尽量落在同一分片。只对需要实时顺序的状态走 Leader 或 Quorum,无关的分析数据可以异步复制。

第三步是明确每个边界的交付语义。

为数据库写入、RPC 和消息分别写清楚:最多一次、至少一次,还是允许结果未知;幂等键由谁生成;去重保存多久;超时后查哪个状态;永久失败进入哪里。

第四步是同时设计恢复路径。

备份需要演练恢复,2PC 需要处理 in-doubt 事务,Saga 需要补偿重试与人工接管,Raft 需要快照和成员变更。系统设计图若只有从左到右的成功箭头,最重要的一半还没画出来。

第五步是用历史和故障实验验证。

在隔离环境制造分区、延迟、重启、磁盘写失败和旧进程恢复。保留完整操作历史,用不变量检查结果。上线后继续观察重试率、Unknown 状态、Leader 抖动、复制落后和补偿积压。

十三、几个容易混淆的问题

有数据库事务,还需要理解共识吗?

单机数据库内部已经替应用处理日志、锁和崩溃恢复。数据库变成高可用集群后,主库选举、日志复制和故障转移仍依赖共识或专用复制协议。应用不必实现 Raft,却需要理解成功确认点、Follower 读取语义和故障切换期间会发生什么。

使用 Raft 后,服务就是线性一致的吗?

Raft 为复制日志提供安全基础,服务还要正确实现读路径。直接读取落后 Follower 会返回旧值;Leader 若没有确认自己仍掌握权威,也可能执行过期读取。ReadIndex、Leader Lease 或把读编入日志各有前提。状态机应用延迟和客户端重试同样会影响对外语义。

分布式事务一定应该改成 Saga 吗?

账户记账、库存扣减等业务可能需要强原子性,数据库提供的跨分片事务也可能已经足够成熟。Saga 适合能够定义补偿、允许中间状态的长流程。补偿不可逆或中间状态风险很高时,强事务仍然值得它的协调成本。

最终一致是不是性能一定更高?

减少同步协调通常能降低写延迟并提高分区时的可用性,但冲突检测、修复读取、反熵同步和应用合并都会消耗资源。若业务最终通过全局锁或人工对账恢复正确性,整体成本可能更高。性能结论要在真实冲突率、网络拓扑和读写比例下测量。

三个节点是不是永远比一个节点可靠?

三个节点引入了对节点故障的冗余,也增加了网络、配置和协议状态。若故障独立、确认规则正确、恢复经过演练,集群能够提高可用性和持久性。若节点共享电源、凭据或错误配置,同一故障会同时击穿全部副本。副本数量只有结合故障域才有意义。

十四、入门之后按什么顺序继续学

这篇文章建立的是分布式系统的共同语言,后续可以沿五条主线继续学习:

  1. 复制与共识:Raft 日志复制、快照、成员变更、线性一致读;
  2. 分布式事务:2PC、MVCC、Timestamp Ordering、Saga 与 Outbox;
  3. 高可用存储:一致性哈希、Quorum、反熵、读修复与 CRDT;
  4. 消息系统:分区顺序、消费组、重复投递、事务消息与积压恢复;
  5. 稳定性工程:超时、重试、限流、熔断、隔离、故障注入与容量规划。

阅读任何系统设计时,都可以先画出节点、消息和持久化边界,再问五个问题:成功响应在哪个时刻发出,谁能在故障后继续写,旧节点如何被拒绝,重复请求怎样识别,哪份运行证据能证明承诺成立。

分布式系统没有消除不确定性的通用算法。成熟设计会把不确定状态显式保留下来,用任期、版本、幂等键、日志和补偿协议逐层收窄,直到业务能够接受或人工能够接管。

参考资料