🔐 本章核心:事务是一层编程抽象,用原子性处理部分失败、用隔离性约束并发;工程上必须从业务不变量出发,识别并发异常,再选择原子更新、锁、乐观并发控制或可串行化隔离。 学习日期:2026-09-16 学习方式:概念讲解 + MySQL/InnoDB 双会话推演 + 情境问答 + 综合设计 学习结果:最终测验通过,能够区分读偏差、丢失更新和写偏差,并能设计公共资源锁方案。
一、全章主线
事务的目标不是让故障和并发消失,而是提供一组安全保证,让应用可以用更简单的模型处理它们。
本章可以用四个问题串起来:
- 失败时怎么办:一组写入只完成一部分时,用原子性整体撤销。
- 并发时看见什么:隔离级别决定事务可以观察到哪些并发结果。
- 业务规则如何守住:应用先定义不变量,再选择约束、原子操作、锁或可串行化隔离。
- 强隔离如何实现:真正串行执行、两阶段锁 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:多个参与者的原子提交,保证大家一起提交或一起回滚。
订单库与库存库参与同一全局事务时:
- 应用分别写入订单和扣减库存,但暂不提交。
- 协调者向所有参与者发送
PREPARE。 - 参与者持久化事务数据和 prepare 状态、保留锁,并回答
YES或NO。 - 全部回答
YES时,协调者持久化COMMIT决定并通知所有参与者;任何一方回答NO时,通知全部回滚。 - 参与者回答
YES后不能自行回滚;协调者持久化最终决定后也不能反悔。
如果协调者在部分参与者收到提交命令后崩溃,其他已 prepared 的参与者必须等待协调者恢复。2PC 因此是阻塞式原子提交协议:它宁可暂时不可用,也不能让参与者获得相反的最终决定。
十一、学习问答与纠偏
- 正确识别:多语句自动提交在第二条失败后会留下第一条结果;单事务可以通过原子性整体撤销。
- 正确识别:
READ COMMITTED下第二次读到其他事务已提交的新值不是脏读,而是不可重复读或读偏差。 - 正确判断:InnoDB
REPEATABLE READ中,普通SELECT读取快照,SELECT ... FOR UPDATE使用当前读。 - 正确提出:库存超卖可以用原子条件更新、
FOR UPDATE或版本号乐观锁解决。 - 曾将“两个事务都读 42 并写 43”误判为读偏差;修正为丢失更新。关键区别是更新效果是否被覆盖。
- 正确识别医生值班为写偏差;进一步补全为“所有代码路径锁同一公共班次记录,并在获得锁后重新检查”。
- 正确理解 SSI:两个医生事务不能都成功提交,但读取阶段通常不必互相阻塞。
- 正确理解 2PC 的不可反悔点:协调者持久化
COMMIT后,即使只通知了一个参与者,也不能回滚已提交方,只能恢复后继续通知另一方提交。
十二、工程决策顺序
- 单行计算可以放进 SQL:优先原子条件更新并检查受影响行数。
- 复杂判断依赖多行状态:锁定所有竞争者共享的业务对象,并在锁内重新判断。
- 冲突较少且适合重试:使用版本号乐观锁。
- 规则复杂、难以枚举锁:考虑
SERIALIZABLE,并实现整个事务的安全重试。 - 可以用唯一约束表达的不变量,优先让数据库强制执行。
- 多资源原子提交确实必须由事务协调器保障时,再评估 2PC 的阻塞、锁持有时间和运维成本。
十三、复习清单
- 能准确解释 ACID,并区分数据库保证与应用不变量
- 能区分脏读、脏写、读偏差、丢失更新、写偏差与幻读
- 能解释 InnoDB 一致性读和当前读
- 能为库存扣减选择原子条件更新或显式锁
- 能为跨行不变量设计公共资源锁
- 能比较真正串行执行、2PL 与 SSI
- 能区分 2PL 与 2PC
- 能解释 2PC 的 prepare 承诺、最终决定和协调者故障阻塞
十四、一句话复盘
事务设计的核心不是把隔离级别调得越高越好,而是先明确业务不变量和可能的并发时间线,再用成本最低且能够真正制造冲突或检测冲突的机制保护它。