MySQL 事务、并发控制与故障排查

从隔离级别到业务一致性的真实实验

Aias00

2026-10-21

前言:从一句“事务失败了”追问到证据

应用捕获到数据库异常,往往只能得到一个错误码。但接下来该重试一条 SQL、回滚整个事务,还是先查询业务状态,答案并不相同。死锁、锁等待超时和提交响应丢失,表面都让一次调用失败,留下的数据库事实却可能完全不同。

本书以 MySQL 8.4 和 InnoDB 为基准,围绕账户、库存、订单和本地事件表,学习这些事实如何形成、怎样观察,以及怎样把业务约束写进可验证的事务。适合了解基本 SQL、会编写后端程序的读者;不要求先阅读整个数据库内核。

随书实验使用真正的 MySQL 服务。Python 与 PyMySQL 只是降低连接和并发实验的启动成本;隔离级别、锁、回滚、索引计划和提交结果都由 InnoDB 执行。没有使用字典或 mock 来替代数据库语义。

一条贯穿全书的业务约束

仓库里只剩一件商品,两位用户同时购买。最终库存没有负数,是否就说明没有超卖?如果两个请求都先读到 1,再分别把库存写成 0,两个订单都可能被确认。我们需要检查库存与订单共同满足的约束,而不是只盯着某个字段。

同一订单还会遇到重复提交、死锁、连接断开与事件发布。每增加一项能力,先问它依赖什么数据库保证,异常后又留下什么证据。整本书会不断回到这条线:业务事实、事务边界和恢复动作要相互匹配。

阅读与运行路线

前面的章节建立事务、快照、锁和索引模型;中间章节讨论并发库存、幂等、未知提交和 Outbox;后面的实验章带你读取实际输出,再做自己的故障评审。可以先运行实验,观察那些与直觉不同的结果,再回到对应原理。

本机没有数据库时,仓库的 GitHub Actions 使用隔离 MySQL 容器运行实验并保存证据。每个测试创建自己的随机数据库,结束后仅删除该测试创建的数据库。运行前仍应阅读实验说明,使用独立开发实例与公开的教学凭据,不应连接生产数据。

证据的范围

小数据集的执行计划能说明优化器在这个数据与配置下选择了什么,不能证明生产响应时间提升了多少。单节点事务能说明本地原子性,不能证明主从切换不丢数据或消息恰好发送一次。书中的案例都是教学场景,生产设计与未运行项会单独说明。

本文稿、EPUB 与在线阅读来自同一份 Markdown,代码附录从实际实验源码生成。希望读完之后,面对“数据库报错”,你能进一步说清:哪条语句失败、整个事务是否仍活跃、业务结果是否已提交,以及下一步凭什么执行。

第 1 章:先确定事务从哪里开始,到哪里结束

一次业务请求可能包含多条 SQL,一条连接也可能先后服务多个请求。数据库不会凭应用的函数名自动识别业务边界。若连接的 autocommit、隔离级别和未结束事务没有被检查,代码看起来在一个方法里,实际却可能分成几次提交。

1.1 显式事务表达一个共同承诺

下面的转账片段表达两个账户余额必须一起变化。示例只在随书实验表中运行,业务系统还应检查账户存在、余额、货币与权限:

START TRANSACTION;
UPDATE accounts SET balance=balance-10 WHERE id=1;
UPDATE accounts SET balance=balance+10 WHERE id=2;
COMMIT;

若程序在第二条 UPDATE 前出错,需要明确回滚,而不是把连接直接交给下一个请求。若异常发生在 COMMIT 的响应阶段,又需要先判断提交结果,后面会专门讨论这个未知窗口。

事务不是一种自动获得业务正确性的包装。把两个错误的 UPDATE 放在一起,只能让错误一起提交;缺少余额条件、业务唯一键或正确的锁顺序,仍然会出问题。

1.2 autocommit 是连接状态

在自动提交模式下,独立语句通常各自构成事务。关闭 autocommit 后,应用必须管理事务结束。驱动也可能有自己的默认值,所以应在实际连接上检查,而不是只查服务器的全局配置。

SELECT @@autocommit, @@transaction_isolation;

随书连接显式设置事务模式和隔离级别。连接池场景还要验证归还连接时是否回滚、是否重置会话设置;一次请求把隔离级别改为 RC,不能让下一位调用者在不知情的情况下继承它。

1.3 保存点只回到事务内部

SAVEPOINT 可以把一个事务内的部分工作撤回,再继续处理。实验先将账户 1 改为 80,建立保存点,将账户 2 改为 120,再回滚到保存点并提交,最终得到 80 与 100。

SAVEPOINT before_second;
-- 执行后续语句
ROLLBACK TO SAVEPOINT before_second;

这不能撤销已经提交的另一个事务,也不能回滚已经发出的邮件。外部系统的副作用需要自己的幂等、补偿或协作协议。

1.4 DDL 不适合混入业务事务

MySQL 中不少 DDL 会造成隐式提交,不能把建表、改表与普通业务更新混在一起,再依赖一个 ROLLBACK 撤销全部工作。本实验把建库建表放在独立初始化阶段,业务测试只处理自己的事务。

数据库迁移还涉及元数据锁、执行耗时、资源占用和版本回退。它需要独立验收,不能因为一段 DDL 在空表上成功就直接用于生产大表。

练习。 应用开始事务后更新账户,调用一个会执行 DDL 的辅助函数,随后捕获异常并 ROLLBACK。为什么不能仅凭最后一条回滚,就认定前面的业务更新没有提交?请为辅助函数写出一个更清楚的责任边界。

第 2 章:用业务不变量判断数据是否正确

库存字段非负、订单表插入成功、所有 SQL 都在事务里,这三个条件同时成立,仍然可能超卖。数据库保证的是被提交的操作满足其约束;业务必须先把真正需要保护的关系表达出来。

2.1 一件商品可以产生两个订单

初始库存为 1。事务 A 和 B 分别执行普通 SELECT,都读到 1。随后 A 把库存写为 0 并插入订单,B 等待 A 的写锁释放后,也把库存写为自己此前算出的 0,再插入另一个订单。两个事务都可以提交。

随书实验观察到剩余库存 0、确认销售量 2。只检查 stock >= 0 会漏掉问题。更合适的业务关系是:

期初库存 + 有效入库 = 当前可用库存 + 已确认消耗

这个式子还没有包含预留、退款和取消等业务状态,但已经能揭示当前示例违反了守恒关系。随着业务扩展,需要为每种状态写清楚何时进入哪一项。

2.2 把检查与变化放到同一个竞争点

对于单商品扣减,可以使用带条件的原子更新:

UPDATE inventory
SET stock=stock-1
WHERE sku=1 AND stock>0;

应用检查受影响行数。只有确实扣减一行,才在同一事务里确认订单;没有扣减成功就拒绝或进入其他业务流程。关键是资格判断和库存变化共同由这条更新决定,避免用旧 SELECT 的结果再次覆盖新值。

这条方法不代表所有库存业务都能缩成一条 SQL。多商品订单、预留过期和退款可能涉及多行与多个状态,仍需要设计事务、锁顺序和业务记录。

2.3 数据库约束帮助缩小错误空间

唯一键可以阻止同一业务标识重复建立记录,NOT NULL 和 CHECK 可以保护行内条件。约束应与应用校验共同工作:入口校验给出友好解释,数据库约束负责在并发竞争下做最终裁决。

不同数据之间的全局业务关系,通常不能靠一个行内 CHECK 完整表达。不要把“应用已经查过没有订单”当作唯一性保证;查询与插入之间仍有其他事务能够进入。

2.4 失败断言应检查最终关系

测试不应只断言某个函数返回 false。库存实验应从独立连接检查库存、订单数量和确认销售量。这样才能发现应用表面拒绝了一个请求,数据库却已经留下订单,或扣了库存却没有记录用途。

比较错误版本与修复版本时,保持同样的初始数据和并发入口,再核对最终不变量。测试的价值来自制造真实竞争并读取结果,而不是给正确实现复制一份同样的计算。

练习。 若增加“预留库存”和“已支付库存”两种状态,怎样扩展守恒式?用户取消预留时,哪些记录必须在同一事务中变化,哪些外部通知可以留到事务之后?

第 3 章:看清 RR 与 RC 的快照起点

同一个 SELECT 在事务里执行两次,是否一定看见相同结果,取决于隔离级别和读取类型。MySQL 的 REPEATABLE READ 与 READ COMMITTED 都支持一致性非锁定读,但建立快照的时间不同。

3.1 BEGIN 不等于已经固定了普通快照

在本书的 RR 实验中,连接 A 开始事务,尚未做普通一致性读;连接 B 将库存改为 2 并提交。随后 A 第一次 SELECT 读到 2。B 再改为 3 并提交,A 的第二次普通 SELECT 仍读到 2。

A: BEGIN
B: UPDATE stock=2; COMMIT
A: 普通 SELECT -> 2,建立本事务一致性读快照
B: UPDATE stock=3; COMMIT
A: 普通 SELECT -> 2
A: COMMIT
A: 新事务普通 SELECT -> 3

这说明本例 RR 的快照由第一次一致性读建立,不能笼统地把所有 BEGIN 时刻都当作读视图起点。显式使用一致性快照语法属于另一种需要说明的选择。

RR 实验时间线:BEGIN 后第一次普通读建立快照;其他事务后续提交不会改变该快照。

3.2 RC 每次一致性读使用新的快照

在 RC 实验里,A 第一次读到 1,B 更新为 2 并提交,A 同一事务的下一次普通 SELECT 就读到 2。对于报表和业务决策,读者需要知道多条查询是否希望基于同一个历史视图。

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

修改隔离级别不会自动替你保护库存不变量。RC 并不等于“可以不用锁”,RR 也不等于“所有语句永远只看到旧数据”。下一章会看到锁定读和更新对当前记录的处理。

3.3 自己的修改仍然可见

一致性读一般不看快照建立后的其他事务提交,却能看见自己之前语句所做的修改。这种组合可能呈现出一个从未在其他事务眼里整体存在过的视图。官方手册特别说明了这项边界。

因此,不能把事务内连续几次 SELECT 简单理解为对数据库做了一张绝对静止的照片。需要区分哪些行来自快照,哪些是自己的变化,哪些语句采用了锁定读取。

3.4 隔离级别需要连同访问模式一起讨论

本书实际实验重点是 RC 与 RR。READ UNCOMMITTED 和 SERIALIZABLE 可作为参照,但不能据此声称已完整压测四种隔离级别。不同级别影响读与锁策略,也会与索引、范围和语句形式共同作用。

选择级别时先写业务要求,再验证具体 SQL。面对并发错误,不应先把隔离级别改到最高,期望它消除所有程序缺陷;也不应为了减少等待直接切成 RC,却不重测业务约束。

练习。 在 RR 下先 BEGIN、等待三秒、再做第一次普通 SELECT。另一个事务在这三秒内已经提交。仅凭 BEGIN 更早,能否判断这次 SELECT 一定读不到新值?请画出快照真正建立的时刻。

第 4 章:普通读取与锁定读取回答不同的问题

普通 SELECT 适合读取一致性视图,SELECT ... FOR UPDATE 则用于在事务中锁定将要处理的记录。两者混用时,如果还假设整个事务只有同一个可见版本,就容易错误解释结果。

4.1 一个看似矛盾的结果

RR 实验先读库存得到 1;另一个事务改为 2 并提交。原事务执行 FOR UPDATE 读到 2,再次做普通 SELECT 仍读到 1。三个结果分别来自一致性读、锁定读和原来的读视图。

