电商系统设计(十五):核心业务长事务怎么处理

电商系统设计(十五)(一致性与事务专题;总索引见(一)全景概览与领域划分

引言

在微服务电商系统里,最容易把架构打穿的一条链路就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户点击下单
->
校验价格
->
锁库存
->
锁优惠券
->
创建订单
->
跳转支付
->
支付成功后最终扣减库存和核销营销资产

这条链路横跨订单中心、库存中心、营销中心、支付中心,天然就是一个典型的分布式长事务。一旦设计不当,就会出现下面几类问题:

  • 库存已经扣了,但订单创建失败,导致少卖或脏占用。
  • 优惠券已经核销,但用户支付超时,补偿路径复杂。
  • 支付网关回调慢或重复回调,导致状态机反复扭转。
  • 高并发下线程池、数据库锁和消息积压互相放大,最后拖垮结算链路。

所以面试官问“长事务怎么处理”,本质上不是在考你会不会背 SagaTCC 名词,而是在看你有没有能力在一致性、吞吐量、隔离性、研发成本、外部系统约束之间做架构权衡。

本文会围绕四种主流方案展开:

  • 2PC / XA
  • 经典 Saga
  • 标准 TCC
  • 工业级改良版 Saga

然后给出我在电商创单链路里的推荐选型与落地姿势。

一、为什么本地事务在这里彻底失效

单体时代,我们可以把“扣库存、扣券、写订单、记支付单”放进一个数据库事务里,用本地事务一次提交。
但在微服务架构里,这条链路至少有三个变化:

  1. 每个核心域都有独立数据库,事务边界被物理拆开。
  2. 支付网关是第三方外部系统,不可能参加你的数据库事务。
  3. 用户支付不是毫秒级完成,而是可能等待几十秒,甚至几分钟。

这就意味着:

  • 不能再指望数据库的 ACID 替你兜底。
  • 不能把用户支付等待时间包进锁里。
  • 不能为了强一致,把整个系统的吞吐量换掉。

因此,长事务设计的目标不再是“所有库同时提交”,而是:

  • 明确谁是权威状态源
  • 明确哪些资源是预占,哪些动作是最终提交
  • 明确失败后如何补偿、如何幂等、如何防悬挂

二、评估长事务方案时看什么

在具体选方案之前,我一般先看五个维度:

维度 要回答的问题
一致性级别 需要强一致,还是最终一致就够了?
隔离性 中间状态能不能暴露给外部?是否允许“僵尸占位”?
并发吞吐 高峰期能不能扛住海量下单请求?
外部系统兼容性 能不能接支付网关、银行、三方清结算?
开发维护成本 业务代码要不要为事务额外维护大量补偿和状态机?

真正成熟的架构思路不是追求“理论最强”,而是选一个最符合业务体感的方案。

三、方案一:2PC / XA,数据库层面的强一致枷锁

2PC 的思路很直白:引入一个全局协调器,把所有本地事务拆成两阶段。

3.1 执行流程

1
2
3
4
5
6
7
Prepare:
协调器通知订单库、库存库、营销库执行本地 SQL
各库锁定数据,但不提交

Commit / Rollback:
全部 Ready -> 一起 Commit
任一失败 -> 全部 Rollback

3.2 它为什么看起来很美

  • 强一致,语义简单。
  • 理论上所有库要么一起成功,要么一起失败。
  • 应用层补偿逻辑最少。

3.3 为什么电商创单链路里几乎不用它

核心问题有三个:

  1. 长时间持锁,吞吐量极差
    在订单、库存、营销多个库都进入 Prepare 后,底层锁会一直持有。结算高峰期,这几乎等价于主动制造锁竞争风暴。

  2. 无法友好接入第三方支付
    支付网关不支持 XA,也不可能陪你一起持锁等待用户扫码。

  3. 协调器故障风险高
    协调器如果卡住,整个事务可能进入悬而未决状态,底层资源被长期占用。

3.4 结论

2PC 更像数据库世界里的强一致教科书,不适合高并发电商结算。
在“用户下单 -> 等待支付”这类跨系统长事务里,它基本是工业界会主动避开的方案。

四、方案二:经典 Saga,先斩后奏的最终一致

经典 Saga 的核心思想是:

不再追求所有服务同时提交,而是把大事务拆成一连串本地事务;每个服务先执行正向动作,后面失败了再逆向补偿。

4.1 执行流程

1
2
3
4
5
6
7
订单服务:创建订单
->
库存服务:真扣库存
->
营销服务:真核销优惠券
->
支付服务:发起支付

如果后面某一步失败,就逆序补偿:

1
2
3
4
5
6
7
支付失败
->
营销服务:补偿券状态
->
库存服务:加回库存
->
订单服务:关闭订单

4.2 它的优势

  • 不长时间持锁。
  • 每个服务只做本地事务,吞吐量高。
  • 非常适合天然异步、允许最终一致的系统。

4.3 它的硬伤

1. 缺乏隔离性,容易出现“僵尸占位”

如果用户 A 提单时已经真扣库存,但他在收银台停了 15 分钟不付款,这 15 分钟内用户 B 看到的就是“没货”。
即使最后库存补回来了,黄金销售窗口也已经丢失了。

2. 网络乱序会放大补偿复杂度

如果“补库存”消息先到,而“扣库存”消息后到,就会出现:

  • 空补偿
  • 悬挂事务
  • 幽灵扣减

为了兜住这些问题,每个服务都要维护幂等日志、状态判重、补偿防重,工程复杂度会迅速上升。

4.4 结论

经典 Saga 的优点是轻,但太“莽”。
它适合一些弱隔离、后果可回滚的后台任务,不适合作为电商核心交易链路的默认方案。

五、方案三:标准 TCC,业务层冻结资源的重型武器

TCC 可以理解为把两阶段提交从数据库层提升到业务层。

每个服务都必须提供三套接口:

  • Try:检查并预留资源
  • Confirm:最终提交
  • Cancel:释放预留资源

5.1 执行流程

以库存为例:

1
2
3
4
5
6
7
8
9
Try:
可用库存 -1
锁定库存 +1

Confirm:
锁定库存真正消费

Cancel:
锁定库存释放回可用库存

优惠券、账户余额等资产也都要实现类似的冻结模型。

5.2 它的优势

  • 隔离性很好,中间状态只是冻结,不是真扣。
  • 失败时可以快速取消,不会产生经典 Saga 那种真扣后长时间占坑的问题。
  • 适合金融、账户、额度这类高价值资源。

5.3 它的问题

1. 研发成本极高

一个业务动作要手写 Try / Confirm / Cancel 三套逻辑,而且每套都要考虑幂等、悬挂、空回滚、防重。

2. 同步编排容易卡死线程

标准 TCC 通常由结算服务同步 RPC 编排多个下游。
如果用户要等待支付完成,编排线程就会被长时间挂住,这对高并发链路非常不友好。

3. 对外部系统要求高

如果第三方支付、三方结算系统不支持“预授权 / 预冻结”语义,TCC 也无法完整落地。

5.4 结论

TCC 不是不能用,而是要用在最值钱、最不能暴露中间态的地方。
比如核心账务划拨、余额冻结、授信额度,这类业务很适合;
但标准电商创单链路如果全量上 TCC,往往会显得过重。

六、方案四:工业级改良版 Saga,现代电商最常见的折中解

这是我最推荐的方案,也是很多大厂在下单支付链路里真正采用的思路。

它本质上是:

吸收 TCC 的“先预占,再最终确认”思想,同时保留 Saga 的异步驱动能力,用本地事务 + Outbox 机制把同步重事务改造成可扩展的最终一致链路。

6.1 核心思路

创单时,不去做不可逆的真扣减,而是先拿预占凭证

  • 库存中心锁一个坑位,返回 reserve_id
  • 营销中心锁一张券,返回 coupon_token

只要这些预占凭证有效,订单中心就在自己的本地事务里完成:

  • 写订单主表
  • 写价格、库存、营销快照
  • 写本地消息表 Outbox

这样用户就可以迅速进入收银台,不需要在主请求里等待最终扣减和支付完成。

6.2 典型流程

1. Reserve:预占资源

1
2
3
4
5
结算服务
->
库存中心:TryReserve
->
营销中心:TryLockCoupon

此时只是冻结资源,不发生最终消费。

2. 本地落库:确立订单事实

订单中心开启本地事务,一次性写入:

  • order
  • order_snapshot
  • outbox_event

这里的关键是:订单事实和待发送事件在同一个数据库事务里提交。

3. 跳转支付:主请求快速返回

前端拿到订单号后进入收银台。
这一步不再占着结算服务线程等待支付结果。

4. 支付成功后异步 Confirm

支付成功回调后,订单中心通过 Outbox 投递可靠消息:

1
2
3
4
5
6
7
8
9
支付成功
->
订单状态改为 PAID
->
发布 ConfirmInventory / ConfirmCoupon
->
库存中心真扣
->
营销中心真核销

5. 超时未支付或取消时异步 Cancel

如果用户超时未支付:

1
2
3
4
5
6
7
订单超时关闭
->
发布 CancelInventory / ReleaseCoupon
->
库存中心释放预占
->
营销中心释放券锁

6.3 它为什么更适合电商

1. 兼顾隔离性和吞吐量

资源先冻结,不会像经典 Saga 一样真扣后长期占坑;
同时主请求又不会像标准 TCC 一样长期同步阻塞。

2. 对支付网关非常友好

支付网关不需要参与事务,只需要作为状态机的一个外部开关:

  • 支付成功 -> Confirm
  • 支付失败 / 超时 -> Cancel

3. 工程边界清晰

每个服务只需要关心:

  • 如何预占
  • 如何确认
  • 如何取消

而不需要把整个长事务同步编排到底。

6.4 它的代价

这个方案也不是没有成本:

  • 需要设计清晰的资源预占模型
  • 需要 Outbox 或可靠消息机制
  • 需要状态机和补偿任务
  • 需要对幂等、重复回调、空取消做强约束

但相比 TCC 全链路三接口化,它的复杂度明显更可控。

七、四种方案综合对比

对比维度 2PC / XA 经典 Saga 标准 TCC 改良版 Saga
一致性 强一致 最终一致 业务强一致 最终一致
并发吞吐 很低 很高
业务隔离性
外部支付兼容性 很差 较好 一般 很好
研发成本 低到中 很高
高并发电商适配度 一般 很好

如果用一句话总结:

  • 2PC:一致性最硬,但把系统锁死。
  • 经典 Saga:吞吐高,但容易产生脏占用和补偿泥潭。
  • 标准 TCC:最严谨,但太重。
  • 改良版 Saga:在电商场景下最平衡。

八、我的推荐选型

8.1 电商创单链路:优先选改良版 Saga

对“下单 -> 库存 -> 营销 -> 支付”这条典型链路,我最推荐:

预占凭证 + 本地事务 + Outbox 异步 Confirm / Cancel

原因很简单:

  1. 用户支付是长等待动作,不能同步挂住线程。
  2. 支付网关是外部系统,不能纳入强事务。
  3. 库存和优惠券需要隔离,但不适合创单时就真扣。
  4. 电商系统更看重吞吐量和可扩展性,而不是理论上的全局强一致。

8.2 金融级账务:必要时选择 TCC

如果你处理的是:

  • 账户余额
  • 授信额度
  • 实际资金划拨

那就要优先考虑 TCC,因为这类资产最怕中间态暴露。

8.3 明确不建议

  • 不建议在核心交易流使用 2PC
  • 不建议直接用经典 Saga 真扣库存后再等待支付

九、改良版 Saga 的落地细节

真正落地时,除了主流程,最重要的是这几个工程细节。

9.1 幂等

每一个 Reserve / Confirm / Cancel 都必须可幂等。

例如库存中心:

  • 同一个 reserve_id 重复 Confirm,只能成功一次
  • 已经 Cancel 的预占,后续重复取消不能出错
  • 未成功 Reserve 的单据,不允许凭空 Confirm

9.2 防悬挂

最典型的场景是:

  • Cancel 先到
  • Reserve 后到

如果没有状态机保护,就可能出现已经宣告失败的事务又把资源锁上了。

因此库存、营销这类服务内部都要维护预占状态机,例如:

1
2
INIT -> RESERVED -> CONFIRMED
INIT -> RESERVED -> CANCELED

非法状态跳转必须被拒绝。

9.3 Outbox 必须和订单本地事务同提交

如果订单写成功了,但待发送消息没写进去,后续确认或取消就丢了。
所以推荐的做法永远是:

1
2
3
4
同一个本地事务里:
写订单
写快照
写 outbox_event

然后再由异步投递器把 Outbox 事件真正发出去。

9.4 超时关单和补偿闭环

不能只设计支付成功路径,必须设计支付失败和支付超时。

典型做法:

  • 订单创建后写入超时关闭时间
  • 定时任务扫描未支付订单
  • 推进订单状态到 CLOSED
  • 异步触发库存释放和券释放

9.5 可观测性

长事务如果没有追踪能力,线上会非常痛苦。
建议至少有:

  • trace_id
  • order_id
  • reserve_id
  • coupon_token
  • payment_id
  • 每一步状态流转时间

这样你才能快速回答:

  • 卡在哪个节点?
  • 是预占失败,还是支付回调没来?
  • 是 Confirm 丢了,还是 Cancel 没消费?

十、面试里怎么讲这件事

如果面试官问你“电商长事务怎么处理”,一个比较完整的回答结构是:

10.1 先分场景

“我会先区分是不是跨服务长事务,是否涉及支付这类外部系统,以及资源是需要真扣还是先冻结。”

10.2 再讲为什么不能用本地事务

“订单、库存、营销、支付是独立服务和独立数据库,用户支付又是长等待动作,所以不能再用单库事务,也不能长时间持锁等待支付完成。”

10.3 然后快速对比方案

2PC 强一致但吞吐量太差,也接不了外部支付;经典 Saga 最终一致但隔离性差;标准 TCC 能解决隔离问题,但研发成本和同步编排成本很高。对标准电商创单链路,我更推荐改良版 Saga:创单时只预占资源,订单本地落库后快速返回,由 Outbox 异步驱动支付成功后的 Confirm 和超时关闭后的 Cancel。”

10.4 最后补工程关键点

“真正落地时,核心不只是选 Saga,而是把幂等、防悬挂、状态机、超时关单、可靠消息和补偿路径设计完整。”

十一、总结

长事务的本质不是“选一个事务协议”,而是:

  • 识别哪些动作必须强隔离
  • 识别哪些动作可以最终一致
  • 把用户长等待从主请求线程里拿掉
  • 让资源预占、订单事实、支付结果、补偿释放形成闭环

在电商系统里,最实用的经验通常不是“理论上最强的一致性”,而是:

用预占代替真扣,用本地事务固化事实,用可靠异步完成最终收敛。

这也是为什么在标准创单支付链路里,我会优先选择**工业级改良版 Saga**,而不是 2PC、经典 Saga 或全链路标准 TCC