架构设计

DDIA 第七章:事务|MySQL/InnoDB 实践笔记|2026-09-16

以邮件计数、账户转账、库存扣减、医生值班和会议室预约为案例,系统理解 ACID、弱隔离级别、MVCC、脏读与脏写、读偏差、丢失更新、写偏差、幻读,以及 MySQL/InnoDB 中的一致性读、当前读、next-key lock、2PL、SSI 和 2PC 的工程取舍。

🔐 本章核心:事务是一层编程抽象,用原子性处理部分失败、用隔离性约束并发;工程上必须从业务不变量出发,识别并发异常,再选择原子更新、锁、乐观并发控制或可串行化隔离。 学习日期:2026-09-16 学习方式:概念讲解 + MySQL/InnoDB 双会话推演 + 情境问答 + 综合设计 学习结果:最终测验通过,能够区分读偏差、丢失更新和写偏差,并能设计公共资源锁方案。

一、全章主线

事务的目标不是让故障和并发消失,而是提供一组安全保证,让应用可以用更简单的模型处理它们。

本章可以用四个问题串起来:

  1. 失败时怎么办:一组写入只完成一部分时,用原子性整体撤销。
  2. 并发时看见什么:隔离级别决定事务可以观察到哪些并发结果。
  3. 业务规则如何守住:应用先定义不变量,再选择约束、原子操作、锁或可串行化隔离。
  4. 强隔离如何实现:真正串行执行、两阶段锁 2PL、可串行化快照隔离 SSI 各有不同成本。

二、ACID

属性 核心问题 准确理解
Atomicity 原子性 做到一半失败怎么办 全部提交或全部撤销;主要处理故障,不是并发
Consistency 一致性 数据是否符合业务规则 业务不变量主要由应用定义;数据库通过事务和约束辅助维护
Isolation 隔离性 并发事务如何互不干扰 理想结果等价于某种串行顺序;实际数据库常提供更弱的级别
Durability 持久性 提交后故障怎么办 已提交数据不应遗忘,通常依赖日志、磁盘、副本和备份

关键辨析:

  • 原子性解决“执行一半失败”;隔离性解决“同时执行互相干扰”。
  • ACID 中的 Consistency 不是数据库自动理解业务。比如“两个账户转账前后总额不变”是应用定义的不变量。
  • 邮件写入和未读计数分别自动提交时,第一条成功、第二条失败会留下部分结果;放在一个事务里则一起提交或撤销。

三、错误处理与重试

  • 只对死锁、序列化失败、临时网络中断等瞬时错误进行有限重试。
  • 使用指数退避和抖动,避免系统过载时形成重试风暴。
  • 约束冲突或业务条件不满足通常是永久错误,不应盲目重试。
  • 必须重试整个事务,不能只重试最后一条 SQL。
  • 支付、发消息等数据库外副作用不会随数据库事务自动回滚,应使用幂等键、事务外盒或其他协调机制。
  • 客户端发出 COMMIT 后连接断开时,结果可能不确定;关键操作需要可查询结果或幂等重放。

四、弱隔离级别与并发异常

异常 判断线索 常用防护
脏读 读到后来回滚的未提交值 `READ COMMITTED` 或更强
脏写 覆盖其他事务尚未提交的写入 数据库写锁;常见事务实现都会防止
不可重复读 / 读偏差 同一事务读取了不同时间点的数据 快照隔离;InnoDB `REPEATABLE READ` 的普通一致性读
丢失更新 多方读旧值、计算、写同一对象,一次效果被覆盖 原子条件更新、`FOR UPDATE`、版本号乐观锁
写偏差 多方读取同一条件,修改不同对象,组合后破坏规则 锁定公共对象或查询范围,或使用 `SERIALIZABLE`
幻读 读取后出现新的匹配记录,影响先前判断 next-key/gap lock、唯一约束、公共资源锁或 `SERIALIZABLE`