SELECT stock FROM inventory WHERE sku=1;
SELECT stock FROM inventory WHERE sku=1 FOR UPDATE;

这不是数据库把一次 SELECT 缓存在客户端。它说明读取模式是语句语义的一部分。更新语句也不能简单按普通 SELECT 的历史快照来推导。

4.2 锁需要覆盖后续的业务动作

如果采用先读后改,应在显式事务里进行锁定读,并在同一事务中检查和修改。若每条语句都自动提交,锁无法按预期保护后续业务阶段。

START TRANSACTION;
SELECT stock FROM inventory WHERE sku=1 FOR UPDATE;
-- 在持有记录锁期间检查并修改
COMMIT;

持锁期间不宜等待用户输入或长时间调用外部服务。数据库锁只保护数据库中的竞争,还会占用其他事务的等待时间;网络操作应尽量移到合适的事务边界之外。

4.3 SKIP LOCKED 适用于队列式认领

两个工作者从本地事件表取任务,可以使用 FOR UPDATE SKIP LOCKED 让第二个工作者跳过第一个已锁住的记录。本实验里,工作者 A 取得事件 1,B 取得事件 2;A 回滚后,另一个工作者又能够取得事件 1。

这证明锁与回滚的本地认领行为,不证明消息已经发送一次。SKIP LOCKED 会跳过数据,得到的不是一般事务所需的一致完整视图,官方手册也明确提示其适用边界。不能将它直接用于要求完整结果的财务统计。

4.4 锁定范围由访问路径参与决定

唯一键等值查找、非唯一范围扫描与全表扫描,可能涉及不同的索引记录和间隙。不能只看 SQL 写了一个业务条件,就认定只锁定自己期望的那一行。

本书会用执行计划与实际等待关系一起排查。只看“有索引”不足以判断锁范围,必须确认使用了哪个索引、条件是否形成范围,以及使用什么隔离级别。

练习。 为什么事件表工作者适合考虑 SKIP LOCKED,而“计算今天所有应付款总额”通常不适合?请分别写出对跳过记录的容忍程度,以及跳过后必须怎样继续处理。

第 5 章:先读执行计划,再讨论索引是否有用

一条查询在开发机很快,线上却扫描大量记录。原因可能是数据规模、分布、统计信息、条件写法或访问路径发生变化。建立索引之前,应先确认数据库实际打算怎样读取数据。

5.1 用同一份数据比较计划

随书实验建立 2000 行 people 数据,以 email 作为查询条件。没有 email 索引时,JSON 计划中的访问类型为 ALL;增加唯一 email_idx 后,计划选择该索引与相应的等值访问方式。

EXPLAIN FORMAT=JSON
SELECT id FROM people WHERE email='user1000@example.invalid';

这个实验没有报告“快了多少倍”。小数据集、缓存状态和共享 CI 机器不适合据此提供生产性能数字。它证明的是同一查询在该条件下改变了访问路径。

5.2 读懂几项关键字段

access_type 描述访问方式,possible_keys 列出可能的索引,key 是实际选择,rows 一般是估计扫描量,filtered 是条件过滤比例。估计值与实际执行值需要区分,不能把 rows 当作已经发生的物理磁盘读取次数。

传统表格计划中的 rows,在本章 JSON 表节点里对应 rows_examined_per_scan;报告应使用实际格式的字段名。本版证据从 2000 改为 1 的也是计划估计,不是一次物理 I/O 测量。

JSON 格式可能省略不适用字段,并把不同查询结构组织成不同层次。实验递归寻找目标表节点,没有假设所有查询永远位于某个固定路径。复杂连接、子查询与不同版本需要重新核对计划结构。

5.3 索引同时影响读、写和锁

索引可以缩小读取范围,也增加写入维护成本与存储占用。对事务来说,访问路径还会影响遇到哪些索引记录,从而改变等待与竞争。添加索引需要测试关键写入和锁定查询,不能只测一个 SELECT。

例如订单查询经常按 tenant_id 与状态过滤,再按创建时间排序,索引设计应从这些真实访问模式出发。单独为每一列建一个索引,不能保证组合条件与排序都高效。

5.4 EXPLAIN 与实际执行各有位置

EXPLAIN 描述计划。需要实际执行信息时,可以在受控环境评估 EXPLAIN ANALYZE,但要知道它会执行查询,且具体支持形式受语句与版本约束。不要把带副作用的语句当作纯查看操作随意运行。

排查慢请求还要确认连接池等待、锁等待、返回行数、网络传输与数据库执行分别耗时多少。查询计划很好,也可能因为事务长时间持锁而让用户等待。

练习。 计划已经使用索引,但一次请求仍然耗时十秒。请列出至少三种不以“再加一个索引”为直接答案的检查方向,并为每个方向写出需要收集的证据。

第 6 章:把“卡住了”还原成等待关系

应用看到 UPDATE 长时间没有返回,首先需要区分数据库正在执行、等待行锁、等待元数据锁,还是请求还没有获得连接。把这些都称为“SQL 慢”,会让优化方向偏离实际原因。

6.1 等待关系是一张有方向的图

事务 A 锁住账户 1,事务 B 也想修改账户 1。B 等待 A,是一条从等待者指向阻塞者的边。若 A 还在正常执行并很快提交,这只是等待;若各事务互相等待形成环,就可能构成死锁。

排查时至少关联事务、连接、当前语句、目标对象和持锁时长。只看最后一条正在等待的 SQL,不一定能找到阻塞者最初获得锁的语句;阻塞者当前可能已经空闲,却仍未结束事务。

6.2 直接观察实际等待

MySQL 8.4 的 Performance Schema 提供 data_locks 与 data_lock_waits。随书实验让一个事务明确持有账户记录的锁,再启动等待者,从独立连接查询实际等待边。

SELECT r.OBJECT_SCHEMA, r.OBJECT_NAME,
       w.REQUESTING_ENGINE_TRANSACTION_ID,
       w.BLOCKING_ENGINE_TRANSACTION_ID
FROM performance_schema.data_lock_waits AS w
JOIN performance_schema.data_locks AS r
  ON r.ENGINE=w.ENGINE
 AND r.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID
WHERE r.OBJECT_SCHEMA=DATABASE();

这是诊断查询,需要相应观测权限。先选择本次实验数据库,DATABASE() 才能提供正确限定;尚未选库时它为 NULL,不会匹配。本版自动测试直接绑定随机库名作为参数,避免将实例上其他会话混入证据。生产环境还要结合访问权限和字段脱敏管理,不能把全实例事务信息无条件提供给所有用户。

6.3 索引、范围与隔离级别共同决定锁

InnoDB 的记录锁与索引记录相关。范围扫描还可能涉及间隙与 next-key 锁,行为随隔离级别、条件和访问路径变化。RC 减少某些间隙锁行为,并不表示所有竞争或死锁消失。

本书真实实验重点验证主键行竞争、反向锁序、等待超时与队列认领,没有完整枚举所有范围锁布局。遇到自己的范围查询时,应固定表结构、数据和隔离级别,从实际计划与锁表读取结果,再解释现象。

6.4 解除等待不等于修复根因

终止阻塞连接可能解除眼前等待,也可能导致昂贵回滚,并影响该连接承担的业务。操作前应确认业务归属、事务状态和恢复方式。不能只因为它出现在阻塞图里就直接执行终止命令。

根因可能是事务中调用了慢外部接口、连接池归还前没有回滚、批量更新范围过大或索引失效。应把“解除当前阻塞”和“避免下次发生”分别记录,前者是处置,后者需要代码或数据访问方式的改动。

练习。 阻塞者当前显示 Sleep,等待者是一条 UPDATE。为什么仍可能是前者持有锁?请写出你需要补充的事务信息,以及终止它之前需要确认的业务事项。

第 7 章:死锁后,重试的是完整业务事务

两个事务分别先更新账户 1 与账户 2,再尝试更新对方已经锁住的账户。双方都无法继续,形成等待环。InnoDB 默认开启死锁检测,通常会选择一个受害事务并回滚,让另一个继续。

7.1 用相反锁序制造真实竞争

实验的两个线程都先成功完成第一条 UPDATE,再通过同步屏障进入第二条。这样不依赖猜测某个 sleep 足够长,能够稳定制造竞争窗口。

最终一个事务提交,另一个收到 1213。账户初始为 100,获胜事务分别增加两行,最后都是 101;受害事务的第一条修改没有残留。这是整笔事务被回滚的证据。

为了区分引擎自动回滚与应用主动清理,这个隔离实验在捕获 1213 后故意不调用 ROLLBACK,而是再发送 COMMIT。若第一条修改仍被保留,它就可能被提交,最终余额不会是 [101,101]。这是验证回滚范围的探针,不是建议生产应用在异常后继续提交业务;实际恢复仍应显式结束当前事务、重新读取并按完整业务单位处理。

A: 锁账户1,增加1
B: 锁账户2,增加1
A: 等待账户2
B: 等待账户1
InnoDB: 回滚一个受害事务
另一个事务继续并提交

哪个事务被选为受害者不是应用应依赖的稳定顺序。测试检查一份提交、一份 1213 和最终余额,没有规定某个线程必须获胜。

7.2 不只重发报错的那一条

受害事务之前的读取和写入已被回滚。只重试最后一条 UPDATE,可能丢掉整个业务过程的前半部分。正确的恢复单位是经过重新读取与校验的完整业务事务,并且仍应受到次数、期限和资源预算限制。

业务事务如果包含事务外副作用,重试还可能重复发送通知或调用支付。这些动作应通过业务幂等与事件机制安排,不能仅靠数据库回滚消除。

7.3 统一顺序缩小等待环的机会

多个事务需要锁同一组账户时,可以按稳定的账户 ID 顺序获得锁。缩短事务、减少持锁期间的外部等待,以及为重要条件提供适当索引,也有助于降低竞争。

这些做法减少风险,不构成“以后不会再死锁”的承诺。范围、多个表和不同访问路径仍可能产生复杂竞争;应用必须保留死锁处理与观察能力。

7.4 看诊断,也看业务结果

SHOW ENGINE INNODB STATUS 可以帮助观察最近的 InnoDB 死锁。若要持续收集,还应按部署策略管理错误日志与相关配置。诊断记录中的 SQL、对象和连接可能含业务信息,需要控制访问与保留。

最终是否修复,应以真实并发测试和业务不变量判断。死锁次数下降,却把事务拆成互不原子的几段,可能让面板更安静但业务更容易出现半完成。

练习。 转账程序扣款后发送通知,再写入收款记录。若它因为死锁重试整个函数,通知可能发生什么?请重新安排事务与通知边界,并说明仍需哪项幂等保证。

第 8 章:锁等待超时不一定回滚整个事务

收到 1205 后,应用若直接继续提交,可能提交事务里先前已经成功的语句。这与上一章的死锁受害事务不同。恢复策略必须知道具体错误与服务器配置。

8.1 先读实际配置

InnoDB 默认锁等待超时回滚当前失败语句;开启 innodb_rollback_on_timeout 时,行为会改变。随书实验先检查该配置为关闭,再观察默认路径,避免把一个部署配置当成所有实例的常量。

SELECT @@innodb_rollback_on_timeout;
SELECT @@innodb_lock_wait_timeout;

锁等待超时还不同于网络读超时。前者是服务器明确返回数据库错误;后者可能意味着客户端根本没有收到语句的最终结果,不能由异常类名字相似就采用同一处理。

