分布式系统里的部分失败为什么比整体失败更难处理?
简化版
部分失败时,有些节点或步骤成功、有些失败,还有些结果因超时而未知,系统无法像单机事务那样统一回滚。难点是区分已提交、未执行和未知状态,并通过幂等、状态机、可靠事件、补偿与对账让流程最终进入可解释的终态。
详细版
整体失败通常容易识别并停止;部分失败可能表现为请求已写数据库但响应丢失、三个并行依赖只有一个超时、跨服务流程走到一半。调用方看到的网络错误不能证明业务失败,盲目重试会重复副作用,盲目回滚又可能撤销不了已被外部观察的动作。
处理时先为每个步骤持久化业务状态和唯一操作 ID,明确成功、失败、处理中、未知和待补偿等状态;本地更新与待发送事件用事务或 outbox 绑定;重试必须幂等且有预算;不可原子回滚的步骤使用业务补偿,并通过超时扫描和对账发现长期悬挂。
方案要按业务不变量选择。能在一个数据库事务内完成的动作不要过早拆散;跨服务后则接受中间状态,向用户诚实展示“处理中”。对并行聚合请求,还要定义部分结果是否可用、缺失字段如何标记,不能把超时依赖默认为空数据。
完整版教学
一、部分失败造成三值结果
单机函数常被简化为成功或抛异常,远程调用至少有成功、明确失败和未知三种结果。超时属于未知:请求可能没到服务端,也可能已经提交但响应丢了。这个第三种状态使自动重试和回滚都需要额外协议。
例如扣款服务提交成功后网络中断,订单服务若把超时当失败并再次创建扣款,会重复扣钱;若把它当成功,也可能在请求根本未到达时错误发货。正确做法是使用稳定支付号查询或同键重试。
记忆钩子:部分失败最关键的问题不是“哪里报错”,而是“哪些事实已经对外成立,哪些仍然未知”。
二、跨服务为什么不能统一回滚
数据库本地事务控制同一资源管理器中的提交与回滚。跨服务后,步骤可能使用不同数据库、消息系统和外部 API;某一步成功后还可能向用户发短信或把包裹交给物流,这些副作用不能靠数据库 ROLLBACK 撤回。
创建订单成功 -> 冻结库存成功 -> 扣款成功 -> 发券失败
已成立事实:订单、库存冻结、扣款
待处理:重试发券,或按业务规则退款并释放库存
因此要在设计时决定哪些步骤可重试,哪些有补偿动作,哪些一旦发生只能向前修复。补偿本身也是新的业务操作,可能失败,必须幂等且可观察。
三、状态机如何保存进度
流程状态不能只放在调用栈内,否则协调者重启后不知道走到哪。应持久化流程实例、当前阶段、步骤结果、重试次数和版本,并限制合法迁移。例如 PAYING 超时后进入 PAYMENT_UNKNOWN,查询确认成功才进入 PAID。
| 状态 | 含义 | 允许动作 |
|---|---|---|
| PENDING | 尚未开始 | 发起下一步 |
| PROCESSING | 已发起、结果未定 | 查询、同键重试 |
| SUCCEEDED | 业务事实成立 | 推进流程 |
| FAILED | 明确不可恢复失败 | 终止或补偿 |
| COMPENSATING | 正在撤销已完成步骤 | 幂等重试补偿 |
| MANUAL_REVIEW | 自动规则无法判断 | 冻结相关操作并人工处理 |
状态更新使用版本条件,避免两个调度器同时推进同一实例。
四、可靠事件如何避免“写库成功但发消息失败”
服务更新本地状态后直接发 MQ,会在两步之间留下故障窗口。outbox 将业务写和事件记录放进同一本地事务,后台发布器可反复发送;消费者按事件 ID 幂等。这样把“恰好一次发送”转化为“至少一次投递加一次业务效果”。
BEGIN;
UPDATE orders SET status='PAID' WHERE id=42 AND status='PAYING';
INSERT INTO outbox(event_id, aggregate_id, type)
VALUES ('evt-9001', 42, 'OrderPaid');
COMMIT;
发布成功标记丢失会导致重复发送,因此消费端仍不能省略去重。事件版本还应防止旧事件覆盖新状态。
五、补偿不是技术反向操作
补偿必须符合业务语义。退款不是删除支付流水,而是创建一笔反向资金流水;释放库存前要确认货物尚未出库;已发送优惠券可能选择作废而非物理删除。补偿动作需要审计,不能抹掉原事实。
顺序通常与正向流程相反,但并非绝对。若优惠券失败不影响核心交易,可以继续订单并异步补发,而不是退款。Saga 的编排式方案集中记录流程,易观测但协调器复杂;协同式事件方案耦合较低,却更难看清全局状态。
补偿失败要继续重试、升级告警或进入人工处理,不能假设“补偿一定成功”。
六、带数字看超时扫描与恢复
假设每分钟产生 6 万个流程,其中 0.1% 进入未知状态,即每分钟 60 个。若扫描器每分钟只能处理 40 个,悬挂会以每分钟 20 个增长,一天新增 28800 个。低比例异常在大流量下仍必须做容量规划。
扫描任务应按状态和更新时间索引,使用分片抢占或租约避免单点;每次接管先查询事实,再决定重试或补偿。重试采用退避并设置最大自动次数,超过阈值转人工队列。
unknown_backlog_change = new_unknown - recovery_rate
要稳定收敛:recovery_rate > new_unknown
七、用户体验和可观测性
当结果未知时显示“处理中”比错误地显示成功或失败更诚实,并提供查询入口、防重复操作提示。状态变化可以通过轮询、推送或通知反馈,但前端展示不能成为唯一事实来源。
监控包括各状态数量、最长停留时间、步骤成功率、重复命中、补偿失败和对账差异。Trace 帮助定位一次调用,但长期流程跨越数小时,仍需以持久化业务 ID 串联。
payment_unknown: 23
oldest_unknown_seconds: 84
compensation_pending: 4
compensation_failed: 0
reconciliation_diff: 1
八、常见误区与追问
- 误区:远程调用抛异常就代表业务未执行。 网络异常只描述通信结果,服务端可能已经提交。
- 误区:所有跨服务步骤都应强行统一回滚。 外部副作用无法技术回滚,应设计业务补偿或向前恢复。
- 误区:补偿就是把数据库记录删掉。 补偿是可审计的新业务动作,需尊重后续状态和合规要求。
- 误区:有重试就能解决部分失败。 永久错误、重复副作用和顺序冲突需要幂等、状态机及人工闭环。
- 追问:编排和协同 Saga 怎么选? 长流程、强观测和集中规则适合编排;简单松耦合流程可协同,但要控制事件环和全局可见性。
- 追问:如何避免两个恢复任务重复接管? 用版本 CAS 或带租约的任务抢占,同时让实际步骤保持幂等。
- 追问:什么时候应保留本地事务? 强不变量能在同一数据所有者内完成时优先本地事务,不为追求服务数量徒增分布式协调。
九、加强记忆
按“识别未知、记录进度、幂等重试、业务补偿、对账收口”处理部分失败。先承认超时不能判定结果,再用持久化状态机保存每一步事实;可靠事件避免状态与通知脱节,补偿处理不可回滚副作用,超时扫描和对账保证流程不会永久卡在中间。