一次下单要建订单、扣库存、扣余额。三张表在一个数据库里,一个事务就能搞定;表一旦分到三个库,会发生什么?这篇用三个真实的 MySQL 实例,把“跨库”的后果逐个跑出来:业务失败、进程被杀、按概率注入失败。
初始(单库):订单 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 日志。保证的范围只是这个实例。要让多个实例一起提交或一起回滚,就要有人在它们之上再做一层协调。