8.2 一个故意提交半完成的反例

连接 A 持有账户 1 的锁。连接 B 先把账户 2 增加 5,再尝试修改账户 1,等待后收到 1205。实验故意对 B 执行 COMMIT,最后 A 回滚。

实际余额为账户 1 的 100、账户 2 的 105。B 的第一条更新保留下来,说明失败的第二条语句没有自动撤销整个事务。这个有意制造的反例仅在专用随机数据库中运行,不是推荐的业务处理方式。

8.3 把应用恢复范围写清楚

对于要求整个业务一起完成的事务,一个清楚的应用策略是捕获相关异常后显式回滚整笔事务,再在允许的条件下重新开始业务过程。不要让连接带着部分状态回到池里,也不要在不了解前序变化的情况下重发最后一条语句。

某些应用确实希望使用保存点或重试语句,这需要明确证明局部恢复与业务约束相容。选择哪一种,应基于事务内容和配置,而不是套用一个捕获所有异常的通用重试装饰器。

8.4 超时时间不是吞吐容量

提高锁等待时间可能减少短暂竞争导致的报错,也可能让更多连接长时间挂起。等待者占用应用线程和连接池时,故障会传播到本来不相关的请求。

应先找出谁在持锁、持锁多久、为什么不提交,再决定是否调整期限。将队列堆在数据库连接里,无法替代请求准入、事务缩短或热点数据设计。

练习。 同一事务第一条语句已成功,第二条等待超时报错。应用 catch 后没有回滚,连接池归还连接时也没有处理。下一位使用该连接的请求可能继承什么?你会分别在业务代码和连接池边界检查哪项行为?

第 9 章:唯一业务键与库存变化共同提交

用户点击两次,网络层重发一次,任务恢复又执行一次,同一个业务意图可能多次到达数据库。幂等需要有稳定的业务身份,以及围绕这个身份建立的约束和结果查询。

9.1 查询不存在不能代替唯一键

两个事务可以同时查询某个订单键不存在,再各自尝试插入。真正裁决重复的是数据库唯一约束。应用应能识别预期的重复键冲突,并查询该业务键已有的结果,而不是把所有唯一冲突都描述成成功。

幂等键需要作用域,例如租户、业务类型和操作标识。还要核对参数:同一个键第一次购买商品 A,第二次却变成商品 B,不能仅因键相同就无条件返回第一次的成功。

9.2 把键与扣减放在同一事务

随书实验先插入唯一操作键,再执行条件扣减,然后提交。两个线程使用相同键,一个完成,另一个收到 1062 并回滚。最终只有一个订单,库存只扣减一次。

START TRANSACTION;
INSERT INTO orders(op_id,qty) VALUES('same-operation',1);
UPDATE inventory SET stock=stock-1 WHERE sku=1 AND stock>0;
-- 确认受影响行数为1,否则回滚
COMMIT;

不能把插入幂等记录单独先提交,再让库存操作在另一笔事务里失败;那样重复请求会查到一个“存在但业务没完成”的记录。若业务确实需要多阶段状态,应明确保存 pending、completed 等含义与恢复动作。

9.3 字符比较规则也是合同

业务键使用字符串时,需要考虑大小写、编码、长度与排序规则。数据库的默认比较规则可能认为两个外观不同的字符串相同。实验使用 VARBINARY 保存操作标识,明确按字节比较,避免隐含的大小写折叠改变测试含义。

真实系统应按业务定义选择比较规则,并在入口规范化后保存原始或可审计信息。截断键、随意转小写或跨租户复用键,都可能让原本不同的操作相互覆盖。

9.4 幂等需要保留期限与可查询结果

唯一记录删除后,旧请求再来可能重新执行。需要说明记录保留多久、客户端重试窗口多久,以及成功结果怎样查询。对外返回“重复请求”只能说明没有新建记录,不足以说明原操作已经成功。

实验验证当前数据库生命周期内的去重,不提供完整的业务 API 或跨系统幂等。下一章会使用同一业务身份,处理提交响应丢失时的未知状态。

练习。 同一租户重复使用同一个键,但数量由 1 改为 2。你会返回什么,依据保存在哪里?如果只保存键而不保存请求指纹,系统还缺少哪项判断证据?

第 10 章:提交响应丢失,不能直接当作回滚

客户端发送 COMMIT 后,数据库可能已经完成提交,而确认响应在网络中丢失。应用捕获到连接异常,却不能仅凭异常认定订单不存在。这是许多重复扣款、重复发货问题的起点。

10.1 把服务器事实与客户端知识分开

数据库已提交是一项服务器事实,客户端已收到成功确认是另一项事实。两者之间存在网络窗口。超时或连接断开,只能说明客户端没有取得可靠结果。

此时应进入 unknown 状态,使用稳定业务标识核查,而不是把同一个业务意图换个新键再次执行。若原连接仍有不确定状态,也不应继续让它承担后续业务。

10.2 一个真正切断响应的实验

随书故障代理只在环回地址监听,转发 MySQL 数据包。当它观察到客户端的 COMMIT 和服务器返回的 OK 后,关闭客户端连接,故意不转发确认。

测试从独立连接查询操作键,确认订单实际存在。由此同时得到两份证据:客户端收到错误,服务器记录已经提交。它比在程序里手工抛一个异常,更准确地制造了这个边界。

代理只用于明文教学连接,不解密 TLS、不记录数据包内容,也不适合作为生产代理。普通连接保留驱动自身的 TLS 行为,只有这个明确的环回故障注入连接禁用 TLS。实验开发过程曾因新驱动默认协商 TLS 而卡在握手,修复针对的是注入环境,不是取消生产传输保护。

10.3 查询状态也需要选择可信位置

如果提交后立即去一个有复制延迟的只读副本查询,暂时查不到不代表主库没有提交。业务需要明确查询位置、读一致性和等待策略。本书使用同一个单节点 MySQL 的独立连接核查,没有证明复制与故障切换语义。

若查询得到业务键,继续检查其参数、状态和结果;若暂时查不到,应按已定义的恢复协议处理,而不是由一个瞬时的空结果推导永不提交。长时间无法确认时,需要告警和授权核查流程。

10.4 不确定状态需要被保存

应用任务表可以保存请求身份、操作键、最后已知阶段、尝试记录和核查结果。它不能神奇地消除分布式不确定性,却能防止每个重试者都从零猜测。

补偿也需要依据。如果订单已经提交却没有返回响应,盲目“补偿扣款”可能反而撤销了有效订单;如果外部支付状态未知,数据库中的一条 rollback 也无法证明支付被撤销。

练习。 COMMIT 响应丢失后,应用在从库查不到订单,于是换一个操作键重试。请标出至少两个缺少证据的推断,并设计一条保留 unknown、查询原键和限制新执行的恢复路径。

第 11 章:本地 Outbox 解决哪一段原子性

订单提交后需要发布事件。如果先提交订单,再发送消息,进程可能在两步之间退出;如果先发消息,再提交订单,消费者可能看见最终没有成立的业务。Outbox 把订单与待发事件写入同一数据库事务,缩小这段缺口。

11.1 订单与事件共同提交

随书实验一笔事务插入订单和 outbox,提交后两者都存在;另一笔事务插入后回滚,两者都不存在。这个结果证明同一 InnoDB 本地事务的原子性。

START TRANSACTION;
INSERT INTO orders(op_id,qty) VALUES('order-1',1);
INSERT INTO outbox(payload) VALUES('{"operation":"order-1"}');
COMMIT;

它没有证明事件已经进入消息系统,也没有证明消费者只执行一次。发布者、Broker 与消费者的故障窗口仍需分别处理。

11.2 认领与发送之间仍有窗口

多个发布者可以使用队列式锁定读取认领不同记录。本实验用 SKIP LOCKED 验证互不重复的本地选择,以及回滚后其他工作者可以重新选择该行。

若发布者发送成功、更新 sent 标记之前退出,下次可能再次发送。若它先标 sent 再发送,退出时又可能丢失发布。没有额外的跨系统协议,不能由本地表推导全链路恰好一次。

11.3 消费者需要自己的业务身份

事件应携带稳定身份,消费者将去重记录与自己的业务变化共同提交。消费者返回确认之前还是之后发生数据库提交,也有窗口,需要结合实际消息系统的投递和确认语义验证。

这与第 9 章的订单幂等类似,但不能把生产者的一个唯一键直接当作所有下游系统已经实现去重。每个执行副作用的边界都需要自己的合同。

11.4 记录积压与恢复依据

Outbox 应保留事件创建、认领、发送尝试、结果与错误分类。积压量和最老事件年龄可以帮助定位发布停滞;重复发送比例则需要结合消息身份与确认记录。

本书没有连接真实 Broker 的 Outbox 派发器。它完整验证数据库侧共同提交与认领边界,消息投递可结合 RocketMQ 或 Kafka 书中的实验继续实现。生产设计必须再验证发送成功后崩溃、重复投递和消费者提交后确认丢失。

练习。 发布成功后程序退出,outbox 仍为未发送。下一轮可能发生什么?请分别写出生产者记录、事件身份和消费者事务需要保存的信息,并指出哪一项无法只靠 sent 布尔值表示。

第 12 章:按证据缩小一次数据库故障

用户说“下单很慢”,不一定是某条 SELECT 扫描太多。请求可能在应用队列、连接池、数据库锁、执行阶段或返回网络上等待。排障先确定阶段,再提出改动。

12.1 保存能够关联的最小信息

应用侧应记录请求标识、数据库操作类别、事务阶段、错误码、总耗时与重试次数。连接池等待最好单独测量。日志中不默认写完整 SQL 参数、密码或个人数据,必要诊断应经过脱敏与访问控制。

数据库侧结合连接与事务信息、实际锁等待、执行计划和受控的慢查询记录。一个应用超时与一个数据库错误码不是同一份证据,应保留两端事实,而不是只记一行“数据库失败”。

12.2 用问题选择工具

现象 先确认 可以补充的证据
获取连接前就耗时 连接池容量与占用 池等待、活跃连接、事务时长
UPDATE 等待 谁阻塞谁 data_lock_waits、data_locks、事务与连接
查询扫描过多 实际访问路径 EXPLAIN、统计信息、返回与扫描规模
收到 1213 受害事务与锁顺序 最近死锁记录、业务事务完整性
收到 1205 超时配置与前序语句 rollback_on_timeout、当前事务状态
COMMIT 后连接异常 结果是否已提交 原业务键、可信数据库查询、恢复记录

这些工具应在权限与负载允许的条件下使用。诊断查询本身也可能消耗资源,不能把大范围抓取当成无成本操作。

12.3 每次改动只验证它能证明的事情

加索引后计划变化,应重新检查关键读写与锁行为;缩短事务后等待减少,应验证业务原子性仍成立;增加重试后错误减少,还应检查尝试放大、延迟和重复副作用。

没有真实负载实验时,不给出吞吐提升百分比。没有断电和恢复实验时,不把正常进程重启写成断电持久性证明。本书的 CI 实验证明所列事务与故障窗口,生产容量与高可用需要单独验收。

12.4 复盘保留原始事实与决策

一次好的复盘能区分症状、证据、根因、处置和长期改造。例如“终止长事务后等待消失”是处置效果;根因可能是应用在事务里等待外部接口。长期改造应缩短边界并增加相应并发回归,而不只是继续调大连接池。

练习。 一次故障通过重启应用暂时消失。请写出至少三种仍未被证明的根因假设,以及下一次发生时应提前采集的信息,避免再次仅依靠重启确认问题。

