下单会涉及三个服务:库存服务预留库存,支付服务对银行卡进行授权,配送服务创建发货单。在单体应用里,这是一个数据库事务。拆分成各自拥有数据库的服务之后,就不存在横跨三者的事务了,而在服务之间使用两阶段提交,在实践中很少可行。
Saga 模式是标准答案。本文介绍 Saga 的工作方式、编排式与协同式的区别、补偿和关键步骤如何配合,以及 Saga 不会替你解决的隔离性问题。图中展示的是一个编排式的订单 Saga。
什么是 Saga
Saga 是一串本地事务。每一步都在自己服务的数据库中提交,并触发下一步。如果某一步失败,Saga 会执行 补偿事务,撤销已经提交的那些步骤所产生的影响。
这个想法比微服务更早:Hector Garcia-Molina 和 Kenneth Salem 在 1987 年一篇关于长事务的论文中描述了 Saga。这个模式之所以重新流行,是因为服务边界再次制造了同样的问题。
有两个后果决定了其他的一切:
- 最终一致性。 在第一步和最后一步之间,系统处于其他请求可以看到的中间状态。订单是 PENDING,库存已预留,银行卡已授权但尚未扣款。
- 补偿不是回滚。 已经发出的邮件收不回来,银行卡上的扣款也不能悄悄撤销。补偿是一个新的业务操作,比如撤销授权或发起退款,在语义上抵消之前的操作。
可补偿、关键和可重试的步骤
动手画图之前,先给每一步分类。Azure 架构中心的 Saga 模式指南 把步骤分为三类:
| 类型 | 含义 | 示例 |
|---|---|---|
| 可补偿 | 可以用另一个事务撤销 | 预留库存;授权支付 |
| 关键(pivot) | 不可回头的点:一旦成功,Saga 必须执行到底 | 创建发货单 |
| 可重试 | 位于关键步骤之后,最终必须成功;幂等 | 发送确认邮件 |
顺序很重要。可补偿的步骤放在最前,然后是关键步骤,之后只放可重试的步骤。关键步骤失败就补偿;它一旦成功,就不断重试之后的所有步骤,直到成功为止。如果关键步骤之后还有可补偿的步骤,那么这一步失败时,Saga 就无法到达一致的状态。
编排式与协同式
协调这些步骤有两种方式。
编排式: 由一个 Saga 编排器(通常是带持久化状态的状态机)告诉每个服务该做什么,并等待回复。顺序、重试、超时和补偿都由它负责。
协同式: 没有协调者。每个服务监听事件并作出反应:库存服务收到 OrderCreated 后预留库存,支付服务收到 StockReserved 后进行授权,依此类推。失败也是事件,每个服务都知道该执行哪个补偿。
| 编排式 | 协同式 | |
|---|---|---|
| 流程在哪里 | 一个地方:编排器 | 分散在各个服务的事件处理器中 |
| 增加一个步骤 | 修改编排器 | 修改谁监听哪个事件 |
| 查看某个订单的状态 | 查询 Saga 日志 | 从事件中重建 |
| 耦合 | 服务了解编排器的命令 | 服务了解彼此的事件 |
| 适用场景 | 步骤多、有分支、顺序严格 | 两三个步骤的简单流程 |
协同式在短流程里用起来很舒服,可一旦有人问“为什么订单 8812 卡住了?”,就很头疼。编排式让这个问题只需要看一个地方。Temporal、AWS Step Functions 或 Camunda 这样的工作流引擎提供持久化的编排,你不必自己实现状态机。Chris Richardson 的 Saga 模式页面 给出了两种风格的完整示例。
隔离性:Saga 留给你的部分
数据库事务会对其他事务隐藏中间状态,Saga 不会。订单 Saga 运行期间,另一个请求可能读到已预留的库存,第二个 Saga 也可能修改同一条记录。常见的应对措施如下:
- 语义锁。 把记录标记为处理中(PENDING 的订单、RESERVED 的库存),让其他操作知道应该等待或拒绝。
- 可交换的更新。 把操作设计成以任意顺序执行都得到相同结果,比如增减计数,而不是直接设置数值。
- 写入前重新读取。 检查某个值在 Saga 读取之后是否发生了变化,如果变了,就失败或重新开始。
让每一步和每个补偿都幂等,以 Saga ID 和步骤作为键。消息会被投递两次,而一次重试的“撤销授权”绝不能撤销另一笔支付。
超时与卡住的 Saga
每一步都需要截止时间。一个永远不回复的支付服务,会让 Saga 永远等下去,库存也一直处于预留状态。给每一步设置超时:在关键步骤之前,把超时当作失败(进行补偿);在关键步骤之后,把超时当作重试的理由;并对在某个状态停留时间超出预期的 Saga 发出告警。一个按当前步骤列出 Saga 的小仪表盘,本身就能回答大部分支持问题。同时记录每个 Saga 结束的原因。
架构图
图表内容
- Order service
- Saga orchestrator
- Saga log
- Inventory service
- Payment service
- Shipping service
- All steps succeeded?
- Order APPROVED
- Compensate in reverse order
- Order REJECTED
- start
- persist each step
- 1. reserve stock
- 2. authorize payment
- 3. create shipment
- yes
- no
- void, release
这张图的读法如下:
- 左侧: 订单服务以 PENDING 状态创建订单,并启动 Saga。
- 中间: 编排器按顺序执行各步骤,并把每一步持久化到 Saga 日志中,因此重启后可以从中断的地方继续。
- 右侧: 三个服务,各自标注了步骤编号和类型。库存和支付是可补偿的,配送是关键步骤。
- 底部: 如果所有步骤都成功,订单变为 APPROVED。如果关键步骤之前的某一步失败,编排器按相反顺序补偿(先撤销授权,再释放库存),订单变为 REJECTED。
- Markdown 表格 把每个操作与它的补偿一一对应,这是评审者首先应该检查的内容。
Saga 图上应该画什么
- 步骤顺序和每一步的类型。 可补偿、关键还是可重试,要写在方框上。
- 每一个补偿。 没有补偿的步骤,要么是关键步骤,要么可重试,要么就是 bug。
- Saga 状态保存在哪里。 中途崩溃就无法恢复的 Saga,还不算完成。
- 可见的中间状态。 PENDING 和 RESERVED 是设计的一部分,其他功能会看到它们。
Saga 与 事务性发件箱模式 天然搭配,因为每一步都必须既更新数据库又发送消息,而且两者都不能丢。微服务架构图 展示了这些服务所处的位置,支付网关集成图 则详细介绍了授权和请款这两个步骤。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的 容器 节点,分别表示订单服务、Saga 编排器和三个参与服务,再添加一个 数据库 作为 Saga 日志。
- 在每个服务的技术字段中写上步骤编号和类型(可补偿或关键)。
- 添加流程图节点包中的一个 判断、一个 处理 和两个 开始 / 结束 节点,表示成功路径和补偿路径。
- 添加一个基础节点包中的 Markdown 块,用表格列出各个操作及其补偿。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
Saga 和分布式事务有什么区别? 分布式事务(两阶段提交)会锁住多个数据库中的资源,直到所有参与方都提交。Saga 在本地提交每一步,如果后面的步骤失败,就用补偿撤销前面的步骤,用隔离性换取可用性。
应该用编排式还是协同式? 步骤多、有分支或顺序严格的流程用编排式,因为每个 Saga 的状态都在一个地方。两三个服务彼此响应事件的短流程用协同式。
什么是关键事务? 在这一步之后,Saga 就无法再被补偿。它之前的所有步骤都必须可补偿;它之后的所有步骤都必须可以重试,直到成功。
补偿失败了怎么办? 重试。补偿必须幂等,并且最终成功;如果某个补偿无法成功,Saga 就需要一条人工处理的路径和一个告警。
Saga 能提供隔离性吗? 不能。其他请求可以看到中间状态。使用 PENDING 状态这样的语义锁、可交换的更新和重新读取,来防止对你的业务真正有影响的异常。