← 系统设计与交付 · 第 1 篇

从单体到多服务:先设计失败与恢复

用订单与库存的独立案例解释超时、幂等、补偿、共识与恢复目标,检验服务拆分后新增的失败状态。

学完这一篇,你能做到

为订单预留设计可重复恢复的状态转换,并分别验证多数派计数与备份恢复目标。

订单服务已经能在一台机器上运行,现在准备把库存拆成独立服务。拆分之后,订单创建成功、库存扣减失败、回复在网络里丢失,都会变成必须处理的业务状态。先写清这些状态的含义,再决定服务边界,才能知道增加部署单元是否解决了现有问题。

本文使用虚构的教材订购业务:订单 O-17 购买两套教材,库存服务负责预留,订单服务负责确认。所有 ID 与数量都是教学数据。目标是防止重复预留,并让未完成的订单可以被查出和恢复。

拆分的理由要对应实际约束

单体应用可以按订单、库存、结算分模块,通过同一个数据库事务维护约束。模块化单体仍是一种合理起点:调用过程容易跟踪,发布单元少,事务范围清楚。只有出现独立扩容、团队所有权、故障隔离或不同交付节奏等需求时,拆分才有具体收益。

多服务增加了协议、超时、身份验证、版本兼容和观测成本。订单服务依赖库存服务,并不意味着两个服务具有独立可用性。如果订单确认必须同步得到库存回复,那么库存停机时,增加订单副本也不能完成确认。把依赖画在请求链路上,比数容器更有用。

先列业务约束:库存不能变成负数;同一个订单只能预留一次;用户应能查询处理中状态;无法确定结果时不能宣称失败后立即创建另一份订单。然后分别给查询、创建和后台恢复定义延迟与可用性目标。不同操作可能采用不同的一致性要求。

超时表示结果未知

一次远程调用至少有请求发出、对方提交、回复返回三个阶段。回复丢失时,调用方看到超时,对方却可能已经扣减库存。因此重试应复用稳定的业务键,例如订单 ID 与预留动作的组合。库存端要持久化处理记录,并让重复请求返回已有结果。

仅在客户端记住“已经发送”不够,进程重启就会丢失记录。幂等键也要绑定参数:同一个键先要求两套教材,之后要求三套,应该拒绝冲突,不能把第二次请求悄悄当作成功。处理记录和库存变化应在库存系统自己的事务边界内保持一致。

可以把订单状态设计成 pending、reserved、confirmed、cancelled。状态转换必须有条件,已确认的订单不能被旧的超时任务直接取消。后台恢复任务查询库存预留结果,再推进订单;如果预留失败,则记录取消原因。需要释放库存时,补偿动作也要幂等,并验证释放的是对应预留。

跨服务恢复要有持久化线索

两个服务各自提交数据库事务,不会自动得到一个跨服务原子事务。若选择最终一致的流程,就要接受中间状态,并定义最长等待、重试预算和人工处理入口。补偿只能撤销业务效果中允许撤销的部分,已经发出的实体教材无法靠回滚一条记录追回。

消息驱动流程也有提交与发送之间的缺口。可以在同一事务里写业务记录和待发送事件,再由后台发布器发送;消费者按事件 ID 去重。这个 outbox 方案仍需处理重复发送、积压和重放。必须记录事件版本与关联订单,否则排查时无法区分第一次请求和后续重试。

恢复任务需要可以重复运行,且只挑出满足条件的记录。例如 pending 超过教学设定的等待时间才进入核查队列,核查之前重新读取当前状态。处理失败应保留下一次重试时间与最后错误分类,避免一次无限循环占满资源。

多数派解决的是受约束的共识问题

Raft 把复制日志的共识过程分成领导者选举、日志复制与安全性规则。固定五个成员的集群,多数派至少需要三个不同成员。两个多数派会相交,这为共识规则提供基础;实际安全性还依赖任期、日志匹配、选举限制和持久化等条件。

网络分区可能让三台与两台成员互相无法通信。若某项写入必须经多数派确认,少数一侧应停止确认这类写入。CAP 讨论的是分区期间特定一致性与可用性定义下的限制,不能简化成所有系统在任何时刻都只能选两个属性。

下面只检查固定成员集合中的票数,不实现 Raft,也不能证明系统能处理任意恶意节点。重复票不增加人数,未知成员要被拒绝。

members = {"A", "B", "C", "D", "E"}
def majority(votes):
    distinct = set(votes)
    if not distinct <= members:
        raise ValueError("unknown member")
    return len(distinct) >= len(members)//2 + 1
assert majority(["A", "B", "C"])
assert not majority(["A", "A", "B"])
assert not majority(["D", "E"])
print(majority(["A", "B", "C"]))

实际输出是 True。三个不同成员达到多数;A、A、B 只有两名成员,D、E 也只有两名。断言核验了计数约束,没有模拟日志复制、时钟、磁盘故障或成员变更。把这个函数放进服务并不会让数据库自动具有共识保证。

副本、备份与恢复目标分别验证

副本可以帮助承受机器故障,但误删除可能被复制到所有副本。备份需要保留可恢复的历史,并与当前故障域分开。多副本若共用同一电源、网络或管理员凭据,仍可能同时失败。跨地域也会增加延迟、数据同步和运营成本,应根据目标选择。

RTO 是希望在多长时间内恢复服务;RPO 是可以接受多少时间窗口的数据损失。例如教学目标写成“恢复不超过两小时,最多丢失最近十五分钟数据”,就应验证备份频率、恢复权限和日志补放是否支持这些目标。这些数值是要求,不是本练习的实测成绩。

恢复演练应在隔离环境中选择备份、恢复数据、核对订单与库存关系,再运行查询和创建检查。记录从开始到可用的时间,以及恢复点之后缺失的数据范围。只看到备份任务成功,无法说明文件可读、结构兼容或权限可用。

练习:把未知结果送回可解释状态

为 O-17 写一张状态转换表,每条边注明触发事件、前置条件、持久化证据和失败后的下一步。模拟预留成功但回复丢失、重复请求、订单确认前进程重启、补偿重复到达,以及长期无法联系库存服务。

验收时,同一订单重复请求不能多扣库存;参数冲突必须明确拒绝;重启后能根据持久记录继续;未解决状态可以查询,不能伪装成已完成。增加恢复演练记录,分别报告服务恢复时间与可确认的数据损失。若无法满足目标,应调整架构或目标,并保留原因。

资料来源与延伸阅读

资料核验:2026-11-10。本文为原创工程讲解,教学示例不代表线上实测。