12.2 InnoDB Read View、Undo 与加锁读
元数据卡
- 前置:12.1 PostgreSQL MVCC、快照与隔离级别
- 关键词:InnoDB、Read View、Undo、Consistent Read、Next-Key Lock、隔离选型
- 代码语言:SQL(MySQL 8.4)
同样叫 Repeatable Read,PostgreSQL 与 InnoDB 的具体行为并不相同。应用迁移时,不能只搬隔离级别名称,还要区分普通快照读、当前读和加锁范围。
InnoDB 如何重建旧版本
InnoDB 在聚簇索引记录中维护事务标识和指向 undo 记录的回滚指针等隐藏字段。修改产生 undo 信息;一致性读需要旧版本时,会沿这些信息重建较早的行内容。
聚簇记录中的当前版本
DB_TRX_ID = 120
DB_ROLL_PTR ──> undo(旧值, 前一指针) ──> 更旧的 undo ...undo 同时服务于事务回滚与一致性读。只要某个活跃 Read View 仍可能需要旧版本,相应 update undo 就不能被 purge。长事务因此会推高 history list、拖慢清理并扩大存储占用。
Consistent Read 与 Read View
普通 SELECT 在 Read Committed 和 Repeatable Read 下通常是非加锁一致性读。Read View 记录创建时活跃事务的边界与集合,读取器据此判断当前版本是否可见;不可见时沿 undo 重建更早版本。
- Read Committed:每次一致性读建立新的 Read View;
- Repeatable Read:同一事务的普通一致性读复用第一条一致性读建立的快照;
- 本事务之前语句的修改对自己可见。
在 Repeatable Read 中,START TRANSACTION 后、第一次普通一致性读前,并发提交仍可能被首次快照包含。若业务要求立即确定快照,可在适用场景使用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
普通读与加锁读不是同一个时间点
SELECT balance
FROM account
WHERE account_id = 1;上面通常读取 Read View 中可见的历史版本,不因另一事务持有该行写锁就等待。
SELECT balance
FROM account
WHERE account_id = 1
FOR UPDATE;加锁读要锁定当前索引记录,必要时等待并读取较新的版本;旧版本无法被锁住。因此,同一 Repeatable Read 事务把普通快照读和加锁读混用时,可能观察到不同时间点的数据。设计读—判定—写流程时,应明确哪些读取只是报告,哪些读取必须保护后续修改。
FOR SHARE 获取共享锁,FOR UPDATE 获取更强的修改意图;NOWAIT 让冲突立即失败,SKIP LOCKED 跳过被锁行,后者适合多消费者任务队列,不适合需要完整一致结果集的通用查询。
Record、Gap 与 Next-Key Lock
InnoDB 锁定的是扫描遇到的索引记录和索引区间,而不是抽象的“SQL 行条件”。
- record lock:锁某个索引记录;
- gap lock:锁两个索引键之间的间隙,限制插入;
- next-key lock:record lock 与其前方 gap 的组合;
- insert intention:多个准备插入同一间隙不同位置的事务可表达各自意图。
在 Repeatable Read 下,加锁读和修改语句为了防止范围内出现幻行,可能使用 gap 或 next-key lock。若通过唯一索引的完整唯一条件定位单条记录,通常只需 record lock;缺索引或范围过宽会扩大扫描与锁范围。
Read Committed 通常关闭用于搜索和索引扫描的 gap locking,仅为外键检查、重复键检查等保留,并对不匹配行更早释放记录锁。它可能提升并发,也会改变应用依赖的范围保护。
MySQL 隔离级别不能照抄 PostgreSQL 表
InnoDB 默认隔离级别是 Repeatable Read,而 PostgreSQL 默认是 Read Committed。InnoDB 的 Read Uncommitted 允许 dirty read;这与 PostgreSQL 把该名称映射到 Read Committed 不同。
Serializable 在 InnoDB 中加强普通读取的锁行为,具体表现还受 autocommit 和语句形态影响。它不等于 PostgreSQL 的 SSI,也不应写成“Repeatable Read 的另一个名字”。
因此选型应从业务操作出发:
| 操作 | 首选起点 | 仍需确认 |
|---|---|---|
| 独立点查、原子增量 | Read Committed 或产品默认级别 | SQL 是否把判断放进原子更新 |
| 一致报表 | Repeatable Read 只读事务 | 快照建立时点、长事务清理成本 |
| 读后修改固定行 | SELECT ... FOR UPDATE 或条件更新 | 锁顺序、超时和死锁重试 |
| 维护范围不变量 | Serializable、范围锁或重新建模 | 索引是否覆盖谓词、重试率 |
| 工作队列领取 | FOR UPDATE SKIP LOCKED | 跳过锁导致结果并非一致快照 |
一个不会静默覆盖的应用乐观锁
给记录增加版本号:
ALTER TABLE account
ADD COLUMN version bigint NOT NULL DEFAULT 0;
UPDATE account
SET balance = 400.00,
version = version + 1
WHERE account_id = 1
AND version = 7;应用必须断言影响行数为 1。影响 0 行表示版本已变化或目标不存在,应重新读取并决定重试、合并还是返回冲突;不能把它当作成功。
诊断顺序
遇到 InnoDB 阻塞时,按下面顺序提问:
- 这是普通一致性读、加锁读,还是写语句?
- 执行计划选择了哪个索引,扫描了多大范围?
- 当前隔离级别是否启用了范围保护?
- 阻塞的是 record、gap、next-key,还是元数据锁?
- 事务从何时开始,是否在等待应用或网络?
只看到 SQL 文本而不看索引访问路径,往往无法解释“明明只更新一行,为什么挡住了另一个插入”。
参考
- MySQL 8.4:Consistent Nonlocking Reads
- MySQL 8.4:Locking Reads
- MySQL 8.4:Transaction Isolation Levels
- MySQL:InnoDB Multi-Versioning
下一章把 MVCC 背后的写入锁、两阶段锁、死锁检测和乐观验证放在一张图里比较。