bigywater.tech
分布式事务 · 1

本地事务的边界:一次下单为什么跨了多个库

一次下单要建订单、扣库存、扣余额。三张表在一个数据库里,一个事务就能搞定;表一旦分到三个库,会发生什么?这篇用三个真实的 MySQL 实例,把“跨库”的后果逐个跑出来:业务失败、进程被杀、按概率注入失败。

177 / 2000
三个实例各自提交、每步 5% 失败率时,落在半截状态的订单数(8.8%)
0
同样 2000 单放进一个实例的一个事务,三张表对不上的单数
2 / 4
kill -9 调用方进程的四个位置里,会把数据永远留在半截的有几个

同一个实例里:一个事务管三张表

实测 · 本机执行输出
  初始(单库):订单 0 条,sku-1 库存 10,用户 1 余额 50
  BEGIN
  insert into orders ...                      -> OK(事务内可见,还没提交)
  update stock set qty = qty - 1 ...          -> OK
  update wallet set balance = balance - 80 ... -> 失败:(3819, "Check constraint 'wallet_ok' is violated.")
  ROLLBACK
  回滚后(单库):订单 0 条,sku-1 库存 10,用户 1 余额 50  —— 三张表都回到了初始值

三张表在一个 MySQL 实例里,第三步触发 CHECK 约束报错,前两步一起回滚。本地事务的保证就是这样:同一个实例里的修改,要么全成,要么全不成。

拆成三个实例:各提交各的

实测 · 本机执行输出
  初始(拆开后):订单库 0 条,库存库 sku-1=10,钱包库 用户 1 余额=50
  订单库:insert + COMMIT      -> OK
  库存库:update + COMMIT      -> OK
  钱包库:update              -> 失败:(3819, "Check constraint 'wallet_ok' is violated.")
  钱包库:ROLLBACK(只回滚了自己这一步)
  结果:订单库 1 条,库存库 sku-1=9,钱包库 用户 1 余额=50  —— 订单存在、库存少了 1、钱没扣:三个库对这单的看法不一致

每个实例只能提交或回滚自己的事务。第三步失败时,前两步已经提交,没有任何东西替它们回滚。三个库对同一单的看法不一样。

进程在间隙里被杀

实测 · 本机执行输出
  进程在哪个位置被杀               | 订单数 | 库存扣了 | 余额扣了 | 结论 
  ------------------------+-----+------+------+----
  kill -9 于:第 1 次提交之前     | 0   | 0    | 0    | 一致 
  kill -9 于:订单库提交之后       | 1   | 0    | 0    | 不一致
  kill -9 于:库存库提交之后       | 1   | 1    | 0    | 不一致
  kill -9 于:钱包库提交之后(全部完成) | 1   | 1    | 80   | 一致 
  只有“一次都没提交”和“三次都提交”两种状态是一致的;中间的两个间隙里,进程一死,数据就永远停在半截

不一定要有业务错误:调用方进程在两次提交之间被杀,数据也会永远停在半截。重启后没有任何地方记着“上次做到了哪一步”,下一篇起要讲的各种方案,都是在补这个记忆。

按概率算:半截状态有多常见

实测 · 本机执行输出
  三个实例(每步各自提交):2000 单,按程序里的记录 成功=1715,  第 1 步就失败(什么都没留下)=108,  第 2 步失败(订单已存在)=92,  第 3 步失败(订单存在且库存已扣)=85
    从数据库里数出来:订单 1892 条,库存扣了 1800,余额折合 1715 单   —— 三个数字不相等,按单对账会有差
    不一致的单数 = 177(8.8%)
  一个实例(一个事务):2000 单,按程序里的记录 成功=1715,  第 1 步就失败(什么都没留下)=108,  第 2 步失败(订单已存在)=92,  第 3 步失败(订单存在且库存已扣)=85
    从数据库里数出来:订单 1715 条,库存扣了 1715,余额折合 1715 单   —— 三个数字完全相等
    不一致的单数 = 0(0.0%)
  理论值:第 2、3 步失败的概率 = (1-p)p + (1-p)^2 p = 9.26%,2000 单期望 185 单,标准差约 13 单;实测 177 单,差 8 单(随机数固定种子 7)

每一步 5% 的失败率看起来不高,三步串起来就是 9% 的订单留下半截状态。失败率越高、步骤越多、调用链越长,这个比例越大。

本地事务到底保证了什么

实测 · 本机执行输出
  事务内可见:订单 2,库存 5(订单 2 与库存 -5 都还没 COMMIT)
  kill -9 mysqld
  重启后:订单 1 条([1]),库存 10
  错误日志里和恢复有关的行:
    [Server] Starting XA crash recovery...
    [Server] XA crash recovery finished.
  本地事务的原子性来自这一个实例自己的日志;订单库的日志管不了库存库里发生了什么

已提交的重启后还在,未提交的被回滚,靠的是实例自己的 redo/undo 日志。保证的范围只是这个实例。要让多个实例一起提交或一起回滚,就要有人在它们之上再做一层协调。

分步演示

本地事务的边界
本步的对象

    本文用到的工具与边界

    • 本机 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;kill -9 的是调用方子进程或 mysqld;5% 的失败是用固定种子 7 的随机数在每一步执行之前注入的,不是网络真实丢包
    • 没有验证:真实网络里的超时(请求可能已经被对端执行了但响应丢了);MySQL 以外的数据库;多个库在同一台机器上与分布在多台机器上的差别;调用方崩溃时 MySQL 连接的清理细节(这里是进程被杀后连接被服务端关闭)
    京ICP备2026067502号-1京公网安备11010502063487号