第 13 章:第一次运行,把环境、断言和证据对齐

前面的例子解释了事务应该保护什么,现在要让真正的 InnoDB 决定并发结果。第一轮运行的目标不是看到十五个绿色名称,而是确认代码连接了哪一个实例、使用什么会话设置,以及每个结论来自哪次独立查询。下面的实测节选来自 GitHub Actions 运行 39574392227,不是本机安装与运行数据库的记录。

13.1 先建立独立实验实例

准备 Python 和 Docker Compose 或等价的隔离 MySQL 环境。若你的机器具备 Docker,可按 lab/README.md 启动专用容器;下面显式写出根用户来源范围,并只把端口映射到宿主环回地址:

docker run --rm --name mysql-book-lab \
  -e MYSQL_ROOT_PASSWORD=book-lab-only \
  -e 'MYSQL_ROOT_HOST=%' \
  -p 127.0.0.1:23306:3306 mysql:8.4

密码是公开教学值,不是等待你填入生产密码的占位符。另一个终端检查初始化是否结束:

docker exec mysql-book-lab mysqladmin ping \
  -h 127.0.0.1 -uroot -pbook-lab-only --silent

这个检查只证明服务器能响应,不能代替后面的建库权限、存储引擎和事务语义验证。mysql:8.4 标签可能继续更新;本次证据实际记录 MySQL 8.4.11,lab/evidence/container.json 保存拉取镜像的摘要。严格复现时应使用记录的摘要,而不是仅凭标签推定二进制完全相同。

本地缺少这些工具时,可以阅读 Actions 的原始产物,或在自己的隔离环境准备好之后再运行。不要把程序改为连接一个方便取得的共享数据库来绕过环境准备。

13.2 全套运行与单个场景

在仓库根目录执行:

python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements.txt
python3 -m lab.demo --evidence lab/evidence/mysql.json

lab.demo 使用 unittest 运行场景,再把版本、观测和失败信息写入 JSON;终端测试日志与证据文件是两种输出。想集中复查保存点,可以单独执行:

python3 -m unittest \
  lab.scenarios.InnoDBCases.test_02_rollback_and_savepoint_are_transactional -v

单独运行 unittest 不会自动重写完整 JSON 报告。不要把终端的新结果与旧文件里的 tested_at 混为同一次执行。

本次全套报告节选为:

{"python":"3.12.15","driver_version":"1.2.3",
 "tests_run":15,"passed":true}

首项数据库合同检查还记录 REPEATABLE-READ、innodb_rollback_on_timeout=0 和死锁检测开启。若实例不满足这些前提,测试应失败,让读者重新确认环境;不应为了得到绿色结果而删除前提断言。

报告里的 Python 是实际解释器版本,driver_version 从已安装发行包元数据读取,服务器版本则来自 SELECT VERSION();三个来源不能用镜像标签互相替代。保存 mysql.json 时,也应保留同次 container.json,避免把新拉取的镜像与旧测试结果拼成一份记录。

passed=true 还需要连同 tests_run、observations 和 failures 检查。若后续改了测试发现规则,只运行两项也可能得到通过;若某项失败,前面已完成的观测仍可能写进报告。先核对实验清单,再根据具体失败定位,不要只搜索文件里有没有 true。

13.3 临时数据库与独立观察者

每个场景创建自己的 book_lab_ 随机数据库,建立 inventory、orders、accounts 和 outbox,结束后只删除本次创建的库。名称由程序生成,清理逻辑不接受环境变量传入的任意库名。测试会执行真实建库和删库,因此需要独立实例与相应权限。

业务连接显式关闭自动提交,并设置隔离级别;控制连接开启自动提交,负责检查其他事务最终留下的状态。这个差异很关键:若观察者也沿用已经建立的 RR 快照,可能因为看见旧版本而错误判断另一个事务没有提交。

测试结束时随机库已经清理。JSON 里保留了观测值,不表示你还能在结束后连接那个库查询。想增加中间观察,应在自己的实验分支中安排受控暂停、保留连接和恢复清理,而不是删掉清理代码后长期积累数据库。

13.4 改参时保留可比较的前提

可以将独立容器映射到 23307,再用 MYSQL_PORT=23307 执行相同命令,验证连接参数确实指向目标实例。默认配置只接受环回地址;不要通过放开地址检查去连接生产服务。改隔离级别或服务器回滚配置属于下一层实验,应同时调整合同与预期,并重新保存报告。

如果连接失败,先依次确认容器初始化、宿主端口、凭据、建库权限和驱动依赖。若数据库能响应却断言失败,再看具体场景和 SQL。不能把全部失败笼统归为“并发偶发”,也不能从健康检查通过推导事务实验已经完成。

练习。 将全套运行与单项运行各保存一份独立记录,说明版本、测试数量、文件时间和实际执行命令怎样对应。再指出为什么控制连接的自动提交设置,会影响你对最终库存与订单的判断。

第 14 章:用两条连接重放快照与等待

同一事务里读到不同数值,可能是隔离级别改变,也可能只是读取方式不同。实验先控制两条连接的交错顺序,再让数据库返回结果。只有把“谁先开始、谁先提交、哪一条建立快照”写清楚,数字才具有解释力。

14.1 重放 RR 的 2、2、3

可以单独运行快照场景:

python3 -m unittest \
  lab.scenarios.InnoDBCases.test_03_rr_snapshot_starts_at_first_consistent_read -v

测试在 A 执行 BEGIN 后,让 B 把库存改为 2 并提交,再让 A 第一次普通读取。B 随后改为 3 并提交;A 第二次读取,结束事务后再读。真实记录是:

{"after_begin_before_first_read":2,
 "same_snapshot":2,"new_transaction":3}

第一次读到 2,说明本例读视图没有在 BEGIN 就固定;第二次仍为 2,说明后续普通一致性读沿用它;新事务读到 3,说明后来提交的数据确实存在。三个数要一起检查。只展示中间的两个 2,不能排除 B 根本没有成功更新。

做改参实验时,可以在 A 的 BEGIN 后、B 提交前插入一次普通库存查询,再预测后续结果。这个新增读取会提前建立快照,原来的 2、2、3 断言就需要重新推导。它是待你独立运行的扩展,不是现有十五项结果的一部分。

14.2 RC 与锁定读分别改变什么

RC 场景在同一事务里先读 1,另一个连接提交 2,再读得到 2。完整证据记录 first=1, second=2。这里改变的是一致性读的快照策略,不代表数据库忽略了写锁,也没有解决所有先读后写的竞争。

另一个 RR 场景保留了更容易误解的对照:

{"consistent_before":1,"locking_read":2,"consistent_after":1}

A 先普通读取建立旧视图;B 更新并提交;A 的 FOR UPDATE 读取较新的记录并加锁,再做普通读取仍使用旧视图。本例 A 没有修改这条记录。若你在锁定读后加入自己的 UPDATE,后续普通读还会受到“能够看见自身修改”的规则影响,不能继续机械地套用最后那个 1。

建议先分别运行场景 04 与 05,再回看源码中的每条语句。把所有 SELECT 都换成 FOR UPDATE,可能让某个数字更容易解释,却同时改变了锁与等待成本,也丢失了原实验要观察的区别。

14.3 在竞争发生时取证

等待实验先让 holder 更新账户 1 且不提交,再启动第二条连接修改同一行。独立观察连接轮询 Performance Schema,筛选当前随机数据库的 accounts 表。此次记录为:

{"observed_lock_wait_edges":1,
 "blocker_rollback_released_waiter":true}

断言要求至少观察到一条实际等待边,不要求所有环境永远恰好只有一条。随后 holder 回滚,等待者完成并提交,最终余额成为 101。等待边与最终状态相互补充,证明程序不只是让一个线程睡眠来伪装锁等待。

python3 -m unittest \
  lab.scenarios.InnoDBCases.test_15_performance_schema_exposes_real_blocking_edge -v

若在等待者完成以后才查询锁表,返回零可能只是竞争窗口已经结束。不要据此断言数据库从未等待。扩大观察窗口的改参方式是延长受控持锁阶段,同时保持等待者超时、线程 join 和清理期限相容;不能把 join 改成无限等待来掩盖阻塞。

这里的线程事件仅说明等待者已经准备进入 SQL,不等于锁等待必然已经建立。真正使结论成立的是随后在 Performance Schema 中读到数据库等待边。轮询与线程退出都有期限:较慢环境若没捕获到窗口,应检查调度、权限、连接错误和时间安排,而不能把“没看见”改写成“已经证明没有等待”。

同一测试中的观察连接选择了本次随机库,查询也带库名与表名过滤。把这些条件拿掉,可能把别的会话造成的等待当成自己的成功证据。分析行锁时还要区分持锁语句和阻塞者当前语句,两者不必相同。

14.4 计划是另一种证据

索引场景在 2000 行 people 上比较同一条查询。增加唯一 email_idx 后,JSON 计划从 ALL、估计扫描 2000 行,变为本次 const、估计扫描 1 行。它证明访问路径改变;没有测出响应时间提升几倍,更不能把估计扫描量当作物理磁盘读取次数。

这也提示我们,锁等待与扫描问题需要不同证据。执行计划不能单独证明没有阻塞,等待边也不能解释所有查询成本。实际排查应先确认观察时刻与访问模式,再决定补哪一种信息。

练习。 为 RR、RC 和锁定读各画一条时间线,明确首次一致性读与另一个事务提交的位置。然后解释:为什么一次锁表空结果,和一次 EXPLAIN 使用索引,都不能单独证明用户请求没有发生数据库等待?

快照、锁定读与计划的官方入口见资料附录。

第 15 章:报错以后,先查清数据库留下什么

错误处理如果只有“捕获异常后重试”,就把三种不同问题压成了一种动作:死锁受害事务已经整体撤销,默认锁等待超时可能保留前序修改,提交响应丢失又可能意味着业务已经成功。恢复单位必须匹配数据库事实。

恢复判断:1213 重建完整事务,默认 1205 先结束残留事务,提交未知先核查原业务身份。

15.1 对照 1213 与 1205

先单独运行两种竞争:

python3 -m unittest \
  lab.scenarios.InnoDBCases.test_09_deadlock_rolls_back_one_entire_transaction \
  lab.scenarios.InnoDBCases.test_10_lock_timeout_does_not_rollback_prior_statement_by_default -v

死锁场景让两个连接分别先更新不同账户,通过屏障后再争用对方的行。结果一份提交、一份 deadlock_1213,最终余额为 [101,101]。受害者第一条更新没有残留;谁成为受害者不被测试固定,恢复时应重新读取并执行完整业务事务。

当前测试在 1213 后故意不先 ROLLBACK,直接发送一次 COMMIT,再由独立连接检查余额。这样可以排除“只是应用主动回滚才消除了残留”的解释。实验报告将此动作记为 victim_final_action=commit_without_explicit_rollback;较早的实验报告没有这个字段,不能用它单独证明这项增强断言。

1205 场景的前提是 innodb_rollback_on_timeout=0。等待者先成功修改账户 2,再在账户 1 超时。实验故意继续 COMMIT,最后持锁者回滚,得到 [100,105]。它展示了不完整业务仍可能被提交,而不是推荐在报错后这样处理。

将会话锁等待上限从一秒改为两秒,可以研究等待期限,而不能改变默认回滚范围。若另开实例启用整事务超时回滚,必须同时修改环境合同与预期;不要删除配置断言后把两种行为混进同一报告。

15.2 真正丢掉 COMMIT 确认

