第 38 章 / 共 57 章 · 约 4 分钟
设计模式:多步骤流程
下单这件事背后往往是好几步:占库存、扣款、生成订单、发通知,分别属于不同的服务和数据库。任何一步都可能失败、超时或者重复执行,而跨服务又没有一个能回滚一切的数据库事务。这一章讲怎样让这类流程“要么走完,要么干净地撤回”,并且在重试时不出错。
1. 问题是什么
- 部分失败:扣款成功了,生成订单时服务挂了。钱扣了,订单没有。
- 超时的不确定性:调用支付超时,你不知道对方是成功了还是没收到请求。
- 重复执行:为了不丢,所有步骤都会重试,于是同一步可能执行两次。
- 长时间等待:有的步骤要等外部回调或人工审核,流程可能持续几分钟到几天。
2. 基础构件
2.1 幂等
每一步都要能安全地重复执行:调用方带上幂等键(例如 order_id 或客户端生成的请求 id),被调用方记住处理过的键和结果,再次收到时直接返回第一次的结果。没有幂等,重试就等于重复扣款。
2.2 事务性发件箱(outbox)
“改数据库”和“发消息”是两个系统,先后执行总有一步可能失败。outbox 的做法是:在同一个数据库事务里,既更新业务表,又往 outbox 表插入一条待发事件;再由一个中继进程读取 outbox,发到 Kafka 或队列,发成功后标记已发送。这样事件不会丢,最多重复,消费方用幂等去重。也可以用 CDC 直接读数据库日志代替中继。
3. 方案
3.1 两阶段提交(2PC)
协调者先让所有参与者“准备”,全部同意后再“提交”。能做到原子性,但协调者故障时参与者会一直锁着资源等待,而且要求所有参与者都支持 XA 之类的协议。跨服务、跨公司的流程(例如外部支付网关)基本用不了,面试里说清楚为什么不用即可。
3.2 Saga
把流程拆成一串本地事务,每一步都有对应的补偿操作。某一步失败时,按相反的顺序执行已完成步骤的补偿:退款、释放座位、取消订单。Saga 不提供隔离性:中间状态对外可见(座位显示“已预占”),设计时要接受并处理这一点。
- 编排式(orchestration):一个协调服务按状态机驱动各步,知道整个流程走到哪里,失败时负责补偿。流程清楚、好排查,推荐作为默认答案。
- 协同式(choreography):各服务监听上一步的事件,自己决定下一步。耦合低,但流程散落在各个服务里,步骤一多就很难看清全貌。
查看图表源码
flowchart LR
accTitle: 订票 Saga 与补偿
start["开始"] --> hold["1. 预占座位"]
hold --> pay["2. 扣款"]
pay --> confirm["3. 确认订单"]
confirm --> notify["4. 发送通知"]
pay -.->|"扣款失败"| release["补偿:释放座位"]
confirm -.->|"确认失败"| refund["补偿:退款"]
refund -.-> release
3.3 持久化工作流引擎
Temporal、AWS Step Functions 这类引擎把“流程走到哪一步、每一步的结果”持久化,进程崩溃后从断点继续;还自带重试、超时、定时器(“30 分钟未付款就取消”)。写起来像普通代码,适合步骤多、等待长、需要人工介入的流程。代价是多一个要运维(或付费)的系统。
3.4 数据库里的状态机
规模不大时,一张 orders 表加 status 字段(PENDING → PAID → CONFIRMED,或 → CANCELLED),配合定时任务扫描超时的记录,往往就够了。状态迁移用条件更新(WHERE status='PENDING'),防止并发的两次迁移。
4. 怎么选
| 场景 | 推荐 |
|---|---|
| 一个服务、一个数据库内 | 直接用本地事务 |
| 改数据库并通知其他服务 | outbox(或 CDC)加幂等消费者 |
| 三四步、跨服务、需要回滚 | 编排式 Saga,状态存数据库 |
| 步骤多、等待长、有定时和人工环节 | 工作流引擎 |
| 需要跨库强原子性、参与者都可控 | 2PC,但要说明可用性代价 |
5. 常见追问
- 调用支付超时了,要不要重试? 带同一个幂等键重试,或者先查询这笔支付的状态;绝不能换一个新键重试。
- 补偿也失败了怎么办? 补偿操作同样要幂等、可重试;多次失败后进入人工处理队列,并且有对账任务兜底。
- 怎么防止消息丢失? outbox 保证“数据库改了就一定会发消息”;消费方处理完再提交 offset,保证“消息一定会被处理”。
- 流程的中间状态用户能看到吗? 能,所以要设计成用户可以理解的状态(“处理中”“等待付款”),并给出超时时间。
6. 本站用到这个模式的案例
- 案例 10:支付与账本:幂等键、outbox 加 Saga,不用 2PC。
- 案例 12:票务抢购:预占、付款、确认三步,超时释放。
- 案例 6:通知系统:上游用 outbox 保证事件不丢,下游按渠道重试并进入死信队列。
- 案例 5:网约车:派单超时这类“N 秒后做某事”交给延迟队列或 Temporal 这样的工作流引擎。