MYSQL 8.4 · 事务存储引擎专题

MySQL MVCC 与事务隔离:版本链怎样决定你看见什么

MVCC(Multi-Version Concurrency Control,多版本并发控制)是让一行数据保留可追溯的历史版本,使普通读取通常不用阻塞写入的机制;事务隔离则规定并发事务彼此能看见哪些修改。InnoDB 是 MySQL 默认的事务存储引擎,本文围绕它解释两个事务为何可能看见不同结果。

适用:MySQL 8.4 / InnoDB前置:事务与普通 SELECT重点:版本链 · Read View · 隔离级别
一、先看问题:读请求为什么不应该总等写请求

事务是一组要么全部成功、要么全部撤销的数据库操作。假设小林正在把订单金额从 100 改成 120,但还没提交;小周此时只想查询订单。最简单的方案是让小周一直等锁,结果读多写少的系统也会产生大量排队。

只有一份数据

写入过程中要么让读者看见未完成结果,要么让读者等待。前者可能读到脏数据,后者牺牲并发。

保留历史版本

写者产生新版本,读者按自己的观察时点选择旧版本。读写能同时推进,但系统要维护历史记录并判断可见性。

直觉:像协作文档的版本历史。别人正在编辑最新版时,你仍可按某个时间点查看已经完成的版本;MVCC 就是这层“按时间点选版本”的能力。
二、隔离解决什么:三种读异常先用结果区分

隔离级别是数据库允许并发事务互相看见到什么程度的设置。它在一致性与并发能力之间做取舍。

现象只改变的条件事务 A 的观察
脏读B 修改 100→120,但未提交A 已读到 120;若 B 回滚,A 读过从未成立的数据
不可重复读A 两次按主键读;中间 B 更新并提交第一次 100,第二次 120,同一行不同
幻读A 两次按范围统计;中间 B 插入并提交第一次 3 行,第二次 4 行,像多出“幻影”
易错点:不可重复读关注“原有行的值变了”;幻读关注“范围结果的行集合变了”。工程讨论还必须区分普通快照读与加锁的当前读。
三、版本从哪里来:隐藏字段、undo log 与版本链

InnoDB 是 MySQL 常用的事务存储引擎。它会为聚簇索引记录维护事务相关信息:DB_TRX_ID 记录最近修改该版本的事务编号,DB_ROLL_PTR 指向 undo log(撤销日志)中的上一版本信息。undo log 本来负责回滚,也能重建一致性读取需要的旧版本。

状态来历:两次已提交更新怎样留下可追溯版本 时间从左到右;事务编号是递增示例,不等于墙上时钟 初始:amount=80trx=10 事务 20 提交 新版本:amount=100trx=20 事务 30 提交 当前:amount=120trx=30 当前记录沿 DB_ROLL_PTR 向过去追 120 / trx 30聚簇索引中的当前记录 100 / trx 20undo 重建的上一版本 80 / trx 10更早版本 每向左一次,都多一次历史版本重建成本;长事务会让旧版本更久不能清理。
最应记住:版本链不是把完整历史行都塞在当前记录旁边,而是当前记录通过回滚指针追到 undo 信息并重建旧版本。常见误读是把 undo log 只当成“出错时才用的回滚文件”。
四、Read View:候选版本怎样逐个排除

Read View(读视图)是一次一致性读取用来判断版本能否看见的快照规则。先把它理解成一张名单:创建视图时,哪些事务还活跃、下一个尚未分配的事务编号是多少、当前事务是谁。

下面使用简化但可推演的例子:事务 A 编号 25;创建视图时,其他活跃事务为 [28, 30],最小活跃编号 28,下一个尚未分配的编号是 31。A 查询版本链 trx 30 → 20 → 10。

