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

原文: https://yishto.com/learn/delivery/distributed-systems-and-recovery/

作者: [Yishto](https://yishto.com/about/)

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

首次发布: 2026-11-10
内容更新: 2026-11-10

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

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

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

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

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

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

## 超时表示结果未知

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

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

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

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

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

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

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

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

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

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

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

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

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

## 资料来源与延伸阅读

- [Reliability pillar](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/welcome.html)
- [In Search of an Understandable Consensus Algorithm](https://raft.github.io/raft.pdf)
- [Site Reliability Engineering](https://sre.google/sre-book/table-of-contents/)

资料核验：2026-11-10。教学示例不代表线上实测。

## 相关文章

- [让 Agent 能恢复：任务状态、重试与幂等](https://yishto.com/learn/agents/durable-agent-state/)
- [请求太多时：任务队列、缓存与背压](https://yishto.com/learn/service-data/jobs-caches-and-backpressure/)