提交未知场景使用专用环回代理。它转发客户端 COMMIT,在观察到服务器 OK 后切断连接,故意不让确认到达调用方。真实节选为:

{"client_error_code":2013,
 "server_commit_ok_observed":true,
 "independently_found_order":1}

2013 是这次驱动与断连路径的实际结果,不应把它当成所有网络故障的统一代码。关键证据是代理确实等到了服务器提交确认,而独立连接又按原操作键找到一个订单。客户端报错与数据库提交成功在这里同时成立。

python3 -m unittest \
  lab.scenarios.InnoDBCases.test_14_commit_reply_lost_can_still_mean_committed -v

代理按明文 MySQL 包识别命令,无法理解 TLS 加密后的 SQL。仅该故障连接通过 ssl_disabled=True 明确使用环回明文;普通连接保留 PyMySQL 的原有 TLS 行为。此前握手失败的修复限定在这个注入入口,不是让所有连接统一关闭传输保护,也没有额外证明生产证书校验策略。

15.3 查询原业务身份,再决定恢复

独立控制连接以自动提交方式读取同一实例,不沿用可能过期的 RR 视图,也没有从复制副本查询。找到订单后还应在真实系统核对参数、状态与结果。若查询暂时为空,仍需考虑提交时序与读取位置,不能立即换一个新操作键重做。

本例在服务器已经返回 OK 的确定位置断线,所以独立查询应找到订单。若你把故障点移到发送 COMMIT 之前,就变成另一项实验;必须重新定义已知阶段、断言与清理,不能沿用这个输出作为证明。

对于业务原子事务,1213 或默认 1205 后的恢复通常包括显式结束当前事务、重新读取和受限重试;对 unknown 则先保存身份并核查。重试次数、截止时间和事务外副作用还要有自己的约束。一个统一装饰器无法从异常名称自动推导这些责任。

核查必须发生在测试清理之前。本场景先由独立连接找到订单、记录结果,再由 tearDown 删除专用随机库。稍后查不到该库,是清理生命周期结束,不是原 COMMIT 被回滚。保留下来的证据应写明观察顺序,否则同一段“找不到记录”的描述可能指向完全不同的事实。

15.4 Outbox 只补上本地缺口

最后把订单与 outbox 放进同一事务。提交一组、回滚另一组后,两张表各剩一行;队列式认领则观察到工作者取得 1、2,第一位回滚后另一位重新取得 1。它们验证本地原子性、竞争与恢复机会,没有连接 Broker 证明消息发出,更没有证明全链路恰好一次。

发布成功但 sent 标记未更新,仍可能再次发送;消费者因此还需要稳定事件身份与自己的事务。数据库实验证明哪一段,就把结论留在哪一段。复制、备份、断电恢复与跨库故障没有在这十五项中执行,不能从单节点成功推导它们也成立。

练习。 给 1213、1205 和提交未知各写一张恢复卡:已知事实是什么,哪些前序工作仍可能存在,下一步查询或回滚针对谁,以及什么条件下允许再次执行原业务。至少指出一项不能靠数据库 ROLLBACK 撤销的外部动作。

错误处理与锁定读取的官方依据见资料附录,实际结果以本次 CI 的 JSON 和提交为准。

第 16 章:把实验结论带到真实服务之前

实验的随机数据库、单节点实例和短事务,帮助我们定位语义。真实服务还要处理连接池、权限、部署、数据量、复制与恢复。迁移时应保留不变量和证据,再重新验证每个实现边界。

16.1 会话状态必须回到可知起点

连接池复用连接时,应明确 autocommit、隔离级别、超时和未提交事务的清理。业务异常路径不能带着部分写入进入下一次请求。测试可让一个请求失败后复用同一连接,再检查新请求看见的模式和数据。

事务中执行 SQL 的对象必须真正在同一连接和事务里。框架的事务标记、异步执行与跨线程调用都有各自限制;应在运行时确认连接、事务边界和提交位置,不只根据注解名称推断。

16.2 传输与账号保护独立设计

生产应显式配置 TLS、受信任 CA 与服务端身份验证,并按工作负载分配最小数据库权限。驱动默认愿意协商加密,不等于已经验证服务器身份。不要因为环回故障代理需要明文,就取消真实服务的传输保护。

业务账号一般不需要创建和删除数据库,也不应持有全实例诊断权限。随书测试使用隔离实例的管理账号,只创建本次随机库;生产应用与诊断操作者应分别授权。

16.3 重试受业务身份与总预算约束

将明确死锁与未知提交分开处理。前者在重新读取与校验后,可以按策略重试完整事务;后者应先查询原业务身份。次数、总期限和退避共同限制恢复,避免错误率下降却把数据库负载放大数倍。

业务键、请求指纹、结果与保留期限需要可查询。幂等记录过早删除、不同参数复用同键或不同租户共享键,都可能损坏恢复判断。

16.4 索引与迁移需要代表性负载

实验计划从 ALL 改为 const,不能代替真实读写压测。上线前应使用有代表性的数据规模与分布,观察扫描、写入维护、锁等待和事务时长,并制定回退与发布窗口。

DDL 的算法、锁与耗时要按所用版本及操作验证。先在隔离环境演练长事务与迁移并发,确认元数据等待、资源和故障恢复;不能从空表成功推导大表可以无感变更。

16.5 复制、备份与持久性需要另一组证据

本书没有进行主从切换、断电或备份恢复演练。单节点正常事务结果不能证明这些保证。生产应分别确定恢复点目标、恢复时间目标、读取位置、复制延迟、备份可恢复性和切换后的幂等记录状态。

“有备份”应通过恢复到隔离实例并核对业务数据来验证。故障切换后,查询原操作键的可信位置也可能变化,未知提交恢复流程必须与数据库高可用设计一起演练。

16.6 给值班者一条可执行路径

运行手册应说明如何查原业务键、识别当前事务、读取等待边、处理连接以及进行授权核查。任何会终止会话、回滚事务或修改业务状态的动作,都需要记录依据与影响范围。

练习。 将实验中的管理账号替换为业务账号与诊断账号,分别列出所需权限。再选一个尚未验证的生产保证,写出完成时需要的实际演练,而不是只列一个配置名称。

第 17 章:交付一个可以解释失败的订单内核

综合项目的目标是一个小而清楚的订单内核:它能够在并发、重复与失败时保持库存和订单的关系,并能解释尚未确认的状态。范围可以有限,但每项承诺都应有可执行验收。

17.1 先列出业务身份和状态

订单至少保存租户、操作键、请求指纹、商品与数量、处理状态和结果身份。库存变化有明确用途,事件表关联已确认的业务记录。对外查询原操作键时,应能区分不存在、处理中、已完成与待核查。

状态名本身不能提供保证。需要说明哪笔事务建立它、哪些唯一键保护它,以及发生异常时谁有权继续。不要把 HTTP 超时直接写成订单取消,也不要把数据库重复键直接写成新的成功。

17.2 为每项规则选择一个竞争实验

规则 正常验证 失败验证
库存守恒 一次订单共同提交 多个买家竞争最后一件
业务去重 原键查询得到结果 同键并发与不同参数冲突
完整事务 两侧变化共同成功 1213 与 1205 分别处理
未知提交 收到确认后完成 COMMIT OK 后响应丢失
本地事件 订单与 Outbox 同事务 回滚后两者均不存在
运行恢复 读取稳定业务状态 进程重启、重复执行与核查

这张表是综合项目的验收目标,不表示随书十五项测试已经全部覆盖。现有测试提供库存竞争、同键同参数并发、1213/1205、提交确认丢失和本地 Outbox 的数据库事实;同键不同参数的指纹冲突、完整服务恢复与跨进程重启需要你继续实现并补测。现有 orders 教学表没有租户、请求指纹与完整状态字段,不是一个完整上线订单平台。

17.3 把真实接口放在数据库合同之上

写一个服务接口并不困难,困难在于把错误映射成可操作的用户状态。例如库存不足应明确拒绝;重复键需要核对参数并返回原结果;未知提交应允许查询原请求,而不是要求用户反复点击。

返回信息不能泄露其他租户的订单、内部连接或完整 SQL。维护者的诊断渠道与普通用户查询渠道应分别授权。用稳定原因码关联详细审计,比直接把数据库异常字符串返回界面更合适。

17.4 用同一份证据交接

项目应交付表结构、事务边界、错误分类、重试与核查策略、测试、执行计划和真实运行记录。记录 MySQL 版本、配置、驱动版本和镜像摘要,使下一位维护者能够在变更后重新验证。

上线评审时,让另一位开发者仅依据文档回答:哪一笔订单已确认、哪一个事务已经回滚、哪个请求还未知,以及为什么不允许再换一个键重试。答不出来的地方,通常需要补充状态或证据。

17.5 下一步扩展应沿着新边界验证

接入消息系统时验证发布与消费窗口;接入缓存时说明缓存与数据库谁是事实源;增加只读副本时重新检查读一致性;增加多商品订单时验证锁顺序与业务守恒。每次扩展都应增加针对新边界的实验。

综合练习。 实现一个只有下单、查询原操作键与事件认领的最小服务。提交一页设计、完整故障矩阵和实际运行报告。明确哪些测试已经执行,哪些仍是计划,并用业务不变量判断每个失败场景的最终结果。

附录 A:练习解题方向

本附录提供判断依据。实际结果仍应由对应版本的真实数据库实验确认,不能把书里的预期字符串当成运行记录。

事务与快照

第 1 章:某些 DDL 会触发隐式提交,最后的 ROLLBACK 无法据此撤销已经提交的业务变化。迁移与业务事务应分开,由迁移流程管理版本、权限、锁和回退。

第 2 章:可以将库存分成可用、已预留和已确认消耗,并保存有效入库、退款与释放关系。取消预留时,应在同一事务中改变预留状态和相应库存;外部通知可以通过同事务事件表交付,但仍需处理投递重复。

第 3 章:普通 RR 一致性读的快照不一定在 BEGIN 就建立。先明确有没有此前的一致性读、显式一致性快照或自身修改,再推导可见版本。随书实验证明 BEGIN 后提交的新值可在第一次普通读中看见。

第 4 章:队列认领允许暂时跳过被其他工作者占用的行,随后还需继续轮询和处理遗留任务。财务总额通常不能容忍悄悄遗漏一部分应付款,因此不能因 SKIP LOCKED 返回得快,就将结果当成完整统计。

执行与竞争

第 5 章:检查连接池等待、数据库锁等待、返回数据量、网络与应用处理耗时。为每个阶段分别计时,并以实际连接、事务或请求标识关联。访问路径合理只说明其中一部分工作。

第 6 章:Sleep 表示当前没有执行语句,不表示没有未结束事务。应核对事务起点、此前写入、持有锁与业务归属;终止前确认回滚影响、恢复方式和操作权限。

第 7 章:通知位于数据库事务之外,受害事务回滚无法撤销通知。可把待通知事件与业务变化共同写入数据库,再由发布者处理;发布者和消费者仍需以稳定身份去重。

第 8 章:默认锁等待超时只回滚失败语句,先前写入可能仍在事务里。业务代码应明确局部或整体回滚策略;连接归还边界应避免泄露未提交事务和会话设置。不能只在日志里记录异常便继续使用连接。

身份与恢复

第 9 章:同键不同参数应按合同拒绝冲突,或进入明确的修改操作,不能直接沿用第一次结果。保存规范化后的请求指纹与结果身份,有助于判定重复与冲突。

