MVCC 多版本并发控制 + 锁机制:事务隔离级别的底层实现(三)
数据库事务系列 · 第 3 篇引言一个「诡异」的现象在前两篇文章中我们做了一个完整的演示第 1 篇我们学会了事务的基本操作BEGIN、COMMIT、ROLLBACK、SAVEPOINT并验证了原子性和持久性。第 2 篇我们把隔离级别调来调去亲眼看到了脏读、不可重复读、幻读以及四种隔离级别如何应对它们。但你有没有想过一个问题——在演示三Repeatable Read中我们看到了一个「诡异」的现象时间终端B柜员小李终端A柜员小张张三余额T1BEGIN;100T2SELECT balance FROM account WHERE id 1;读到100T3BEGIN;T4UPDATE account SET balance 0 WHERE id 1;T5COMMIT;已提交物理数据变为 0T6SELECT balance FROM account WHERE id 1;仍然读到100诡异的地方在于终端A 已经在 T5 时刻提交了事务数据库里的「真实数据」已经变成了 0。但终端B 在 T6 时刻自己的事务还没结束读取到的依然是 100。终端B 凭什么能看到「已经不存在」的旧数据这个问题指向了 MySQL 事务隔离性最核心的底层机制——MVCCMulti-Version Concurrency Control多版本并发控制。一、MVCC 的核心思想每个事务都有自己的「专属快照」1.1 什么是 MVCCMVCC 的全称是Multi-Version Concurrency Control翻译过来就是「多版本并发控制」。它的核心思想非常巧妙每次修改数据时不直接覆盖旧数据而是生成一个新版本。旧版本继续保留供其他事务读取。用生活场景来理解你在 Git 上提交代码每次提交都会生成一个新的 commit。团队成员可以继续查看旧的 commit也可以拉取最新的 commit。大家各看各的版本互不干扰。MVCC 在数据库里干的是同样的事情┌─────────────────────────────────────────────────────────────────┐ │ 数据行的版本链 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 版本 V1 (balance100) ←── 事务B 正在读这个版本 │ │ │ │ │ ▼ (事务A 修改并提交) │ │ 版本 V2 (balance0) ←── 物理数据现在是这个版本 │ │ │ │ 事务B 读到的还是 V1——因为事务B 启动时V2 还没诞生 │ │ │ └─────────────────────────────────────────────────────────────────┘1.2 MVCC 解决了什么问题MVCC 让数据库实现了两个关键能力能力说明对应业务价值读写不互斥读操作不会阻塞写操作写操作也不会阻塞读操作高并发场景下查询和更新可以同时进行一致性读每个事务都能读到「自己启动那一刻」的数据快照事务内多次查询结果一致可重复读没有 MVCC 之前读操作需要加锁写操作也要加锁——读会阻塞写写也会阻塞读并发性能很差。有了 MVCC读操作直接读取「旧版本」不需要等写操作完成。1.3 MVCC 的适用边界MVCC 解决了「读写冲突」但解决不了「写写冲突」。如果两个事务同时修改同一行数据比如终端A 和终端C 同时要把张三的余额改为 0 和 50MVCC 就无能为力了——因为最终只能有一个版本是「最新」的。这个冲突由锁机制来处理。MVCC 和锁一个管「读-写」一个管「写-写」共同构建了完整的并发控制体系。完整的分工图二、MVCC 的三大核心组成部分在深入细节之前我们先看一张完整的 MVCC 工作流程图。这张图把隐藏字段、undo log 版本链、read view 三者的关系一次性展现出来。接下来的三小节我们会逐一拆解图中的每一个部件。2.1 隐藏字段每行数据都有的「身份证」在 InnoDB 中每一行数据除了我们定义的字段id、name、balance还有三个隐藏字段隐藏字段名称大小存储内容DB_TRX_ID事务ID6 字节最近一次修改该行的事务IDDB_ROLL_PTR回滚指针7 字节指向 Undo Log 中旧版本的指针DB_ROW_ID行ID6 字节单调递增的行ID无主键时用于生成聚集索引我们通常不用关注 DB_ROW_ID但DB_TRX_ID 和 DB_ROLL_PTR 是理解 MVCC 的关键。用「张三的余额」来看trx_id 是自增的意味着什么规则含义trx_id 越小该版本被创建得越早trx_id 越大该版本被创建得越晚当前事务的 trx_id就是当前事务在系统中的唯一编号这就是 read view 判断「谁先谁后」的依据——用 trx_id 的大小关系判断数据版本和当前事务的时间先后。2.2 Undo Log 链数据版本的「全家桶」每次修改数据时InnoDB 会把修改前的旧版本记录到 Undo Log 中。多个旧版本通过roll_pointer串联成一条版本链。关键理解多个事务并行操作同一行时每个版本都记录着「谁改的」并通过 roll_pointer 串联成一个链表。每个版本都记录了两个关键信息是谁哪个事务ID创建了这个版本上一个版本在哪里DB_ROLL_PTR作用说明支持回滚事务回滚时沿着版本链找到旧版本恢复数据支持 MVCCread view 沿着版本链遍历找到「可见」的版本支持一致性读RR 级别下始终读取事务开始时的版本快照2.3 Read View事务启动时的「快照相机」Read View是 MVCC 中最重要的概念。它的作用是在事务执行SELECT时生成一个「快照」记录当时系统中所有活跃事务的状态。Read View 包含三个核心字段字段含义min_trx_id当前所有活跃事务中最小的 IDmax_trx_id系统下一个要分配的事务 ID边界值m_ids当前所有活跃事务的 ID 列表即正在进行中、还没提交的事务Read View 生成后数据可见性的判断规则当要读取某行数据时看该行的DB_TRX_ID创建该版本的事务ID三、RR 与 RC 的核心差异Read View 生成时机不同这是理解「为什么 RR 能可重复读而 RC 不能」的关键。3.1 Repeatable Read一个事务只用一张「快照」在 RR 级别下Read View 在事务中第一次执行 SELECT 时生成之后一直复用直到事务结束。这就是终端B 在演示三中「始终读到 100」的原因时间终端BRead View 状态T1BEGIN;事务开始但还没生成 Read ViewT2SELECT ...读到 100生成 Read View记录了当时的数据快照T3终端A 修改并提交100 → 0事务已提交但 Read View 不变T4SELECT ...仍然读到 100复用同一个 Read View仍然看到旧快照┌─────────────────────────────────────────────────────────────────┐ │ RR 级别Read View 复用示意图 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 事务B 时间线 │ │ ────────────────────────────────────────────────► │ │ BEGIN 第一次 SELECT 第二次 SELECT COMMIT │ │ │ │ │ │ │ │ │ ┌────▼────┐ ┌────▼────┐ │ │ │ │ │生成 │ │复用 │ │ │ │ │ │Read View│ │Read View│ │ │ │ │ └─────────┘ └─────────┘ │ │ │ │ │ │ │ │ │ 数据快照 100 100 100 → 事务结束 │ │ │ │ 即使终端A 在中间提交了修改0终端B 看到的始终是 100 │ │ │ └─────────────────────────────────────────────────────────────────┘3.2 Read Committed每次 SELECT 都生成一张新「快照」在 RC 级别下每次执行 SELECT都重新生成一个 Read View。这就是为什么 RC 会出现「不可重复读」——每次查询都看到最新的已提交数据。时间终端BRead View 状态T1BEGIN;事务开始T2SELECT ...读到 100生成 Read View #1T3终端A 修改并提交100 → 0事务已提交T4SELECT ...读到 0重新生成 Read View #2看到了最新的已提交数据四、锁机制处理「写-写冲突」的唯一武器MVCC 解决了「读-写冲突」但「写-写冲突」必须由锁机制来处理。4.1 为什么需要锁想象这个场景柜员小张终端A正在修改张三的余额100 → 0事务未提交柜员小王终端C也在修改张三的余额100 → 50事务未提交如果两个事务同时修改同一行数据最终结果应该是 0 还是 50MVCC 解决不了这个问题因为最终只能有一个版本成为「最新版本」。所以第二个修改的事务必须等待第一个修改的事务完成提交或回滚。这就是锁的作用——让「写-写」操作排队执行。4.2 锁的分类按粒度锁的「粒度」是指锁定的范围大小。粒度越大锁定的数据越多并发性能越差粒度越小锁定的数据越少并发性能越好。4.2.1 表级锁Table Lock锁定整张表粒度最大并发最差。场景在执行ALTER TABLE、DROP TABLE等 DDL 操作时会加表级锁。类比把整个仓库锁上所有人都不能进出。4.2.2 行级锁Row Lock锁定某一行记录粒度最小并发最好。场景InnoDB 在执行UPDATE ... WHERE id 1时只锁定 id1 的这一行其他行不受影响。类比只锁住仓库里的某一个货架其他人还能搬其他货架的东西。4.2.3 页级锁Page Lock锁定一个数据页通常 16KB介于表锁和行锁之间。说明MySQL InnoDB 主要使用行锁页级锁在一些其他数据库中更常见如 SQL Server。4.3 锁的分类按模式锁模式英文俗称作用共享锁Shared Lock读锁S锁允许其他事务读取不允许修改排他锁Exclusive Lock写锁X锁不允许其他事务读取或修改4.3.1 共享锁S锁读锁多个事务可以同时持有共享锁但不能修改数据。加了 S 锁的数据其他事务可以读但不能写。其他事务也可以加 S 锁但不能加 X 锁。加锁方式SELECT*FROMaccountWHEREid1LOCKINSHAREMODE;业务场景读取数据用于生成报表。报表生成过程中数据可以被其他人读但不希望被修改防止报表数据不一致。4.3.2 排他锁X锁写锁只允许一个事务持有排他锁其他事务既不能读也不能写。加了 X 锁的数据其他事务不能读会被阻塞也不能写。在事务提交或回滚之前X 锁不会释放。加锁方式-- 自动加 X 锁任何修改操作都会自动加 X 锁UPDATEaccountSETbalance0WHEREid1;-- 手动加 X 锁SELECT ... FOR UPDATESELECT*FROMaccountWHEREid1FORUPDATE;业务场景查询某条数据并准备修改。在查询和修改之间不希望其他事务修改这条数据。4.4 共享锁 vs 排他锁 兼容性矩阵锁类型共享锁S排他锁X共享锁S✅ 兼容❌ 冲突排他锁X❌ 冲突❌ 冲突一句话记住多个读可以同时进行S 和 S 兼容只要有写其他人都不能动X 和谁都冲突4.5 意向锁Intention Lock表级锁和行级锁的「协调员」意向锁是 InnoDB 中一个非常重要但容易被忽视的概念。它的作用非常纯粹意向锁是一种「表级锁」用于表示一个事务「打算」在表中的某些行加锁。为什么需要意向锁想象一个场景事务A 正在修改account表中的某一行加行级 X 锁事务B 想要锁定整张account表加表级锁如果没有意向锁事务B 需要扫描所有行检查是否有行级锁存在——这效率极低。有了意向锁事务A 在加行锁之前会先给表加一个「意向锁」作为标记。事务B 看到表上有意向锁就知道「表里已经有行锁了」直接等待即可。意向锁的两种类型意向锁简称含义意向共享锁IS 锁事务打算给某些行加共享锁S 锁意向排他锁IX 锁事务打算给某些行加排他锁X 锁加锁顺序重点事务要加行级 X 锁 │ ▼ 先加表级 IX 锁意向排他锁 │ ▼ 再加行级 X 锁排他锁意向锁的兼容性锁类型共享锁S排他锁X意向共享IS意向排他IX共享锁S✅ 兼容❌ 冲突✅ 兼容❌ 冲突排他锁X❌ 冲突❌ 冲突❌ 冲突❌ 冲突意向共享IS✅ 兼容❌ 冲突✅ 兼容✅ 兼容意向排他IX❌ 冲突❌ 冲突✅ 兼容✅ 兼容关键点意向锁之间是互相兼容的。多个事务可以同时持有 IX 锁表示多个事务都在修改不同行但一旦有事务要加表级 X 锁就必须等待所有意向锁释放。4.6 行锁的三种具体类型InnoDB 特有InnoDB 的行锁不是一种而是三种分别应对不同的场景4.6.1 记录锁Record Lock锁定单个索引记录。-- 锁定 id1 这一行SELECT*FROMaccountWHEREid1FORUPDATE;只锁住id1这一条记录其他 id 的记录不受影响。业务场景柜员查询张三的余额并准备修改防止其他事务在查询和修改之间改变数据。4.6.2 间隙锁Gap Lock锁定索引记录之间的「间隙」防止其他事务在该间隙插入数据。-- 假设 account 表中有 id: 1, 5, 10SELECT*FROMaccountWHEREidBETWEEN1AND10FORUPDATE;间隙锁会锁定 (1, 5) 和 (5, 10) 之间的空隙阻止其他事务插入 id3 或 id7 的新记录。间隙锁的主要目的防止幻读——阻止其他事务在查询范围内插入新数据。业务场景柜员小李统计「余额大于 0 的账户总数」不希望统计过程中有新账户开户插入否则统计数据就不准了。4.6.3 Next-Key Lock记录锁 间隙锁的组合。Next-Key Lock Record Lock Gap Lock它既锁住了记录本身又锁住了记录前面的间隙。在 MySQL 的 RR可重复读级别下查询使用索引时默认加的就是 Next-Key Lock。这就是为什么 MySQL 的 RR 级别能避免大部分幻读的原因。五、三种行锁的详细说明与案例5.1记录锁Record Lock目标证明记录锁只锁住id1这一行其他行不受影响。Step 1终端A 锁定 id1BEGIN; SELECT * FROM account WHERE id 1 FOR UPDATE;Step 2终端B 修改 id1被阻塞BEGIN; UPDATE account SET balance 999 WHERE id 1;Step 3终端B 修改 id5不受影响先CtrlC取消上一条被阻塞的语句然后在终端B 执行UPDATE account SET balance 888 WHERE id 5;截图 3显示Query OK, 1 row affected——立即成功。Step 4终端A 提交释放锁COMMIT;此时终端B 被阻塞的 UPDATE 会立即执行成功。结论记录锁只锁住id1这一行id5不受影响。5.2间隙锁Gap Lock目标证明范围查询会锁住(1,5)之间的间隙阻止在间隙内插入数据。Step 1重置数据DELETE FROM account; INSERT INTO account VALUES (1, 张三, 100); INSERT INTO account VALUES (5, 李四, 200); INSERT INTO account VALUES (10, 王五, 300);Step 2终端A 执行范围查询加锁BEGIN; SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;此时间隙锁锁住了 (1,5) 之间的空隙——也就是 id2、3、4 的位置。**Step 3终端B 在间隙内插入被阻塞BEGIN; INSERT INTO account VALUES (3, 赵六, 400);Step 4终端B 在间隙外插入不受影响先CtrlC取消然后执行INSERT INTO account VALUES (7, 赵六, 400);Step 5终端A 提交COMMIT;结论间隙锁锁住了(1,5)之间的空隙阻止在间隙内插入数据防止幻读。5.3Next-Key Lock目标证明SELECT ... WHERE id 5 FOR UPDATE不仅锁住了id5这一行还锁住了它前面的间隙(1,5)。Step 1重置数据DELETE FROM account; INSERT INTO account VALUES (1, 张三, 100); INSERT INTO account VALUES (5, 李四, 200); INSERT INTO account VALUES (10, 王五, 300);Step 2终端A 锁定 id5BEGIN; SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;Step 3终端B 修改 id5被阻塞——记录锁效果BEGIN; INSERT INTO account VALUES (3, 赵六, 400);Step 4终端B 在间隙 (1,5) 中插入被阻塞——间隙锁效果先CtrlC取消然后执行INSERT INTO account VALUES (3, 赵六, 400);Step 5终端B 在间隙外插入不受影响先CtrlC取消然后执行INSERT INTO account VALUES (6, 赵六, 400);Step 6终端A 提交COMMIT;结论Next-Key Lock 记录锁锁住id5 间隙锁锁住前面的间隙(1,5)。三种行锁演示效果对比表演示终端A 的操作终端B 的操作结果说明记录锁SELECT ... WHERE id1 FOR UPDATEUPDATE ... WHERE id1❌ 阻塞记录锁锁住 id1UPDATE ... WHERE id5✅ 成功id5 不受影响间隙锁SELECT ... WHERE id BETWEEN 1 AND 5 FOR UPDATEINSERT ... id3❌ 阻塞3 在间隙 (1,5) 中INSERT ... id7✅ 成功7 不在间隙中Next-Key LockSELECT ... WHERE id5 FOR UPDATEUPDATE ... WHERE id5❌ 阻塞记录锁INSERT ... id3❌ 阻塞间隙锁前面的间隙INSERT ... id6✅ 成功6 不在锁范围内六、不同隔离级别下的锁策略对比这是本文最重要的总结表建议收藏隔离级别使用的锁机制特点并发性能Read Uncommitted几乎不加锁读操作不加锁写操作加 X 锁性能最高但脏读、不可重复读、幻读都可能最高Read Committed读操作不加锁MVCC写操作加 X 锁无间隙锁避免脏读但不可重复读、幻读可能较高Repeatable Read读操作不加锁MVCC写操作加 X 锁有间隙锁Next-Key Lock避免脏读、不可重复读基本避免幻读中等Serializable读操作加 S 锁写操作加 X 锁所有操作串行执行避免所有并发问题性能最低最低关键差异说明1. Read Committed 没有间隙锁而 Repeatable Read 有这就是为什么 RC 会出现幻读而 RR 不会在大多数场景下。我整理了一个简要对比表对比维度RCRR是否使用 MVCC✅ 是✅ 是Read View 生成时机每次 SELECT第一次 SELECT是否使用间隙锁❌ 否✅ 是能否避免幻读❌ 不能✅ 基本能七、MVCC 锁 日志三者如何协作7.1 三者的职责分工机制负责什么解决什么问题Undo Log存储数据的历史版本原子性回滚 MVCC 的数据来源MVCCRead View控制事务能看到哪个版本隔离性中的「读-写」冲突锁Lock控制事务的并发修改隔离性中的「写-写」冲突Redo Log记录修改操作持久性崩溃恢复7.2 一个完整的「查询」流程RR 级别下当终端B 执行SELECT * FROM account WHERE id 1;时7.3 一个完整的「更新」流程所有级别下当终端A 执行UPDATE account SET balance 0 WHERE id 1;时7.4 三者协作的完整视图八、全文总结8.1 核心知识点回顾知识点核心内容一句话记忆MVCC多版本并发控制通过 Undo Log 链 Read View 实现每个事务都有自己的数据快照Read View事务启动时记录活跃事务列表决定数据可见性RR 级别只用一张快照RC 每次重新拍Undo Log 链数据行的历史版本串联成链供 MVCC 读取每个版本都记录着「谁改的」和「上一个」记录锁锁定单行粒度最小只锁这一条间隙锁锁定索引间隙防止插入RR 级别用来防幻读Next-Key Lock记录锁 间隙锁RR 级别的默认行锁意向锁表级锁标记表内有行锁让表锁和行锁能协调工作8.2 隔离级别与底层实现对照表隔离级别MVCC 使用Read View 策略间隙锁使用如何保证可重复读Read Uncommitted❌ 不用无❌ 不用不保证Read Committed✅ 使用每次 SELECT 生成❌ 不用不保证Repeatable Read✅ 使用首次 SELECT 生成✅ 使用固定 Read View 间隙锁Serializable⚠️ 退化无串行化✅ 使用强制串行执行8.3 一句话全文总结MVCC 为每个事务创建「专属快照」让读写互不干扰锁机制在「写-写」冲突时强制排队保证数据最终一致Undo Log 提供历史版本Redo Log 保证崩溃恢复。四者共同撑起了事务隔离性的整片天空。 下篇预告前三篇文章中我们从 ACID 聊到了 MVCC从脏读聊到了间隙锁——理论已经讲得够多了。但实际开发中我们该如何选择隔离级别死锁是怎么产生的怎么排查用 Prisma ORM 写事务时有哪些坑下一篇文章第 4 篇我们将走进生产环境聊聊事务的实战经验。不同业务场景如何选隔离级别死锁的成因、排查与预防大事务的危害与拆分策略Prisma ORM 中的事务操作实践| 无 | ❌ 不用 | 不保证 || Read Committed | ✅ 使用 | 每次 SELECT 生成 | ❌ 不用 | 不保证 || Repeatable Read | ✅ 使用 | 首次 SELECT 生成 | ✅ 使用 | 固定 Read View 间隙锁 || Serializable | ⚠️ 退化 | 无串行化 | ✅ 使用 | 强制串行执行 |8.3 一句话全文总结MVCC 为每个事务创建「专属快照」让读写互不干扰锁机制在「写-写」冲突时强制排队保证数据最终一致Undo Log 提供历史版本Redo Log 保证崩溃恢复。四者共同撑起了事务隔离性的整片天空。 下篇预告前三篇文章中我们从 ACID 聊到了 MVCC从脏读聊到了间隙锁——理论已经讲得够多了。但实际开发中我们该如何选择隔离级别死锁是怎么产生的怎么排查用 Prisma ORM 写事务时有哪些坑