bigywater.tech
分布式事务 · 32

业务拆分与事务边界:什么时候别拆

什么时候该拆、什么时候别拆?这篇把“建订单 + 扣库存 + 扣余额”做成五种实现,跑在 4 个独立的 MySQL 上,同样的失败、同样的订单,量出延迟、吞吐、对账结果和代码量,看拆分的代价到底落在哪里。

0.27x
8 线程并发下单,三个库 + XA 的吞吐相对单体(1306 对 4901 单/秒)
149 / 2000
朴素拆分在 3% 临时失败下,订单已确认却缺库存预留或扣款的订单数
44 → 24
把总是一起改的订单和库存合并成一个库后,下单函数的有效代码行数(Saga → 合并)

没有失败时的代价

实测 · 本机执行输出
  实现                                 | 延迟中位数   | p99     | 每个订单的提交次数
  -----------------------------------+---------+---------+----------
  单体:一个库、一个本地事务                      | 0.22 ms | 0.65 ms | 1.0      
  拆成三个库,各自提交,没有保护                    | 0.45 ms | 1.03 ms | 3.0      
  拆成三个库 + XA(两阶段提交)                  | 0.82 ms | 1.97 ms | 3.0      
  拆成三个库 + Saga(状态表 + 补偿 + 重试)        | 0.56 ms | 0.72 ms | 4.0      
  订单与库存合并成一个库,只有支付在另一个库(Saga 只剩支付一步) | 0.40 ms | 0.56 ms | 3.0      

拆得越多,每单要多几次往返和提交;XA 的中位延迟约为单体的 3.7 倍(0.82 ms 对 0.22 ms 算出的比值),Saga 多一次状态表提交。

并发下的吞吐

实测 · 本机执行输出
  实现                                 | 订单/秒(3 轮中位数) | 3 轮范围       | 相对单体 
  -----------------------------------+--------------+-------------+------
  单体:一个库、一个本地事务                      | 4901         | 4614 ~ 4958 | 1.00x
  拆成三个库,各自提交,没有保护                    | 3111         | 2626 ~ 3857 | 0.63x
  拆成三个库 + XA(两阶段提交)                  | 1306         | 1294 ~ 1319 | 0.27x
  拆成三个库 + Saga(状态表 + 补偿 + 重试)        | 2982         | 2945 ~ 3020 | 0.61x
  订单与库存合并成一个库,只有支付在另一个库(Saga 只剩支付一步) | 3846         | 2567 ~ 4008 | 0.78x

XA 并发只有单体的约四分之一,Saga 约六成;合并订单与库存的版本介于 Saga 与单体之间,但这一行抖动很大。

有失败时的结果

实测 · 本机执行输出
  实现                                 | 下单成功 | 业务拒绝(余额不足) | 失败 / 卡住 | 库里有记录的订单 | 订单已确认却缺预留或扣款 | 预留或扣款泄漏(订单不是 CONFIRMED) | 停在 PENDING 的订单
  -----------------------------------+------+------------+---------+----------+--------------+-------------------------+---------------
  单体:一个库、一个本地事务                      | 1959 | 41         | 0       | 1959     | 0            | 0                       | 0             
  拆成三个库,各自提交,没有保护                    | 1851 | 37         | 112     | 2000     | 149          | 0                       | 0             
  拆成三个库 + XA(两阶段提交)                  | 1959 | 41         | 0       | 1959     | 0            | 0                       | 0             
  拆成三个库 + Saga(状态表 + 补偿 + 重试)        | 1959 | 41         | 0       | 2000     | 0            | 0                       | 0             
  订单与库存合并成一个库,只有支付在另一个库(Saga 只剩支付一步) | 1959 | 41         | 0       | 2000     | 0            | 0                       | 0             
  Saga 里补偿重试用完仍没释放库存、停在“卡住”的:0 个(要靠恢复 / 人工;没有失败注入的恢复扫描能把它们收尾,见第 30 篇)
  单体和 XA 失败时整体回滚,什么都不留;朴素拆分在中间步骤失败(临时失败、余额不足)时,前面已经提交的步骤没有人撤销;Saga 靠补偿把它们撤回,代价是中间状态对外可见一小段时间

朴素拆分留下 149 个对不上的订单;XA 靠整体回滚、Saga 靠补偿、合并版靠本地事务,对账都是 0,代价各不相同。

代码量与组件

实测 · 本机执行输出
  实现        | 有效代码行数(Python,本实验里的版本) | 额外依赖                                                    
  ----------+------------------------+---------------------------------------------------------
  单体        | 14                     | 0 个额外的表,1 个库                                            
  朴素拆分      | 10                     | 3 个库,没有额外的表                                             
  XA        | 29                     | 3 个库 + 数据库对 XA 的支持(prepared 事务要有人收尾,见第 3、5 篇)           
  Saga      | 44                     | 3 个库 + saga_log 表 + 重试 / 补偿逻辑;生产里还需要恢复守护进程和对账(第 30、16 篇)
  订单 + 库存合并 | 24                     | 2 个库 + 只剩支付一步需要重试 / 补偿                                  
  行数只是这几个函数在本实验里的写法,不是任何框架的真实工作量;但相对大小能说明:每多拆一个库,就要多写一段“这一步失败怎么办”

每多拆一个库就要多写一段“失败怎么办”。能放进同一个本地事务的,就不要拆开去补偿。

分步演示

什么时候别拆
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用四个独立的 MySQL 26.7.0 实例(单体库、订单库、库存库、钱包库),PyMySQL 客户端,每个线程每个库一个连接;失败按订单号的 crc32 确定地注入(每次远程调用 3% 临时失败,在调用执行之前就失败;2% 的订单金额超过余额),五种实现遇到完全相同的失败;XA 与 Saga 是我手写的实现,不是任何框架;代码行数是这几个函数在本实验里的写法
    • 没有验证:真实网络里的延迟与超时(四个实例在同一台机器上,往返极小,拆分的真实代价会更大);XA 的失败恢复(prepared 事务在协调者崩溃后的收尾,见第 3、5 篇);Saga 补偿本身反复失败后的恢复(这次补偿重试没有用完,卡住是 0,恢复守护进程见第 30 篇);更多线程、更大数据量下的比较(并发只测了 8 线程,每个线程 100 单);并发下 Saga 与合并版的隔离问题(第 10 篇);团队、部署与组织层面的拆分理由(本篇只比较事务边界)
    京ICP备2026067502号-1京公网安备11010502063487号