第 10 章:客户端没有确认不等于数据库没有提交;从库暂时没有记录不等于主库永远没有提交。应保留原键和 unknown,选择可信读取位置与核查方式,在证据不足时限制新执行。

第 11 章:发送后、标记前退出可能导致重复发送。需要稳定事件 ID、发布尝试与确认记录,消费者把去重与业务变化共同提交。一个 sent 布尔值难以表达尝试过程、确认丢失与待核查状态。

第 12 章:重启暂时解除症状,仍可能是连接泄漏、长事务、热点锁或应用线程阻塞。提前采集连接池、事务与等待关系、错误阶段及请求耗时,才能检验这些假设。

实验与交付

实验章中改变配置后,应先写出新的预期,再实际运行。关闭死锁检测、开启 timeout 整事务回滚或更换隔离级别,会改变测试前提,不应只是修改断言来让测试通过。

提交响应丢失实验要同时取得服务器 OK 被故障代理观察到、客户端发生错误、独立连接查到原业务键三份证据。只在客户端手工抛异常,不能证明制造了相同窗口。

第 16 章:业务账号按业务表和操作授权,诊断账号按观测需求授权;创建数据库与全实例信息不应成为所有应用连接的默认权限。备份恢复、高可用与容量等保证应有对应真实演练,本书没有替它们给出通过结论。

第 17 章:设计应逐项映射业务身份、事务边界、数据库约束、错误与恢复。一个有效报告可以坦诚写出“数据库侧 Outbox 已验证,Broker 投递尚未实现”,同时提供继续验证需要的故障窗口。不能用笼统的“全部成功”掩盖不同范围。

附录 B:来源与复核范围

以下第一方资料已于2026-10-21读取。书内实验使用MySQL 8.4.11与InnoDB,具体运行、配置和镜像摘要见随书证据;文档解释与实验观察分别保留,不以一方替代另一方。

M01 · InnoDB Error Handling

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-error-handling.html

死锁回滚事务;默认锁等待超时回滚当前语句;必须明确恢复范围。

M02 · Consistent Nonlocking Reads

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-consistent-read.html

RR的一致性快照在第一次此类读取建立,RC每次一致性读建立新快照,自己的修改仍可见。

M03 · Transaction Isolation Levels

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-transaction-isolation-levels.html

隔离级别和具体语句、索引及锁策略共同决定并发行为,不能仅由级别名称推导所有SQL。

M04 · Locking Reads

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-locking-reads.html

锁定读与普通快照读取不同;SKIP LOCKED会跳过记录,适合部分队列式场景而非一般一致性统计。

M05 · Deadlocks in InnoDB

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-deadlocks.html

锁顺序、事务长度和索引会影响竞争;SHOW ENGINE INNODB STATUS可观察最近死锁。

M06 · EXPLAIN Output Format

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/explain-output.html

访问方式、选定索引与扫描估计需要分别解释,EXPLAIN估计不等于实际性能基准。

M07 · Statements That Cause an Implicit Commit

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/implicit-commit.html

不少DDL形成隐式提交,应与业务事务分开设计。

M08 · Performance Schema data_lock_waits Table

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/performance-schema-data-lock-waits-table.html

等待边表与data_locks可关联实际请求与阻塞,仍需观测权限和业务上下文。

M09 · autocommit, Commit, and Rollback

https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-autocommit-commit-rollback.html

事务边界由连接模式与显式结束命令共同决定,应用需处理未结束连接。

驱动与实验环境

PyMySQL 1.2.3的发布元数据与分发源码来自 https://pypi.org/project/PyMySQL/1.2.3/,本版已核对连接参数与TLS协商代码。故障代理连接显式使用ssl_disabled,普通连接保留驱动行为;生产证书验证需要另行明确配置。

真实实验运行:https://github.com/Aias00/mysql-reliability-book/actions/runs/39574392227 。该次15项全部通过,MySQL版本8.4.11、Python3.12.15,运行结果与镜像摘要附在lab/evidence/。正式出版CI会对同源代码再次执行,并将相应产物与EPUB一起发布。

本书没有主从切换、断电、恢复备份或生产压测证据。官方手册提供的配置与能力,需要结合自己的版本、权限与业务语句验证。来源更新后,优先复核事务回滚范围、锁与读语义、计划字段和恢复流程,而不是仅替换版本号。

附录 C:术语与故障速查

术语 本书含义 判断时需要的边界
事务 一组由提交或回滚结束的数据库工作 连接、自动提交、隐式提交与外部副作用
一致性读 按读视图读取可见版本 隔离级别、首次读取与自身修改
锁定读 读取并按语义获得相应锁 显式事务、访问路径、记录与范围
当前读 针对当前可用记录的读取或修改语义 不能等同普通 RR 快照
MVCC 多版本并发控制 可见性不自动提供所有业务约束
死锁 事务之间形成等待环 检测配置、受害事务与完整恢复
锁等待超时 语句等待锁超过期限 默认只回滚当前语句,配置可能改变
唯一业务键 识别同一业务操作并由约束裁决重复 作用域、参数指纹、结果与保留期限
unknown 缺少确认提交结果的证据 不是成功,也不是已回滚
Outbox 与业务记录在本地事务中共同保存的待发事件 不等于全链路恰好一次
SKIP LOCKED 跳过已锁记录的锁定读取 适合特定队列场景,结果可能不完整
EXPLAIN 优化器计划与相关估计 不能直接当作实际延迟或磁盘读次数
执行计划回归 数据、统计、SQL或版本变化后路径改变 需要代表性数据与读写验证
复制延迟 副本尚未反映上游变化 查询为空不一定证明从未提交
观察 可以采取的下一步 不能直接推出
1213 明确完整事务回滚与重试策略 只重试最后一条 SQL 就足够
1205 查配置、处理当前事务并核对前序变化 整个事务一定已回滚
1062 核对冲突约束、原业务键及参数 一定是同一业务已经成功
COMMIT 后连接错误 保留原身份并查询可信状态 订单不存在,可以换键重做
表有索引 检查实际计划与访问范围 查询一定使用该索引且没有等待
HTTP/CLI成功 核对实际数据库状态和业务不变量 所有并发与故障窗口已经正确

实验固定单节点 MySQL 8.4/InnoDB;正常复制切换、断电持久性、备份恢复与生产压测不在已经完成的实验范围。故障名称相近时,优先核对实际阶段、错误码与数据,不用一种重试方式覆盖全部异常。

附录:随书实验完整代码

以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。

lab/README.md

# MySQL 8.4 真实实验

实验使用真正的 MySQL/InnoDB,连接到独立实验实例的环回端口。每项实验自行创建随机 `book_lab_<16位随机值>` 数据库,只清理本次代码创建的库,不从环境变量读取或删除其他数据库名。

先在隔离开发环境启动 MySQL 8.4,例如:

```sh
docker run --rm --name mysql-book-lab -e MYSQL_ROOT_PASSWORD=book-lab-only -p 127.0.0.1:23306:3306 mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364
```

等待初始化完成,再在仓库根目录运行:

```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements.txt
python3 -m unittest discover -s tests -v
python3 -m lab.demo --evidence lab/evidence/mysql.json
```

默认密码是公开的本地实验值,不用于真实部署。连接只允许环回地址;远程实验应先建立自己的安全端口转发。该套件需要创建和删除临时数据库的权限,适用于独立实验实例。不要把生产连接或凭证放进这里。

本地未安装 MySQL/Docker 时,可使用仓库 Actions 的真实 MySQL 服务运行实验。运行结果必须在实际成功后才记为验证;源码存在或服务健康检查本身都不是事务实验通过的证据。

提交响应丢失实验会在 MySQL 已返回 COMMIT OK 之后切断客户端连接,随后用独立连接查询业务标识。它使用仅限环回的明文故障注入代理,不支持 TLS,也不记录数据包内容或凭证。不能把该代理用于生产流量。

lab/init.py

"""Real MySQL 8.4 experiments; no in-memory substitute database."""

lab/commit_proxy.py

"""A loopback, plaintext MySQL packet relay that drops exactly a COMMIT reply.

This is a fault injector, not a SQL proxy or a production authentication layer.
The server's OK packet is observed before the client socket is closed. No packet
contents or credentials are logged; TLS connections are outside this lab.
"""
import socket
import socketserver
import threading


def packet(sock):
    def exact(size):
        result = bytearray()
        while len(result) < size:
            block = sock.recv(size - len(result))
            if not block:
                raise EOFError
            result.extend(block)
        return bytes(result)
    header = exact(4)
    length = int.from_bytes(header[:3], "little")
    if length > 1024 * 1024:
        raise ValueError("lab_packet_limit")
    return header, exact(length)


class CommitReplyDropper:
    def __init__(self, upstream_host, upstream_port):
        if upstream_host not in {"127.0.0.1", "localhost", "::1"}:
            raise ValueError("loopback_only")
        self.commit_ok_seen = threading.Event()
        owner = self

        class Server(socketserver.ThreadingTCPServer):
            allow_reuse_address = True
            daemon_threads = True

        class Handler(socketserver.BaseRequestHandler):
            def handle(self):
                upstream = socket.create_connection((upstream_host, upstream_port), timeout=10)
                downstream = self.request
                downstream.settimeout(10)
                drop_next_commit = threading.Event()

                def close_both():
                    for stream in (upstream, downstream):
                        try:
                            stream.shutdown(socket.SHUT_RDWR)
                        except OSError:
                            pass

                def server_to_client():
                    try:
                        while True:
                            header, payload = packet(upstream)
                            if drop_next_commit.is_set() and payload[:1] == b"\x00":
                                owner.commit_ok_seen.set()
                                close_both()
                                return
                            downstream.sendall(header + payload)
                    except (EOFError, OSError, ValueError):
                        close_both()

                thread = threading.Thread(target=server_to_client, daemon=True)
                thread.start()
                try:
                    while True:
                        header, payload = packet(downstream)
                        if payload[:1] == b"\x03" and payload[1:].strip().upper() == b"COMMIT":
                            drop_next_commit.set()
                        upstream.sendall(header + payload)
                except (EOFError, OSError, ValueError):
                    pass
                finally:
                    close_both()
                    upstream.close()
                    thread.join(2)

        self.server = Server(("127.0.0.1", 0), Handler)
        self.port = self.server.server_address[1]
        self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=.02), daemon=True)

    def __enter__(self):
        self.thread.start()
        return self

    def __exit__(self, *_):
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(2)

lab/db.py

"""Connections and uniquely owned disposable schemas for the book experiments."""
import os
import re
import uuid
import pymysql


def settings():
    host = os.environ.get("MYSQL_HOST", "127.0.0.1")
    if host not in {"127.0.0.1", "localhost", "::1"}:
        raise ValueError("Use the isolated local lab or a local port-forward, not an arbitrary server.")
    return dict(host=host, port=int(os.environ.get("MYSQL_PORT", "23306")),
                user=os.environ.get("MYSQL_USER", "root"),
                password=os.environ.get("MYSQL_PASSWORD", "book-lab-only"),
                charset="utf8mb4", connect_timeout=10, read_timeout=12, write_timeout=5)


def connect(schema=None, autocommit=False, isolation="REPEATABLE READ", port=None):
    config = settings()
    if port is not None:
        config["port"] = port
        # Only the loopback packet fault injector needs plaintext. PyMySQL
        # 1.2 negotiates TLS by default when the real server supports it.
        config["ssl_disabled"] = True
    connection = pymysql.connect(**config, database=schema, autocommit=autocommit)
    if isolation not in {"REPEATABLE READ", "READ COMMITTED"}:
        connection.close()
        raise ValueError("unsupported lab isolation")
    with connection.cursor() as cursor:
        cursor.execute("SET SESSION TRANSACTION ISOLATION LEVEL " + isolation)
        cursor.execute("SET SESSION innodb_lock_wait_timeout=3")
    return connection