完整筛选:当前版本 30 为什么不可见,旧版本 20 为什么可见 Read View:creator=25 · 其他活跃事务=[28, 30] · min=28 · max=31自己的版本:可见;编号 <28:视图前已完成;28…30:查活跃名单;≥31:视图后才出现 候选 trx=30 30 在 [28,31) 内并且在活跃集合中 本视图不可见创建视图时,事务 30 尚未提交 沿回滚指针,多追 1 个版本 候选 trx=20 20 < min 28视图创建前已经提交 命中 amount=100停止继续追链 其他出口:自己的版本→可见;创建时仍活跃→继续追链;所有版本不合格→本行不可见 本例实际走:30 排除 → 付出一次历史跳转 → 20 命中;10 不再检查
最应记住:Read View 不是直接选中某个版本,而是拿候选版本的事务编号逐个判断,失败就沿版本链继续。常见误读是“某事务已经提交,所以所有旧视图都立刻能看见它”。
精确边界:源码字段和可见性函数会随版本演进,面试中应先讲“创建视图时的活跃事务边界与逐版本筛选”,再按目标 MySQL 版本补充字段名。
五、四种隔离级别:不同的是观察规则和锁策略
隔离级别普通读取主要结果代价
READ UNCOMMITTED(读未提交)可能看见未提交修改允许脏读一致性最弱
READ COMMITTED(读已提交)每次一致性读创建新快照不脏读,但同一事务两次结果可变可重复性降低
REPEATABLE READ(可重复读)同一事务的普通一致性读复用首个快照普通读取通常保持一致;InnoDB 默认旧版本可能保留更久
SERIALIZABLE(串行化)把并发行为约束得更像顺序执行隔离最强更多等待、更低并发
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT amount FROM orders WHERE id = 1; -- 第一次一致性读建立快照
SELECT amount FROM orders WHERE id = 1; -- 继续使用该快照
COMMIT;
不要背错:“可重复读解决幻读”必须带语境。普通一致性读依靠快照避免两次出现不同集合;SELECT ... FOR UPDATE、UPDATE 等当前读读取最新状态,并依靠索引范围锁处理插入冲突。
六、快照读与当前读:同一事务为什么可能看到两种世界

快照读是普通 SELECT 按 Read View 选择历史版本;当前读是读取最新可用版本并对目标记录或范围加锁,例如 SELECT ... FOR UPDATE、UPDATE、DELETE。

语句读哪个状态读取是否加记录/范围锁常见目的
SELECT ...快照允许的版本通常不加报表、展示
SELECT ... FOR SHARE当前状态共享锁确认数据后保持可用
SELECT ... FOR UPDATE当前状态排他锁读后准备修改
UPDATE / DELETE当前状态排他锁修改或删除
业务陷阱:先普通 SELECT 判断库存,再 UPDATE,不会自动形成不可分割的“检查并扣减”。应使用条件更新,或在事务中用合适的加锁读,并处理等待、死锁和重试。
七、工程代价与最小实验:长事务为什么越来越沉

清理线程只有在旧版本不再可能被任何活跃视图读取时,才能回收相关 undo 信息。长事务持续持有旧快照,会拉长版本链,使读取为了找到可见版本多做追链,也可能增加 undo 表空间压力。

排查信号

事务持续时间、undo 历史长度、长时间未提交连接、慢查询中版本重建和回表成本。

处理原则

缩短事务;不要在事务中等待用户或远程调用;分批处理大任务;设置合理超时;应用能重试被回滚的事务。

-- 会话 A
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT amount FROM orders WHERE id=1; -- 100

-- 会话 B 更新为 120 并 COMMIT 后,回到 A
SELECT amount FROM orders WHERE id=1;            -- 普通读仍可能为 100
SELECT amount FROM orders WHERE id=1 FOR UPDATE; -- 当前读看到 120
COMMIT;
八、面试追问、总结话术与应用场景

MVCC 是不是完全没有锁?

不是。普通一致性读通常通过版本选择避免加记录锁;写操作和加锁读仍使用锁。MVCC 与锁共同构成 InnoDB 的并发控制。

Read View 什么时候创建?

REPEATABLE READ 下,同一事务的普通一致性读复用第一次一致性读建立的快照;READ COMMITTED 下,每次一致性读建立新快照。事务开始不等于一定立刻创建普通读快照。

90 秒回答:MVCC 通过当前记录上的事务信息与 undo log 构成的历史版本,让一致性读按 Read View 选择可见版本。读已提交每次普通读取使用新快照,可重复读通常复用第一次一致性读的快照。候选版本不可见时沿回滚指针继续找,因此读写多数时候无需互相阻塞;代价是维护 undo、执行可见性判断,并且长事务会拖延历史清理。加锁读和写操作读取当前状态,仍然依赖锁,所以 MVCC 不是“数据库没有锁”。

应用场景

  • 订单详情、账户流水:理解普通查询为何能与修改并发。
  • 批量报表:需要稳定观察时点,但必须控制事务时长。
  • 库存扣减:识别普通快照读不等于锁定最新库存,不能硬把快照当成互斥工具。

资料依据