速记:

  • 读偏差:读到了拼接的世界。
  • 丢失更新:写同一格,其中一次更新效果被覆盖。
  • 写偏差:写不同格,合起来破坏业务规则。

五、MySQL/InnoDB 的读取模型

操作或级别 行为
`READ COMMITTED` 普通 `SELECT` 每条查询创建新的已提交快照,因此同一事务两次查询可能得到不同结果
`REPEATABLE READ` 普通 `SELECT` 使用第一次一致性读建立的事务快照;InnoDB 默认级别
`SELECT ... FOR UPDATE` 当前读,读取较新的可用版本并加排他锁;必要时等待其他写事务
`UPDATE` / `DELETE` 当前读并对相关索引记录加锁
范围锁定读 在合适索引上可使用 next-key lock 锁住索引记录和间隙,防止幻影插入

重要提醒:

  • 普通 SELECT 是一致性读,通常不会因为另一事务持有行锁而等待。
  • 在同一个 REPEATABLE READ 事务里混用普通快照读和锁定当前读,可能看到不同的数据状态。
  • 快照稳定不等于完整可串行化,也不会自动阻止所有丢失更新和写偏差。

六、丢失更新与安全库存扣减

危险模式:

普通 SELECT → 应用计算新值 → UPDATE 写入绝对值

例如两个事务都读取计数器 42,随后都写入 43。本来两次加一应得到 44,其中一次更新却被覆盖。

优先使用数据库内的原子条件更新:

UPDATE products
SET stock = stock - 1
WHERE id = ? AND stock > 0;

只有受影响行数为 1 时,才在同一事务中创建订单。受影响行数为 0 表示库存不足。

如果逻辑无法写成单条 SQL,可以先执行 SELECT ... FOR UPDATE;冲突较少且适合重试时,可以使用带版本号条件的乐观锁,并检查受影响行数。

七、写偏差:医生值班案例

业务不变量:每个班次至少保留一名值班医生。

并发异常:Alice 和 Bob 都从快照中看到两人值班,随后分别修改不同医生为休息。两次写入没有覆盖同一行,却组合成零人值班,因此属于写偏差。

只锁自己修改的医生无效,因为两个事务仍然不会竞争同一把锁。工程上应让所有竞争者锁定同一个公共资源:

START TRANSACTION;

SELECT id
FROM shifts
WHERE id = ?
FOR UPDATE;

SELECT COUNT(*)
FROM doctors
WHERE shift_id = ? AND on_call = TRUE;

-- 人数 <= 1:业务拒绝并回滚
-- 人数 > 1:允许更新
UPDATE doctors
SET on_call = FALSE
WHERE id = ? AND shift_id = ? AND on_call = TRUE;

COMMIT;

所有修改该班次值班状态的代码路径都必须先锁定同一条 shifts 记录。第二个事务获得锁后必须重新检查人数,不能沿用等待前的判断。

八、幻读与 next-key lock

会议室预约中,两个事务都查询“目标时间段没有预约”,随后各自插入一条重叠预约。查询时冲突行尚不存在,因此只锁已有记录无法保护“不存在”这一事实。

InnoDB 的锁定范围查询可以通过 next-key lock 锁定索引记录及间隙,阻止其他事务在受保护范围内插入幻影。需要注意:

  • 必须有合适索引,否则锁范围可能很大。
  • 普通快照 SELECT 不会因为读取过一个范围就阻止并发插入。
  • 时间区间重叠不一定能被简单索引精确表达;可以改为锁房间公共记录,或将时间拆成离散时段并使用唯一约束。

九、三种可串行化实现