def scalar(connection, sql, args=None):
    with connection.cursor() as cursor:
        cursor.execute(sql, args)
        return cursor.fetchone()[0]


class OwnedSchema:
    """Never drops a database supplied by a caller or environment variable."""
    def __init__(self):
        self.name = "book_lab_" + uuid.uuid4().hex[:16]
        self.created = False
        with connect(autocommit=True) as connection:
            with connection.cursor() as cursor:
                cursor.execute("CREATE DATABASE `" + self.name + "` CHARACTER SET utf8mb4")
        self.created = True

    def connection(self, **kwargs):
        return connect(self.name, **kwargs)

    def close(self):
        if self.created:
            assert re.fullmatch(r"book_lab_[0-9a-f]{16}", self.name)
            with connect(autocommit=True) as connection:
                with connection.cursor() as cursor:
                    cursor.execute("DROP DATABASE `" + self.name + "`")
            self.created = False

lab/demo.py

"""Run real scenarios once and save their observations, including failures."""
import argparse
from datetime import datetime, timezone
from decimal import Decimal
import importlib.metadata
import json
from pathlib import Path
import platform
import unittest

from .scenarios import InnoDBCases, EVIDENCE


def exact_json(value):
    if isinstance(value, Decimal):
        return int(value) if value == value.to_integral_value() else {"decimal": str(value)}
    raise TypeError("Unsupported evidence type: " + type(value).__name__)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--evidence", default="lab/evidence/mysql.json")
    arguments = parser.parse_args()
    EVIDENCE.clear()
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(InnoDBCases)
    result = unittest.TextTestRunner(verbosity=2).run(suite)
    report = dict(tested_at=datetime.now(timezone.utc).isoformat(), python=platform.python_version(),
                  driver_version=importlib.metadata.version("PyMySQL"), tests_run=result.testsRun,
                  passed=result.wasSuccessful(), observations=EVIDENCE,
                  failures=[dict(test=str(case), traceback=message) for case,message in result.failures+result.errors])
    target = Path(arguments.evidence)
    target.parent.mkdir(parents=True,exist_ok=True)
    target.write_text(json.dumps(report,ensure_ascii=False,indent=2,default=exact_json)+"\n")
    raise SystemExit(0 if result.wasSuccessful() else 1)


if __name__ == "__main__":
    main()

lab/evidence/container.json

["mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364"]

lab/evidence/mysql.json

{
  "tested_at": "2026-10-21T09:53:27.447278+00:00",
  "python": "3.12.15",
  "driver_version": "1.2.3",
  "tests_run": 15,
  "passed": true,
  "observations": [
    {
      "scenario": "test_01_server_contract",
      "version": "8.4.11",
      "isolation": "REPEATABLE-READ",
      "innodb_rollback_on_timeout": 0,
      "deadlock_detection": 1
    },
    {
      "scenario": "test_02_rollback_and_savepoint_are_transactional",
      "committed_after_savepoint": [
        80,
        100
      ],
      "balance_after_full_rollback": 80
    },
    {
      "scenario": "test_03_rr_snapshot_starts_at_first_consistent_read",
      "after_begin_before_first_read": 2,
      "same_snapshot": 2,
      "new_transaction": 3
    },
    {
      "scenario": "test_04_rc_uses_fresh_committed_snapshot",
      "first": 1,
      "second": 2
    },
    {
      "scenario": "test_05_rr_locking_read_can_see_a_newer_row",
      "consistent_before": 1,
      "locking_read": 2,
      "consistent_after": 1
    },
    {
      "scenario": "test_06_read_then_absolute_write_violates_inventory_invariant",
      "initial_stock": 1,
      "remaining": 0,
      "sold": 2,
      "invariant_satisfied": false
    },
    {
      "scenario": "test_07_conditional_update_preserves_inventory_invariant",
      "initial_stock": 1,
      "remaining": 0,
      "sold": 1,
      "invariant_satisfied": true
    },
    {
      "scenario": "test_08_unique_operation_key_prevents_duplicate_purchase",
      "outcomes": [
        "committed",
        "duplicate"
      ],
      "orders": 1,
      "remaining": 0
    },
    {
      "scenario": "test_09_deadlock_rolls_back_one_entire_transaction",
      "outcomes": [
        "committed",
        "deadlock_1213"
      ],
      "balances": [
        101,
        101
      ],
      "victim_final_action": "commit_without_explicit_rollback"
    },
    {
      "scenario": "test_10_lock_timeout_does_not_rollback_prior_statement_by_default",
      "error": 1205,
      "balances_after_intentional_commit": [
        100,
        105
      ],
      "lesson": "rollback the application transaction before retrying"
    },
    {
      "scenario": "test_11_index_changes_plan_without_claiming_a_speed_benchmark",
      "before": {
        "table_name": "people",
        "access_type": "ALL",
        "rows_examined_per_scan": 2000,
        "rows_produced_per_join": 200,
        "filtered": "10.00",
        "cost_info": {
          "read_cost": "180.25",
          "eval_cost": "20.00",
          "prefix_cost": "200.25",
          "data_read_per_join": "76K"
        },
        "used_columns": [
          "id",
          "email"
        ],
        "attached_condition": "(`book_lab_26d6d2152cbb4fb0`.`people`.`email` = 'user1000@example.invalid')"
      },
      "after": {
        "table_name": "people",
        "access_type": "const",
        "possible_keys": [
          "email_idx"
        ],
        "key": "email_idx",
        "used_key_parts": [
          "email"
        ],
        "key_length": "386",
        "ref": [
          "const"
        ],
        "rows_examined_per_scan": 1,
        "rows_produced_per_join": 1,
        "filtered": "100.00",
        "using_index": true,
        "cost_info": {
          "read_cost": "0.00",
          "eval_cost": "0.10",
          "prefix_cost": "0.00",
          "data_read_per_join": "392"
        },
        "used_columns": [
          "id",
          "email"
        ]
      },
      "note": "execution plan, not a latency benchmark"
    },
    {
      "scenario": "test_12_order_and_outbox_share_one_local_transaction",
      "committed_counts": [
        1,
        1
      ],
      "scope": "database local atomicity; no broker delivery claim"
    },
    {
      "scenario": "test_13_skip_locked_claims_distinct_rows_and_rollback_releases_claim",
      "first": 1,
      "concurrent_claim": 2,
      "after_rollback": 1
    },
    {
      "scenario": "test_14_commit_reply_lost_can_still_mean_committed",
      "client_error_code": 2013,
      "server_commit_ok_observed": true,
      "independently_found_order": 1,
      "lesson": "query stable business identity before replay"
    },
    {
      "scenario": "test_15_performance_schema_exposes_real_blocking_edge",
      "observed_lock_wait_edges": 1,
      "blocker_rollback_released_waiter": true
    }
  ],
  "failures": []
}

lab/scenarios.py

"""Real InnoDB experiments. Every assertion queries independent database state."""
import json
import threading
import time
import unittest
import pymysql

from .commit_proxy import CommitReplyDropper
from .db import OwnedSchema, scalar, settings

EVIDENCE = []


