电商系统设计(十五):核心业务长事务怎么处理
电商系统设计(十五)(一致性与事务专题;总索引见(一)全景概览与领域划分)
- (一)全景概览与领域划分
- (三)库存系统
- (四)营销系统深度解析
- (七)订单系统
- (八)支付系统深度解析
- (十三)购物车与结算域
- (十五)核心业务长事务怎么处理(本文)
引言
在微服务电商系统里,最容易把架构打穿的一条链路就是:
1 | 用户点击下单 |
这条链路横跨订单中心、库存中心、营销中心、支付中心,天然就是一个典型的分布式长事务。一旦设计不当,就会出现下面几类问题:
- 库存已经扣了,但订单创建失败,导致少卖或脏占用。
- 优惠券已经核销,但用户支付超时,补偿路径复杂。
- 支付网关回调慢或重复回调,导致状态机反复扭转。
- 高并发下线程池、数据库锁和消息积压互相放大,最后拖垮结算链路。
所以面试官问“长事务怎么处理”,本质上不是在考你会不会背 Saga 或 TCC 名词,而是在看你有没有能力在一致性、吞吐量、隔离性、研发成本、外部系统约束之间做架构权衡。
本文会围绕四种主流方案展开:
2PC / XA- 经典
Saga - 标准
TCC - 工业级改良版
Saga
然后给出我在电商创单链路里的推荐选型与落地姿势。
一、为什么本地事务在这里彻底失效
单体时代,我们可以把“扣库存、扣券、写订单、记支付单”放进一个数据库事务里,用本地事务一次提交。
但在微服务架构里,这条链路至少有三个变化:
- 每个核心域都有独立数据库,事务边界被物理拆开。
- 支付网关是第三方外部系统,不可能参加你的数据库事务。
- 用户支付不是毫秒级完成,而是可能等待几十秒,甚至几分钟。
这就意味着:
- 不能再指望数据库的
ACID替你兜底。 - 不能把用户支付等待时间包进锁里。
- 不能为了强一致,把整个系统的吞吐量换掉。
因此,长事务设计的目标不再是“所有库同时提交”,而是:
- 明确谁是权威状态源。
- 明确哪些资源是预占,哪些动作是最终提交。
- 明确失败后如何补偿、如何幂等、如何防悬挂。
二、评估长事务方案时看什么
在具体选方案之前,我一般先看五个维度:
| 维度 | 要回答的问题 |
|---|---|
| 一致性级别 | 需要强一致,还是最终一致就够了? |
| 隔离性 | 中间状态能不能暴露给外部?是否允许“僵尸占位”? |
| 并发吞吐 | 高峰期能不能扛住海量下单请求? |
| 外部系统兼容性 | 能不能接支付网关、银行、三方清结算? |
| 开发维护成本 | 业务代码要不要为事务额外维护大量补偿和状态机? |
真正成熟的架构思路不是追求“理论最强”,而是选一个最符合业务体感的方案。
三、方案一:2PC / XA,数据库层面的强一致枷锁
2PC 的思路很直白:引入一个全局协调器,把所有本地事务拆成两阶段。
3.1 执行流程
1 | Prepare: |
3.2 它为什么看起来很美
- 强一致,语义简单。
- 理论上所有库要么一起成功,要么一起失败。
- 应用层补偿逻辑最少。
3.3 为什么电商创单链路里几乎不用它
核心问题有三个:
长时间持锁,吞吐量极差
在订单、库存、营销多个库都进入Prepare后,底层锁会一直持有。结算高峰期,这几乎等价于主动制造锁竞争风暴。无法友好接入第三方支付
支付网关不支持XA,也不可能陪你一起持锁等待用户扫码。协调器故障风险高
协调器如果卡住,整个事务可能进入悬而未决状态,底层资源被长期占用。
3.4 结论
2PC 更像数据库世界里的强一致教科书,不适合高并发电商结算。
在“用户下单 -> 等待支付”这类跨系统长事务里,它基本是工业界会主动避开的方案。
四、方案二:经典 Saga,先斩后奏的最终一致
经典 Saga 的核心思想是:
不再追求所有服务同时提交,而是把大事务拆成一连串本地事务;每个服务先执行正向动作,后面失败了再逆向补偿。
4.1 执行流程
1 | 订单服务:创建订单 |
如果后面某一步失败,就逆序补偿:
1 | 支付失败 |
4.2 它的优势
- 不长时间持锁。
- 每个服务只做本地事务,吞吐量高。
- 非常适合天然异步、允许最终一致的系统。
4.3 它的硬伤
1. 缺乏隔离性,容易出现“僵尸占位”
如果用户 A 提单时已经真扣库存,但他在收银台停了 15 分钟不付款,这 15 分钟内用户 B 看到的就是“没货”。
即使最后库存补回来了,黄金销售窗口也已经丢失了。
2. 网络乱序会放大补偿复杂度
如果“补库存”消息先到,而“扣库存”消息后到,就会出现:
- 空补偿
- 悬挂事务
- 幽灵扣减
为了兜住这些问题,每个服务都要维护幂等日志、状态判重、补偿防重,工程复杂度会迅速上升。
4.4 结论
经典 Saga 的优点是轻,但太“莽”。
它适合一些弱隔离、后果可回滚的后台任务,不适合作为电商核心交易链路的默认方案。
五、方案三:标准 TCC,业务层冻结资源的重型武器
TCC 可以理解为把两阶段提交从数据库层提升到业务层。
每个服务都必须提供三套接口:
Try:检查并预留资源Confirm:最终提交Cancel:释放预留资源
5.1 执行流程
以库存为例:
1 | Try: |
优惠券、账户余额等资产也都要实现类似的冻结模型。
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. 本地落库:确立订单事实
订单中心开启本地事务,一次性写入:
orderorder_snapshotoutbox_event
这里的关键是:订单事实和待发送事件在同一个数据库事务里提交。
3. 跳转支付:主请求快速返回
前端拿到订单号后进入收银台。
这一步不再占着结算服务线程等待支付结果。
4. 支付成功后异步 Confirm
支付成功回调后,订单中心通过 Outbox 投递可靠消息:
1 | 支付成功 |
5. 超时未支付或取消时异步 Cancel
如果用户超时未支付:
1 | 订单超时关闭 |
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
原因很简单:
- 用户支付是长等待动作,不能同步挂住线程。
- 支付网关是外部系统,不能纳入强事务。
- 库存和优惠券需要隔离,但不适合创单时就真扣。
- 电商系统更看重吞吐量和可扩展性,而不是理论上的全局强一致。
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 | INIT -> RESERVED -> CONFIRMED |
非法状态跳转必须被拒绝。
9.3 Outbox 必须和订单本地事务同提交
如果订单写成功了,但待发送消息没写进去,后续确认或取消就丢了。
所以推荐的做法永远是:
1 | 同一个本地事务里: |
然后再由异步投递器把 Outbox 事件真正发出去。
9.4 超时关单和补偿闭环
不能只设计支付成功路径,必须设计支付失败和支付超时。
典型做法:
- 订单创建后写入超时关闭时间
- 定时任务扫描未支付订单
- 推进订单状态到
CLOSED - 异步触发库存释放和券释放
9.5 可观测性
长事务如果没有追踪能力,线上会非常痛苦。
建议至少有:
trace_idorder_idreserve_idcoupon_tokenpayment_id- 每一步状态流转时间
这样你才能快速回答:
- 卡在哪个节点?
- 是预占失败,还是支付回调没来?
- 是 Confirm 丢了,还是 Cancel 没消费?
十、面试里怎么讲这件事
如果面试官问你“电商长事务怎么处理”,一个比较完整的回答结构是:
10.1 先分场景
“我会先区分是不是跨服务长事务,是否涉及支付这类外部系统,以及资源是需要真扣还是先冻结。”
10.2 再讲为什么不能用本地事务
“订单、库存、营销、支付是独立服务和独立数据库,用户支付又是长等待动作,所以不能再用单库事务,也不能长时间持锁等待支付完成。”
10.3 然后快速对比方案
“2PC 强一致但吞吐量太差,也接不了外部支付;经典 Saga 最终一致但隔离性差;标准 TCC 能解决隔离问题,但研发成本和同步编排成本很高。对标准电商创单链路,我更推荐改良版 Saga:创单时只预占资源,订单本地落库后快速返回,由 Outbox 异步驱动支付成功后的 Confirm 和超时关闭后的 Cancel。”
10.4 最后补工程关键点
“真正落地时,核心不只是选 Saga,而是把幂等、防悬挂、状态机、超时关单、可靠消息和补偿路径设计完整。”
十一、总结
长事务的本质不是“选一个事务协议”,而是:
- 识别哪些动作必须强隔离
- 识别哪些动作可以最终一致
- 把用户长等待从主请求线程里拿掉
- 让资源预占、订单事实、支付结果、补偿释放形成闭环
在电商系统里,最实用的经验通常不是“理论上最强的一致性”,而是:
用预占代替真扣,用本地事务固化事实,用可靠异步完成最终收敛。
这也是为什么在标准创单支付链路里,我会优先选择**工业级改良版 Saga**,而不是 2PC、经典 Saga 或全链路标准 TCC。