实现 方法 主要代价
真正串行执行 事务逐个执行,从根源上取消并发冲突 慢事务和单分区吞吐限制;跨分区协调昂贵
2PL 两阶段锁 读取取得共享锁,写入取得排他锁,并持有到事务结束 阻塞、死锁、锁等待和不稳定的尾延迟
SSI 可串行化快照隔离 先并发执行,监控读写依赖,发现危险结构时回滚事务 检测开销、序列化失败和整个事务重试
  • MySQL/InnoDB 的 SERIALIZABLE 主要采用锁定路线。
  • PostgreSQL 的 SERIALIZABLE 使用 SSI;普通读取通常不阻塞写入,但事务可能在提交时因序列化失败而回滚。
  • 2PL 是悲观并发控制:先阻塞;SSI 是乐观并发控制:先运行,发现不能串行化时再回滚。

十、两阶段提交 2PC

2PC 与 2PL 名称相似,但解决的问题不同:

  • 2PL:并发控制与隔离性,保证执行结果可串行化。
  • 2PC:多个参与者的原子提交,保证大家一起提交或一起回滚。

订单库与库存库参与同一全局事务时:

  1. 应用分别写入订单和扣减库存,但暂不提交。
  2. 协调者向所有参与者发送 PREPARE
  3. 参与者持久化事务数据和 prepare 状态、保留锁,并回答 YESNO
  4. 全部回答 YES 时,协调者持久化 COMMIT 决定并通知所有参与者;任何一方回答 NO 时,通知全部回滚。
  5. 参与者回答 YES 后不能自行回滚;协调者持久化最终决定后也不能反悔。

如果协调者在部分参与者收到提交命令后崩溃,其他已 prepared 的参与者必须等待协调者恢复。2PC 因此是阻塞式原子提交协议:它宁可暂时不可用,也不能让参与者获得相反的最终决定。

十一、学习问答与纠偏

  • 正确识别:多语句自动提交在第二条失败后会留下第一条结果;单事务可以通过原子性整体撤销。
  • 正确识别:READ COMMITTED 下第二次读到其他事务已提交的新值不是脏读,而是不可重复读或读偏差。
  • 正确判断:InnoDB REPEATABLE READ 中,普通 SELECT 读取快照,SELECT ... FOR UPDATE 使用当前读。
  • 正确提出:库存超卖可以用原子条件更新、FOR UPDATE 或版本号乐观锁解决。
  • 曾将“两个事务都读 42 并写 43”误判为读偏差;修正为丢失更新。关键区别是更新效果是否被覆盖。
  • 正确识别医生值班为写偏差;进一步补全为“所有代码路径锁同一公共班次记录,并在获得锁后重新检查”。
  • 正确理解 SSI:两个医生事务不能都成功提交,但读取阶段通常不必互相阻塞。
  • 正确理解 2PC 的不可反悔点:协调者持久化 COMMIT 后,即使只通知了一个参与者,也不能回滚已提交方,只能恢复后继续通知另一方提交。

十二、工程决策顺序

  1. 单行计算可以放进 SQL:优先原子条件更新并检查受影响行数。
  2. 复杂判断依赖多行状态:锁定所有竞争者共享的业务对象,并在锁内重新判断。
  3. 冲突较少且适合重试:使用版本号乐观锁。
  4. 规则复杂、难以枚举锁:考虑 SERIALIZABLE,并实现整个事务的安全重试。
  5. 可以用唯一约束表达的不变量,优先让数据库强制执行。
  6. 多资源原子提交确实必须由事务协调器保障时,再评估 2PC 的阻塞、锁持有时间和运维成本。

十三、复习清单

  • 能准确解释 ACID,并区分数据库保证与应用不变量
  • 能区分脏读、脏写、读偏差、丢失更新、写偏差与幻读
  • 能解释 InnoDB 一致性读和当前读
  • 能为库存扣减选择原子条件更新或显式锁
  • 能为跨行不变量设计公共资源锁
  • 能比较真正串行执行、2PL 与 SSI
  • 能区分 2PL 与 2PC
  • 能解释 2PC 的 prepare 承诺、最终决定和协调者故障阻塞

十四、一句话复盘

事务设计的核心不是把隔离级别调得越高越好,而是先明确业务不变量和可能的并发时间线,再用成本最低且能够真正制造冲突或检测冲突的机制保护它。

相关学习