class InnoDBCases(unittest.TestCase):
    def setUp(self):
        self.schema = OwnedSchema()
        self.connections = []
        self.control = self.open(autocommit=True)
        with self.control.cursor() as cursor:
            cursor.execute("CREATE TABLE inventory(sku INT PRIMARY KEY,stock INT NOT NULL) ENGINE=InnoDB")
            cursor.execute("INSERT INTO inventory VALUES(1,1)")
            cursor.execute("CREATE TABLE orders(op_id VARBINARY(64) PRIMARY KEY,qty INT NOT NULL) ENGINE=InnoDB")
            cursor.execute("CREATE TABLE accounts(id INT PRIMARY KEY,balance INT NOT NULL) ENGINE=InnoDB")
            cursor.execute("INSERT INTO accounts VALUES(1,100),(2,100)")
            cursor.execute("CREATE TABLE outbox(id BIGINT AUTO_INCREMENT PRIMARY KEY,payload JSON NOT NULL,sent BOOLEAN NOT NULL DEFAULT FALSE) ENGINE=InnoDB")
        self.c = self.open()

    def tearDown(self):
        for connection in self.connections:
            try:
                connection.rollback()
                connection.close()
            except pymysql.MySQLError:
                pass
        self.schema.close()

    def open(self, **kwargs):
        connection = self.schema.connection(**kwargs)
        self.connections.append(connection)
        return connection

    def record(self, **data):
        EVIDENCE.append(dict(scenario=self._testMethodName, **data))

    def parallel(self, functions):
        errors = []
        def wrapper(function):
            try:
                function()
            except BaseException as error:
                errors.append(error)
        threads = [threading.Thread(target=wrapper, args=(f,), daemon=True) for f in functions]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join(10)
        self.assertFalse(any(t.is_alive() for t in threads), "experiment thread did not stop")
        if errors:
            raise errors[0]

    def test_01_server_contract(self):
        version = scalar(self.c, "SELECT VERSION()")
        isolation = scalar(self.c, "SELECT @@transaction_isolation")
        rollback_timeout = scalar(self.c, "SELECT @@innodb_rollback_on_timeout")
        deadlock = scalar(self.c, "SELECT @@innodb_deadlock_detect")
        self.assertTrue(version.startswith("8.4."), version)
        self.assertEqual(isolation, "REPEATABLE-READ")
        self.assertEqual(rollback_timeout, 0)
        self.assertEqual(deadlock, 1)
        self.record(version=version, isolation=isolation,
                    innodb_rollback_on_timeout=rollback_timeout, deadlock_detection=deadlock)

    def test_02_rollback_and_savepoint_are_transactional(self):
        with self.c.cursor() as cursor:
            cursor.execute("UPDATE accounts SET balance=80 WHERE id=1")
            cursor.execute("SAVEPOINT before_second")
            cursor.execute("UPDATE accounts SET balance=120 WHERE id=2")
            cursor.execute("ROLLBACK TO SAVEPOINT before_second")
        self.c.commit()
        self.assertEqual(scalar(self.control, "SELECT balance FROM accounts WHERE id=1"), 80)
        self.assertEqual(scalar(self.control, "SELECT balance FROM accounts WHERE id=2"), 100)
        self.c.begin()
        with self.c.cursor() as cursor:
            cursor.execute("UPDATE accounts SET balance=1 WHERE id=1")
        self.c.rollback()
        self.assertEqual(scalar(self.control, "SELECT balance FROM accounts WHERE id=1"), 80)
        self.record(committed_after_savepoint=[80, 100], balance_after_full_rollback=80)

    def test_03_rr_snapshot_starts_at_first_consistent_read(self):
        self.c.begin()
        other = self.open()
        with other.cursor() as cursor:
            cursor.execute("UPDATE inventory SET stock=2 WHERE sku=1")
        other.commit()
        first = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1")
        with other.cursor() as cursor:
            cursor.execute("UPDATE inventory SET stock=3 WHERE sku=1")
        other.commit()
        second = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1")
        self.c.commit()
        fresh = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1")
        self.assertEqual((first, second, fresh), (2, 2, 3))
        self.record(after_begin_before_first_read=first, same_snapshot=second, new_transaction=fresh)

    def test_04_rc_uses_fresh_committed_snapshot(self):
        reader = self.open(isolation="READ COMMITTED")
        first = scalar(reader, "SELECT stock FROM inventory WHERE sku=1")
        with self.control.cursor() as cursor:
            cursor.execute("UPDATE inventory SET stock=2 WHERE sku=1")
        second = scalar(reader, "SELECT stock FROM inventory WHERE sku=1")
        self.assertEqual((first, second), (1, 2))
        self.record(first=first, second=second)

    def test_05_rr_locking_read_can_see_a_newer_row(self):
        before = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1")
        with self.control.cursor() as cursor:
            cursor.execute("UPDATE inventory SET stock=2 WHERE sku=1")
        locked = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1 FOR UPDATE")
        consistent = scalar(self.c, "SELECT stock FROM inventory WHERE sku=1")
        self.assertEqual((before, locked, consistent), (1, 2, 1))
        self.c.rollback()
        self.record(consistent_before=before, locking_read=locked, consistent_after=consistent)

    def test_06_read_then_absolute_write_violates_inventory_invariant(self):
        ready = threading.Barrier(2, timeout=5)
        def buyer(key):
            connection = self.open()
            amount = scalar(connection, "SELECT stock FROM inventory WHERE sku=1")
            ready.wait()
            if amount > 0:
                with connection.cursor() as cursor:
                    cursor.execute("UPDATE inventory SET stock=%s WHERE sku=1", (amount-1,))
                    cursor.execute("INSERT INTO orders VALUES(%s,1)", (key,))
                connection.commit()
        self.parallel([lambda: buyer(b"a"), lambda: buyer(b"b")])
        stock = scalar(self.control, "SELECT stock FROM inventory WHERE sku=1")
        sales = scalar(self.control, "SELECT SUM(qty) FROM orders")
        self.assertEqual((stock, sales), (0, 2))
        self.record(initial_stock=1, remaining=stock, sold=sales, invariant_satisfied=False)

    def test_07_conditional_update_preserves_inventory_invariant(self):
        ready = threading.Barrier(2, timeout=5)
        def buyer(key):
            connection = self.open()
            ready.wait()
            with connection.cursor() as cursor:
                changed = cursor.execute("UPDATE inventory SET stock=stock-1 WHERE sku=1 AND stock>0")
                if changed:
                    cursor.execute("INSERT INTO orders VALUES(%s,1)", (key,))
            connection.commit()
        self.parallel([lambda: buyer(b"a"), lambda: buyer(b"b")])
        stock = scalar(self.control, "SELECT stock FROM inventory WHERE sku=1")
        sales = scalar(self.control, "SELECT SUM(qty) FROM orders")
        self.assertEqual((stock, sales), (0, 1))
        self.record(initial_stock=1, remaining=stock, sold=sales, invariant_satisfied=True)

    def test_08_unique_operation_key_prevents_duplicate_purchase(self):
        ready = threading.Barrier(2, timeout=5)
        outcomes = []
        def buyer():
            connection = self.open()
            ready.wait()
            try:
                with connection.cursor() as cursor:
                    cursor.execute("INSERT INTO orders VALUES(%s,1)", (b"same-operation",))
                    changed = cursor.execute("UPDATE inventory SET stock=stock-1 WHERE sku=1 AND stock>0")
                    if changed != 1:
                        raise AssertionError("inventory rejected")
                connection.commit()
                outcomes.append("committed")
            except pymysql.err.IntegrityError as error:
                self.assertEqual(error.args[0], 1062)
                connection.rollback()
                outcomes.append("duplicate")
        self.parallel([buyer, buyer])
        self.assertCountEqual(outcomes, ["committed", "duplicate"])
        self.assertEqual(scalar(self.control, "SELECT COUNT(*) FROM orders"), 1)
        self.assertEqual(scalar(self.control, "SELECT stock FROM inventory WHERE sku=1"), 0)
        self.record(outcomes=sorted(outcomes), orders=1, remaining=0)

    def test_09_deadlock_rolls_back_one_entire_transaction(self):
        ready = threading.Barrier(2, timeout=5)
        outcomes = []
        def actor(first, second):
            connection = self.open()
            try:
                with connection.cursor() as cursor:
                    cursor.execute("UPDATE accounts SET balance=balance+1 WHERE id=%s", (first,))
                    ready.wait()
                    cursor.execute("UPDATE accounts SET balance=balance+1 WHERE id=%s", (second,))
                connection.commit()
                outcomes.append("committed")
            except pymysql.err.OperationalError as error:
                self.assertEqual(error.args[0], 1213)
                # Deliberately COMMIT without an explicit ROLLBACK: if the engine
                # had retained the first update, it would become visible below.
                # This is an isolated probe of automatic rollback, not recovery advice.
                connection.commit()
                outcomes.append("deadlock_1213")
        self.parallel([lambda: actor(1,2), lambda: actor(2,1)])
        balances = [scalar(self.control, "SELECT balance FROM accounts WHERE id=%s", (i,)) for i in (1,2)]
        self.assertCountEqual(outcomes, ["committed", "deadlock_1213"])
        self.assertEqual(balances, [101,101])
        self.record(outcomes=sorted(outcomes), balances=balances,
                    victim_final_action="commit_without_explicit_rollback")

    def test_10_lock_timeout_does_not_rollback_prior_statement_by_default(self):
        holder = self.open()
        with holder.cursor() as cursor:
            cursor.execute("UPDATE accounts SET balance=balance+1 WHERE id=1")
        with self.c.cursor() as cursor:
            cursor.execute("SET SESSION innodb_lock_wait_timeout=1")
            cursor.execute("UPDATE accounts SET balance=balance+5 WHERE id=2")
            with self.assertRaises(pymysql.err.OperationalError) as error:
                cursor.execute("UPDATE accounts SET balance=balance+5 WHERE id=1")
        self.assertEqual(error.exception.args[0], 1205)
        self.c.commit()  # Intentionally shows the partial-commit pitfall in an isolated lab.
        holder.rollback()
        balances = [scalar(self.control, "SELECT balance FROM accounts WHERE id=%s", (i,)) for i in (1,2)]
        self.assertEqual(balances, [100,105])
        self.record(error=1205, balances_after_intentional_commit=balances,
                    lesson="rollback the application transaction before retrying")

    def test_11_index_changes_plan_without_claiming_a_speed_benchmark(self):
        with self.control.cursor() as cursor:
            cursor.execute("CREATE TABLE people(id INT PRIMARY KEY,email VARCHAR(96) NOT NULL) ENGINE=InnoDB")
            cursor.executemany("INSERT INTO people VALUES(%s,%s)", [(i, f"user{i}@example.invalid") for i in range(2000)])
        sql = "EXPLAIN FORMAT=JSON SELECT id FROM people WHERE email='user1000@example.invalid'"
        before = json.loads(scalar(self.control, sql))
        with self.control.cursor() as cursor:
            cursor.execute("ALTER TABLE people ADD UNIQUE KEY email_idx(email)")
        after = json.loads(scalar(self.control, sql))
        def table(node):
            if isinstance(node, dict):
                if node.get("table_name") == "people":
                    return node
                for value in node.values():
                    found = table(value)
                    if found:
                        return found
            elif isinstance(node, list):
                for value in node:
                    found = table(value)
                    if found:
                        return found
        old, new = table(before), table(after)
        self.assertEqual(old["access_type"], "ALL")
        self.assertEqual(new["key"], "email_idx")
        self.assertIn(new["access_type"], {"const", "ref"})
        self.record(before=old, after=new, note="execution plan, not a latency benchmark")

    def test_12_order_and_outbox_share_one_local_transaction(self):
        def create(key, commit):
            with self.c.cursor() as cursor:
                cursor.execute("INSERT INTO orders VALUES(%s,1)", (key,))
                cursor.execute("INSERT INTO outbox(payload) VALUES(%s)", (json.dumps({"operation":key.decode()}),))
            self.c.commit() if commit else self.c.rollback()
        create(b"kept", True)
        create(b"rolled-back", False)
        counts = [scalar(self.control, "SELECT COUNT(*) FROM " + name) for name in ("orders","outbox")]
        self.assertEqual(counts, [1,1])
        self.record(committed_counts=counts, scope="database local atomicity; no broker delivery claim")

    def test_13_skip_locked_claims_distinct_rows_and_rollback_releases_claim(self):
        with self.control.cursor() as cursor:
            cursor.executemany("INSERT INTO outbox(payload) VALUES(%s)", [("{}",),("{}",)])
        first = scalar(self.c, "SELECT id FROM outbox WHERE sent=FALSE ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")
        other = self.open()
        second = scalar(other, "SELECT id FROM outbox WHERE sent=FALSE ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")
        self.assertEqual((first, second), (1,2))
        self.c.rollback()
        third = self.open()
        recovered = scalar(third, "SELECT id FROM outbox WHERE sent=FALSE ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")
        self.assertEqual(recovered, 1)
        other.rollback()
        third.rollback()
        self.record(first=first, concurrent_claim=second, after_rollback=recovered)

    def test_14_commit_reply_lost_can_still_mean_committed(self):
        config = settings()
        with CommitReplyDropper(config["host"], config["port"]) as proxy:
            writer = self.open(port=proxy.port)
            with writer.cursor() as cursor:
                cursor.execute("INSERT INTO orders VALUES(%s,1)", (b"commit-unknown",))
            with self.assertRaises(pymysql.err.OperationalError) as error:
                writer.commit()
            self.assertTrue(proxy.commit_ok_seen.wait(2), "fault must happen after server COMMIT OK")
        actual = scalar(self.control, "SELECT COUNT(*) FROM orders WHERE op_id=%s", (b"commit-unknown",))
        self.assertEqual(actual, 1)
        self.record(client_error_code=error.exception.args[0], server_commit_ok_observed=True,
                    independently_found_order=actual, lesson="query stable business identity before replay")

    def test_15_performance_schema_exposes_real_blocking_edge(self):
        holder = self.open()
        with holder.cursor() as cursor:
            cursor.execute("UPDATE accounts SET balance=balance+1 WHERE id=1")
        begun, stopped = threading.Event(), threading.Event()
        failures = []
        def waiting_writer():
            connection = self.open()
            try:
                begun.set()
                with connection.cursor() as cursor:
                    cursor.execute("UPDATE accounts SET balance=balance+1 WHERE id=1")
                connection.commit()
            except BaseException as error:
                failures.append(error)
            finally:
                stopped.set()
        thread = threading.Thread(target=waiting_writer, daemon=True)
        thread.start()
        observed = 0
        try:
            self.assertTrue(begun.wait(2))
            deadline = time.monotonic()+2
            while time.monotonic()<deadline and not observed:
                observed = scalar(self.control, """
                    SELECT COUNT(*) FROM performance_schema.data_lock_waits w
                    JOIN performance_schema.data_locks r
                      ON r.ENGINE=w.ENGINE AND r.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID
                    WHERE r.OBJECT_SCHEMA=%s AND r.OBJECT_NAME='accounts'
                    """, (self.schema.name,))
                if not observed:
                    time.sleep(.01)
            self.assertGreaterEqual(observed, 1, "must observe an actual database lock wait")
        finally:
            holder.rollback()
            thread.join(8)
        self.assertTrue(stopped.is_set())
        if failures:
            raise failures[0]
        self.assertEqual(scalar(self.control, "SELECT balance FROM accounts WHERE id=1"), 101)
        self.record(observed_lock_wait_edges=observed, blocker_rollback_released_waiter=True)

tests/test_mysql.py

from lab.scenarios import InnoDBCases