On the Anatomy of a Saga如何把一份订单做到不被丢失
一笔订单从用户点下"购买"那刻起, 就开始了它漫长的旅行. 它要穿过身份认证、库存锁定、风控、支付网关、物流分配, 最后落到你不认识的某张表的某一行里. 中间任何一个停靠站坏了, 这笔订单可能就消失在中间地带 — 既没成功, 也没回到起点.
过去十年里, 我们的行业把这个问题包装成了 分布式事务, 然后又把分布式事务包装成了 Saga 模式, 最后又承认 Saga 也救不了一切, 加上了 event sourcing. 但本质问题没变 — 我们怎么知道这笔订单到底走到了哪里, 以及, 怎么从任何一个奇怪的中间态里把它拉回来.
为什么不用一个大事务包起来
最朴素的想法是: 那就把所有步骤放进一个数据库事务里. 这个想法在单库单服务时代是对的. 但当你的库存在 A 服务、支付在 B 服务、物流在 C 服务, 你没办法让一个事务跨越三个独立的进程边界 — 即使有 XA 协议也不行, 因为 XA 在大流量下要么慢要么崩.
分布式系统里没有"原子操作"这种东西, 只有"看起来像原子的"操作.
这句话听起来像是认输, 实际上是行业最贵的一条共识. 一旦你接受了它, 后面的所有架构选择就都是在问 — 我能让"看起来像原子"看起来到什么程度?
Saga 是怎么救场的
Saga 的本质是把一个长事务拆成一串短事务, 每一步都有一个对应的"补偿动作" — 如果第 5 步失败了, 就反向执行 4、3、2、1 的补偿. 听起来简单, 真做起来难在两件事:
第一, 补偿不是简单的反向操作. 你扣了用户 100 块, 退款不是简单的"加回去 100", 因为可能涉及汇率波动、手续费、风控冻结. 你预留了库存, 释放也不是"加回库存", 因为这中间可能有其他人下单看见了你的"假库存".
第二, Saga 的状态机本身要持久化. 否则进程重启后你不知道走到第几步了. 这就是为什么我们要把事件流(event log)作为系统的"事实之源" — 状态可以丢, 事件不能丢.
// Saga 的最小实现, 伪代码
async function placeOrder(input) {
const ev = await events.append("ORDER_RECEIVED", input);
try {
await events.append("INVENTORY_RESERVED", await reserveInventory(ev));
await events.append("PAYMENT_AUTHORIZED", await authorizePayment(ev));
await events.append("ORDER_COMMITTED", await commit(ev));
} catch (err) {
await compensate(ev, err); // 反向走一遍, 每一步都幂等
throw err;
}
}
关于 cron 兜底的争论
一种常见的做法是: 主链路用 saga, 兜底用 cron — 每分钟扫一次"卡住"的订单, 看看能不能推进. 这种做法在一定规模下是有效的, 但有两个隐性代价:
一是 响应延迟. cron 每分钟跑一次, 意味着用户最坏要等 60 秒才看到状态推进. 二是 双写竞争. 主链路推一次, cron 又来推一次, 状态机要做幂等, 否则会出现 "推进两次" 的诡异 bug — 用户会看到自己的钱先扣两次再退一次, 体验比卡死还糟.
所以更稳妥的方式是: 主链路必须 inline 完成, cron 只做事实校验, 不主动 lock. 这是我们在 GGBOYS 的统一订单系统里反复跌过跟头才学到的 — 一开始把 cron 当救命稻草, 后来发现真正能救命的, 是让主链路自己有"重试 + 推进"的能力, cron 只在主链路彻底死透了之后才出场, 而且只 observe, 不 mutate.
把这些经验留下来
每一次订单系统的事故, 我都会把当时的状态机截图、日志、和决策过程归档到团队的 PITFALLS 文档里. 这些文档不是给现在看的, 是给两年后忘了细节的自己和新来的同事看的 — 它们记录了"为什么不能这样做", 而不是"应该这样做".
我越来越相信, 一个团队真正的资产, 是它的事故档案而不是它的代码库. 代码会被重写, 教训不会.
「天 下 难 事, 必 作 于 易; 天 下 大 事, 必 作 于 细」
老 子 · 第 六 十 三 章