🔥 本文专栏:MySQL
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录 :
普通人逆风翻盘,靠的不是一口气冲到底,而是被现实锤了很多次之后,还愿意重新站回起跑线。
思维导图

引入
在此前的学习中,我们已经掌握了 MySQL 的基本数据库表的操作和底层索引原理。我们也知道,MySQL 是一个典型的 C/S 架构网络服务进程:客户端通过 TCP 连接进来,服务端为每一条连接分配一个独立的线程来处理请求。当多个线程并发访问同一张数据库表时,就会面临一些潜在的挑战。
我们先来看一个非常具体的例子------学生选课。
假设数据库中有两张表:
- 课程表:记录课程编号和剩余名额
- 选课表:记录学号和课程编号
学生选课这个动作,从数据库视角看,对应着三条 SQL:
sql
-- 1. 查询该课程的剩余名额
SELECT remain FROM course WHERE course_id = 101;
-- 2. 如果 remain > 0,插入一条选课记录
INSERT INTO student_course (student_id, course_id) VALUES (...);
-- 3. 把剩余名额减 1
UPDATE course SET remain = remain - 1 WHERE course_id = 101;
逻辑上看着平平无奇,几乎是按业务流程"翻译"过来的三步。但是,把它放到真实的并发环境里,就会立刻暴露出两个完全不同方向的问题。
痛点一:多线程并发导致的数据不一致
需要先明确一点:MySQL 中单条 SQL 语句的执行是原子的 ------也就是说,一条 UPDATE 不会执行到一半被打断。但多条 SQL 组合起来的执行过程并不原子,线程切换可以发生在任意两条 SQL 之间。
在刚才的场景中,10 个线程同时访问同一张表,极有可能出现交替执行的情况:10 个线程同时完成了 SELECT 查询,发现名额都还剩 5 个,随后它们并发执行后续的插入和更新操作。最终,剩余名额可能会被扣减到 -5。这显然违背了基本的业务逻辑以及直觉------名额怎么可能为负?这就是典型的多线程并发导致的数据不一致问题。
plaintext
线程 A: SELECT → 5 ┐
线程 B: SELECT → 5 │
线程 C: SELECT → 5 ├── 都看到 5 个名额,都认为"够"
... │
线程 J: SELECT → 5 ┘
↓
全部 INSERT + UPDATE
↓
remain = 5 - 10 = -5 ❌
痛点二:单线程异常导致的部分执行
有并发编程经验的同学读到这里会立刻冒出一个念头:加锁不就完了? 在第一步之前先抢一把互斥锁,访问完再释放,把并发强行串行化,问题自然消失。
这思路没错,但我们换一个完全没有并发的场景再看一眼:只有一个线程在执行选课操作 。这个线程刚跑完 SELECT 和 INSERT,准备执行第三步 UPDATE 时,连接突然断开------可能是网络抖动、客户端进程崩溃。
此时数据是什么状态?
SELECT ✅ 已执行
INSERT ✅ 已执行 → 选课记录已经写进表里
UPDATE ❌ 未执行 → 课程剩余名额没有扣减
选课记录已经实实在在地落到了选课表里,但课程表里的名额还是原来的数字。从业务角度看,这个学生已经成功选上了课,但这门课的"已被选走"的事实却没有被记录。下一次再有学生来选,系统又会按照"还剩 N 个名额"来判断------脏数据就这么留下来了。
面对这种"天灾人祸",加锁是完全无能为力的。锁只能解决多个线程之间的协调问题 ,它解决不了单个线程做到一半挂掉的问题。
所以仅靠锁是远远不够的
把这两个痛点摆在一起对比,会发现它们所揭示的失效路径其实是完全独立的:
plaintext
┌──► 多个线程相互踩踏 ──► 加锁可以解决
数据不一致的来源 ──┤
└──► 单线程半路崩溃 ──► 加锁无能为力
锁只能覆盖左边那条路径。要把右边那条路径也堵上,就需要一种比锁更高层的抽象------它必须能够保证:"这一组 SQL,要么全部生效,要么全部不生效,不接受任何中间状态"。
这正是数据库**事务(Transaction)**机制要解决的问题。
从数据不一致问题到事务概念的引出
根据上文,我们已经认识到:MySQL 在实际运行过程中可能会出现数据不一致问题,而解决数据不一致问题,正是事务机制存在的重要意义。
需要注意的是,数据不一致并不只来自多线程并发访问。并发访问确实会带来数据竞争问题,而锁机制可以在一定程度上解决多个线程同时访问同一份数据时的互斥与隔离问题。但是,仅仅依靠锁机制,并不能解决所有的数据不一致问题。
因为在真实业务场景中,一个业务需求往往不是由一条 SQL 语句完成的,而是由一组 SQL 语句共同完成的。只要涉及多条 SQL,就会面临一个新的问题:MySQL 如何把这一组 SQL 当作一个不可分割的整体来对待?
所以,接下来我们的目光就要聚焦到事务。而学习事务之前,首先要解决的第一个问题就是:事务究竟是什么?
以前面提到的学生选课业务为例。从业务角度看,"选课"是一个完整的动作;但从 MySQL 的执行角度看,它却是由三条独立的 SQL 组成的:
sql
SELECT 查询课程表中的剩余名额;
INSERT 向选课表中插入一条选课记录;
UPDATE 更新课程表中的剩余名额;
单独一条 SQL,MySQL 可以把它作为一个独立的执行单位来处理。但是 MySQL 默认并不知道这三条 SQL 在业务上存在逻辑关系,它只会按客户端发送过来的顺序,一条一条执行。
text
应用层视角 MySQL 视角
───────────── ─────────────
┌───────────┐ SELECT
│ │ ←─ 同一个 ─→ INSERT
│ 学生选课 │ 业务动作 UPDATE
│ │
└───────────┘ (三条独立 SQL)
站在 MySQL 的角度,它看到的是三条独立的 SQL;但站在业务层的角度,这三条 SQL 必须被看成一个完整的整体。这就引出了一个关键问题:如果这一组 SQL 只执行了一部分,会发生什么?
比如已经执行完了 SELECT 和 INSERT,也就是已经判断课程还有剩余名额,并且已经向选课表中插入了一条选课记录。但是此时由于连接断开等异常情况,后面的 UPDATE 没有执行。那么数据库中就会出现一种中间状态:
text
选课表中已经存在该学生的选课记录;
但是课程表中的剩余名额却没有减少。
对 MySQL 底层来说,这只是"前两条 SQL 已经执行,后一条 SQL 还没有执行";但对业务层来说,一次完整的选课操作不应该只完成一半。一个业务动作的结果应该只有两种状态:要么还没有开始执行,要么已经完整执行结束------而不应该出现"执行到一半"的中间态。
这里需要特别澄清一个细节:事务所强调的"原子性",并不是说这一组 SQL 在物理执行过程中绝对连续、不能被线程调度打断。
MySQL 作为一个数据库服务进程,本身就在同时处理多个客户端连接,线程之间发生调度切换是非常正常的事情。事务真正想表达的是:从最终结果来看,这一组 SQL 应该是不可分割的整体------
text
如果这一组 SQL 全部执行成功,那么它们的修改结果一起生效;
如果中间某一步失败,那么前面已经执行过的修改也不应该作为最终结果保留下来。
所以,事务的原子性并不是"执行过程绝对连续 ",而是"执行结果不可分割"。这个区分非常重要,后面在讨论事务实现机制时会反复用到。
从这个角度来看,MySQL 和 CPU 执行指令其实有一点相似(这里我们类比的是"层次抽象"这个角度,不是"原子性"本身)。
CPU 在执行程序时,本质上是一条一条地执行机器指令。CPU 本身并不天然关心这些指令共同构成了哪个 C++ 函数、哪个业务逻辑,或者哪个高级语言层面的语义单元。
同样,MySQL 在接收客户端发送的 SQL 时,本质上也是一条一条地执行 SQL 语句。MySQL 并不会天然知道哪些 SQL 之间存在业务上的逻辑联系,也不会自动知道哪些 SQL 必须作为一个整体执行。
但是对于应用层来说,我们关心的不是某一条 SQL 是否执行完成,而是一个完整业务动作是否执行成功。因此,我们需要一种机制,能够明确告诉 MySQL:
text
从这里开始,下面这些 SQL 属于同一个业务整体;
到这里结束,这个业务整体才算执行完成。
这个机制,就是事务。
所以,我们可以先给事务下一个定义:
事务就是一组逻辑相关的 SQL 语句的集合,这组 SQL 共同完成同一个任务目标或者业务需求。
一个事务可以只包含一条 SQL,也可以包含多条 SQL。但在真实业务中,事务更重要的意义通常体现在:把多条 SQL 绑定成一个逻辑执行单元。
事务本身是一个逻辑概念 。它并不是简单等同于某一条 SQL,也不是简单等同于某一把锁,而是应用层向 MySQL 声明的一段执行边界。一个事务就代表一个不可分割的逻辑处理单元------通过开启事务,上层应用实际上是在给 MySQL 划定一个执行的边界,告诉数据库:"在这个边界内的所有 SQL 语句,请你务必保证它们同生共死!"
在正式进入事务的具体讨论内容之前,需要先把一个边界 画清楚------本文所有关于事务机制的内容,默认都基于 InnoDB 存储引擎 展开。
MySQL 本身是一个客户端-服务器架构的数据库系统,内部又可以分为 MySQL Server 层 和 存储引擎层 两个部分。Server层负责 SQL 语法解析、连接管理、权限校验等通用逻辑;而数据最终如何存储、如何加锁、如何回滚、如何保证崩溃恢复,则取决于具体的存储引擎。
也就是说,BEGIN、COMMIT、ROLLBACK 这些事务控制语句(后文会讲到),属于 MySQL Server 层能够识别的 SQL 语法。但真正支撑事务语义的,并不是这些语法本身,而是底层存储引擎是否具备对应的实现能力。
InnoDB 是 MySQL 默认且最常用的事务型存储引擎。它通过 Undo Log、Redo Log、MVCC、行锁 等机制,支撑事务的原子性、隔离性和持久性。也正因为有这些底层机制,InnoDB 才能在事务执行失败时完成回滚,在并发访问时维持隔离,在数据库崩溃后恢复已经提交的修改。
相对地,MyISAM这类非事务型存储引擎并不具备完整的事务能力。虽然 MySQL Server 层能够解析 ROLLBACK 这类语句,但如果操作对象是MyISAM 表,已经执行的修改并不会因为 ROLLBACK 而被撤销。原因在于 MyISAM本身没有 Undo Log,也没有事务回滚机制,更不具备 InnoDB 这种基于 MVCC 和行锁实现的隔离控制能力。
因此,后文提到的事务、回滚、Undo Log、Redo Log、MVCC、ReadView、行锁以及隔离级别的具体实现,除非特别说明,均默认以 InnoDB 为讨论对象。
简单来说:
text
事务语法由 MySQL Server 层识别;
事务能力由存储引擎真正实现;
InnoDB 支持事务,MyISAM 不支持事务;
本文重点拆解 InnoDB 在事务边界背后做的事。
这也是为什么学习 MySQL 事务时,真正需要深入分析的并不是一条 BEGIN 或 COMMIT 语句本身,而是 InnoDB 在这些事务边界背后具体做了什么。
从并发控制到锁系统实现:InnoDB 锁机制的完整设计
从并发访问到锁分层:Page Latch 与 Row Lock 的协同机制
在前面我们已经看到,导致数据不一致的原因有两条独立路径:多线程并发访问、单线程部分执行。我们也得出了一个判断------锁能解决前者,但解决不了后者。
这个判断光摆出来是不够的。要真的接受它,就得看看 MySQL 内部到底是怎么用锁来抗住并发的,看完之后再回头问:这套机制在面对"单线程做到一半挂了"时,为什么就失效了?
MySQL 中的并发场景
MySQL 是一个 C/S 架构的网络服务进程,每一条连接由一个独立的服务线程处理。多个线程同时访问同一张表、甚至同一批记录,是再正常不过的场景------这种场景下,并发安全问题就是必须面对的。
而对于数据库表的访问,本质上就是 CRUD 操作。这里需要先建立一个底层认知:InnoDB 并不是直接以"一行一行"的方式从磁盘读写数据的 。MySQL 存储数据的基本单位是页 ,InnoDB 默认页大小为 16 KB。执行 SQL 时,存储引擎会先查看目标页是否已经加载到 Buffer Pool(InnoDB 在内存里维护的一块页缓存):
- 如果目标页已经在 Buffer Pool 中,线程直接在内存中访问该页
- 如果没有命中,则触发磁盘 I/O,把目标页从磁盘加载进 Buffer Pool,再进行访问
对于修改操作来说,线程修改的并不是磁盘上的原始数据文件,而是 Buffer Pool 中已经加载进内存的页。修改完成后,该页会被标记为脏页,后续再由 InnoDB 的刷盘机制在合适的时机将脏页刷新回磁盘。
这个机制带来一个非常关键的事实:
text
多线程并发访问数据库
│
▼
目标页加载到 Buffer Pool
│
▼
多线程并发访问 Buffer Pool 中的同一个页
(内存,非磁盘)
所有的并发竞争,最终都发生在内存中的页上 ------而不是磁盘文件上。这一点决定了我们后面讨论"加锁保护什么"的时候,保护对象本质上是内存中的页,不是磁盘上的数据。
直觉方案:给每一个页加一把锁
面对多个线程并发访问同一个页,最直接的想法是:给每一个页都加一把锁。
也就是说,线程想要访问某个页,必须先竞争这个页对应的锁。只有成功持有这把锁的线程,才有资格访问和修改这个页中的数据。
从保证安全性的角度来看,这种设计当然是可以理解的。因为只要同一时刻只有一个线程能够修改这个页,那么页内部结构自然不会被多个线程同时改乱。
但是,这种方案有一个明显的问题:锁的粒度太大,会严重降低并发度。
因为线程真正想访问的对象,通常并不是整个页,而是页中的某一条记录。
并发度(Concurrency)是数据库的核心生命线。如果锁粒度是一整个页,即使线程 A 想修改第 1 条记录,线程 B 想修改第 100 条记录,它们也必须串行排队。然而,一个 16KB 的页中可能存储着很多条记录。如果两个线程访问的是同一个页中的不同记录即它们修改的内存区域(即 User Records 中的不同数据块)是完全独立的,本该可以安全地并发执行。
但是,如果我们只使用页级别的锁,那么只要两个线程访问的是同一个页,即使它们修改的是不同记录,也必须串行执行。这就会导致大量本来可以并发执行的操作被迫排队,从而降低系统的并发能力。
所以,仅仅使用页级锁是不够理想的。为了提高并发度,锁的粒度应该进一步缩小,至少应该能够细化到记录级别,也就是我们通常所说的行锁或者记录锁。
行锁是终点吗?还有一个被忽略的问题
行锁解决了"不同记录可以并发修改"这件事。但读到这里,需要警惕一个想当然的结论:有了行锁,是不是页级别就完全不需要保护了?
答案是否定的。原因藏在 InnoDB 的页内部结构里。
我们之前的博客分析过,一个数据页并不是只装用户记录,它内部还有一堆公共结构:
text
页头信息
空闲链表
页目录
记录链表
Free Space 区域
关键问题是:修改一条记录,影响的不只是这条记录本身,还会触动页的公共结构。以插入操作为例:
- 通过 B+ 树定位到目标叶子页
- 通过页目录(Page Directory)二分查找到目标分组,分组内顺序查找确定插入位置
- 给新记录分配空间------先看 Free List 上有没有逻辑删除的空间可以复用,没有再去 Free Space 里切一块
- 调整记录链表------修改前驱、后继的指针,把新记录串进去
- 更新页头元信息------堆号、记录数、空闲空间偏移等
可以看到,插入虽然在语义上是"新增一条记录",但物理上要动的东西远不止这一条记录------Page Header、Page Directory、Free List 这些公共结构都会被同时修改。
删除操作也是类似的。
InnoDB 中的删除通常不是立刻把记录占用的物理空间彻底释放掉,而是先将记录标记为逻辑删除。记录头中会有相应的标志位,用来表示该记录是否已经被删除。
被逻辑删除的记录,其空间后续可能会被挂载到空闲链表中,供新的插入操作复用。
因此,删除操作除了修改目标记录本身的状态之外,也可能涉及页内空闲链表、页头元信息等公共结构的调整。
这意味着:即使两个线程改的是同一页中不同的记录,它们各自的修改过程中都需要触动同一份公共结构(页头、Page Directory、空闲链表等),而这些结构全页只有一份。如果只用行锁,记录本身是保护住了,但页头、页目录、空闲链表这些公共内存结构会被并发修改撕裂。
所以光有行锁也不够。InnoDB 还需要一种机制,专门保护页内部的物理结构在被并发修改时的完整性。
真正的答案:两套独立的并发控制
由此我们可以得到一个结论:MySQL 内部的锁机制并不是单一层次的,而是需要两套不同维度的锁机制------Page Latch 和 Row Lock------配合使用。
读到这里,你可能会问:这里既然又把页级的锁加回来了,并发度问题怎么办?
这里就是 InnoDB 设计上一个非常关键的认知------Page Latch 和 Row Lock 不是同一种东西的两种粒度,它们从设计上就是两类不同的同步原语,解决的是两个不同维度的问题:
text
Latch(闩锁) Lock(行锁)
───────────────── ─────────────────
保护对象 内存中的物理结构 记录层面的逻辑访问
(页头、目录、链表) (具体某条记录)
持有时间 极短 长
(几条 CPU 指令) (持有到事务结束)
可以看到,两者保护的对象、持有时长完全不在一个量级。为什么要分成两套?关键在于持有时间:
- 修改页内物理结构,是几条 CPU 指令的事,毫秒都谈不上------这种短暂保护用一把轻量的 latch,加上、做完、立刻释放
- 而一个事务对记录的访问,可能会横跨多条 SQL------读出来、判断、再修改、再读其他记录......期间这条记录不能被其他事务并发修改,否则事务整体的逻辑就乱了。所以这种锁必须持有到整个事务结束,对应的记录都需要保持被独占的状态。
假设我们只用一把锁来管这一切------无论这把锁叫什么名字、设计成什么语义,故事都会变成同一个样子:事务 A 在某个页里改一条记录,按物理流程是先改页结构、再改业务数据。第一步做完,锁能释放吗?不能------因为业务数据这一步紧接着要靠它保护,一旦释放,别的事务就能并发修改同一条记录。所以这把锁只能继续持有,一直陪着业务数据走到事务提交。
灾难就藏在这"陪跑"里。事务 A 实际占用页结构的时间只有那几条 CPU 指令的瞬间,但因为锁不能中途释放,页结构因为和业务数据共用同一把锁,被迫陪着独占了整个事务时长 。事务 B 现在想插入同一个页里另一条 记录------业务上和 A 完全不冲突,本来可以并发------却必须先经过修改页结构这一步,而锁此刻仍被 A 持有......B 只能等待 A 提交。明明业务上不冲突的两个事务,被页结构这层共享资源强行串行化。
根本原因在于:页结构和业务数据这两个阶段对锁的"持有时长"需求是矛盾的------页结构需要短暂保护(做完几条 CPU 指令立刻让位),业务数据需要长期独占(保护到事务结束)。耦合在一把锁里,短的需求必然被长的拖累,页结构永远是受害者。所以 InnoDB 干脆把它们拆开:
- Page Latch:保护页的物理结构。由存储引擎在需要时自动获取,操作完立刻释放
- Row Lock(行锁):保护具体某条记录的逻辑访问。持有时间和事务绑定------事务开始操作这条记录时加上,事务结束后才释放
二者是配合关系 : Page Latch 负责保护页结构的物理完整性,Row Lock 负责保护记录在事务期间的逻辑独占性。 底层的物理结构靠 Latch 保证绝对安全,上层的逻辑访问靠 Lock 保证事务正确性,两者协同,MySQL 才能在保证正确性的同时撑住高并发。
InnoDB 行锁系统:行锁对象的创建以及组织方式、生命周期与冲突检测
上一节我们看到 InnoDB 用 Page Latch 和 Row Lock 两套机制分别保护页结构和记录访问。现在把视角再往上拉一层:MySQL 内部并发执行着大量事务,每个事务又可能锁住大量记录------InnoDB 是如何把这些行锁组织起来管理的?
先描述,再组织
MySQL 是一个典型的客户端-服务器架构的网络服务进程。客户端与 MySQL 建立连接之后,MySQL 会为该连接分配或复用一个服务线程,用来处理这条连接发来的 SQL 请求。
从管理角度看,MySQL 面对的不是几条孤立的 SQL,而是大量并发连接、并发会话、并发事务,以及这些事务在执行过程中产生的成千上万的行锁 。这种规模的资源管理,走的是一条非常经典的思路:先描述,再组织。
- 先描述:用一个具体的结构体把要管理的对象抽象出来------这个连接的会话状态是什么?这个事务的 ID 是多少、隔离级别是什么、当前持有哪些锁?这把锁锁的是哪个事务、哪个页、哪些记录?
- 再组织:把这些结构体用某种数据结构(链表、哈希表)串起来,方便快速查找。
具体到 MySQL 内部:
text
对于每条客户端连接 ──► 维护一个会话上下文(session)
对于每个事务 ──► 维护一个事务对象(trx_t),记录事务 ID、状态、
隔离级别、持有的锁列表等
对于每把行锁 ──► 维护一个 Lock Object(lock_t),记录锁属于哪个
事务、锁的是哪个页、哪些具体记录
这里需要注意,连接和事务不是同一个概念:一个连接代表一次客户端与 MySQL 之间的会话,而事务是在这个会话里执行事务性操作时产生的逻辑执行单元。一条连接可以先后跑多个事务,事务的生命周期通常比连接短得多。
我们这一节关心的是行锁的管理 ,所以重点放在 Lock Object 这一层。但有一个关系要先建立起来:事务对象和 Lock Object 之间是双向关联的------
- 每个 Lock Object 里记着"我属于哪个事务"(一个事务 ID 字段)
- 每个事务对象里也维护着一个链表,记录"我目前持有哪些 Lock Object"
为什么要双向?因为锁系统的两个核心查询是反方向的:
text
查询 1(冲突检测): 给定一条记录,查"它有没有被锁、被谁锁"
──► 从记录出发找事务
查询 2(事务提交/回滚): 给定一个事务,查"它持有哪些锁需要释放"
──► 从事务出发找所有锁
两个方向都要快,所以两边都得有指针。
接下来我们就顺着"先描述、再组织"这两步,看 InnoDB 在每一步上具体是怎么做的。
第一步:怎么"描述"一把行锁
最朴素的想法是:一条被锁的记录,对应一个独立的锁对象。锁对象里记下事务 ID、定位到这条具体记录、锁的模式等等。
但这种方案在真实业务面前很快就会出问题。考虑这样一条 SQL:
sql
UPDATE student_course
SET status = 1
WHERE id BETWEEN 1 AND 10000;
这条语句一口气要锁住一万条记录。如果按"一条记录一个锁对象"的方式,假设单个锁对象 100 字节,光这一个事务就要消耗 1 MB 内存。如果系统里同时有几百个事务都在跑类似的批量操作,光锁对象本身就要吃掉几个 GB------这显然撑不住。
InnoDB 的设计选了一条更紧凑的路:以"页"为单位组织锁对象,用一段位图标记页内具体哪些记录被锁住 。具体来说,一个 Lock Object(在源码里叫 lock_t) 包含这样几样东西:
cpp
struct lock_t {
trx_id_t trx_id; // 这把锁属于哪个事务
space_id_t space_id; // 表空间 ID
page_no_t page_no; // 页号
lock_mode mode; // 锁模式 (S 锁 / X 锁等)
bitmap_t bitmap[]; // 附在对象后面的位图
// 每个 bit 对应页内一个 heap_no
// bit = 1 表示该 heap_no 的记录被本对象锁住
};
注意一个关键约束:一个 Lock Object 只能描述"同一事务、同一页、同一锁模式"下的多条记录锁 。换句话说,只有当 (事务, 页, 锁模式) 这个三元组完全相同的多条记录锁,才能被合并到同一个 Lock Object 里------任何一个维度不同,就必须新建一个独立的 Lock Object。
Lock Bitmap 是怎么定位到具体记录的
之前我们学习 InnoDB 页内结构时知道,页内每条记录都有一个 heap_no,就是记录在页内堆中的编号。具体编号规则是:
text
heap_no = 0: infimum (虚拟记录,链表头哨兵)
heap_no = 1: supremum (虚拟记录,链表尾哨兵)
heap_no = 2: 第一条用户记录
heap_no = 3: 第二条用户记录
heap_no = 4: ...
Lock Bitmap 正是利用这个 heap_no 来定位:
text
bitmap 的第 i 位 ←→ 页内 heap_no = i 的记录
bit = 1: 这条记录被这个 Lock Object 锁住
bit = 0: 这条记录没有被这个 Lock Object 锁住
举个例子,事务 A 在某个页上锁住了 heap_no 为 2、4、6 的三条记录(也就是这个页里的第 1、3、5 条用户记录):
text
Lock Object [事务 A, 页 P, X 锁]
│
└─ bitmap: [0,0,1,0,1,0,1,0,...]
↑ ↑ ↑
heap_no 2, 4, 6 被置位
一个很自然的疑问:bitmap 应该开多大
读到这里你可能会想:一个事务在同一页上锁的记录,在 heap_no 上不一定连续------可能是 2、5、17、200 这样跳着分布。那 bitmap 到底要开多大才够用?
InnoDB 的做法非常干脆:bitmap 的大小不是按"这次锁了几条"动态决定的,而是按"这个页当前有多少条记录"一次性开够的。
具体来说,当 InnoDB 第一次为某个 (事务, 页, 锁模式) 三元组创建 Lock Object 时,它会查看这个页当前有多少条记录(含 infimum、supremum),一次性把 bitmap 开成能覆盖整个页所有 heap_no 的大小 。后续这个事务在同一页上再锁更多记录,不需要新建 Lock Object------直接在已有 bitmap 上把对应的位置 1 就行。
举个例子。事务 A 在某个 100 条记录的页上:
text
第 1 次加锁: 锁 heap_no = 5
→ 创建 Lock Object,bitmap 开 102 位(向上对齐到 13 字节)
→ 置位 bit 5
第 2 次加锁: 锁 heap_no = 17
→ 复用上面这个 Lock Object
→ 置位 bit 17
第 3 次加锁: 锁 heap_no = 73
→ 还是复用同一个 Lock Object
→ 置位 bit 73
bitmap 早就开到了能覆盖整个页的大小,所以无论后续锁的 heap_no 是多少、是不是连续,都直接置位即可。
这样设计带来一个非常关键的事实:Lock Object 的创建次数等于"事务访问过的不同页的数量",而不是"事务锁了多少条记录"。
考虑两种极端情况:
text
情形 A: 事务在某个页上只锁 1 条记录
Lock Object: 约 100 字节
Bitmap: 约 13 字节
总计: 约 113 字节
情形 B: 事务在同一个页上锁满整个页 100 条记录
Lock Object: 约 100 字节
Bitmap: 还是 13 字节(只是更多位被置 1)
总计: 约 113 字节
锁 1 条和锁 100 条,内存占用完全一样。这就是为什么 InnoDB 面对"批量更新一万条记录"这种场景时不会爆内存------只要这一万条记录分布在 N 个页里,总开销就是 N 个 Lock Object 的量级,而不是一万个。
那如果页里后来又插入了新记录呢
你可能会进一步追问:bitmap 的大小是按"创建 Lock Object 那一刻页内的记录数"开的------那如果这个页后来又插入了新记录、新 heap_no 超出了 bitmap 的范围,怎么办?
InnoDB 在创建 bitmap 时会预留一些余量 ,不是严格等于"当前页内记录数",而是会按 当前页内记录数 + 一定余量 向上对齐到字节边界。这个余量的目的就是应对页内后续可能出现的新插入记录,绝大多数场景下都够用。
那万一余量也用完了呢?------也就是页内连续插入了大量新记录,超出了原 bitmap 的范围。这时 InnoDB 会重新创建一个 bitmap 更大的 Lock Object 来替换原来那个 :把旧 bitmap 的内容拷贝过去,然后销毁旧对象。这是一次性的迁移,不会出现两个 Lock Object 同时存在、bitmap 部分重叠的情况。
不过这条路径在实际中很少走到,原因有两个:
-
bitmap 的余量通常已经接近页容量上限。一个 16 KB 的页能容纳的记录数本身就有硬上限(受页大小限制,通常几十到几百条),heap_no 不会无限增长。bitmap 在创建时预留的余量按这个上限来开,基本上一开始就够覆盖这个页未来所有可能出现heap_no。
-
B+ 树的页分裂机制会接手。当一个页快填满时,InnoDB 会触发页分裂,把记录分到两个页里。新插入的记录可能会被分配到新页上去,根本不会落在原页继续撑大原 bitmap。
所以"bitmap 扩容"虽然代码上要支持,但运行时几乎不触发,是一条冷路径。
把行锁组织成"Lock Object + Bitmap"这种形式,InnoDB 同时拿到了两个好处:
收益一:查询效率
锁系统最核心、最频繁的操作是:给定一条记录,判断它有没有被锁。每次访问记录前都要做这个判断。如果锁对象是按"一条记录一个对象"散开存的,每次查询都得在大量离散对象里翻找。
而以页为单位组织之后,整个查询路径变得非常清爽:用页的 (space_id, page_no) 一次哈希就能定位到这个页的所有锁信息,再用记录的 heap_no 查 bitmap 对应位即可 O(1) 命中。
收益二:内存压缩
假设一个页里 100 条记录全部被一个事务锁住:
- 朴素方案:100 个 Lock Object × 100 字节 ≈ 10 KB
- InnoDB 方案:1 个 Lock Object(约 100 字节)+ 一段 13 字节的 bitmap ≈ 113 字节
差不多压缩到了原来的 1/100。锁住的记录越多,节省比例越夸张。
可以看到,"以页为单位 + bitmap 定位" 这一个设计同时解决了两个不同维度的问题------查询的常数项和锁对象的内存占用。两个收益相互独立,又同时落到了同一个设计上,这正是这个数据结构精妙的地方。
第二步:怎么"组织"这些 Lock Object
知道了一个 Lock Object 长什么样,下一个问题是:InnoDB 内部成千上万个 Lock Object,是怎么串起来管理的?
InnoDB 的做法是维护一张全局的锁哈希表 (源码里叫 rec_hash),键是 (space_id, page_no),值是挂在这个页上的所有 Lock Object 组成的链表:

为什么哈希表的 key 用 (space_id, page_no) 这个粒度?正是因为 Lock Object 本身就以页为单位组织------同一个事务在同一张表的同一页上的所有记录锁都已经合并到一个 Lock Object 里了,所以 key 只到页粒度就够了,再细的"是哪条具体记录"由 Lock Object 自己的 bitmap 回答。
回头看刚才提到的那两个核心查询,路径就清晰了:
text
查询 1(从记录出发找事务):
记录 R
│ 取 R 所在页的 (space_id, page_no)
▼
全局锁哈希表 ──哈希定位──► 该页的 Lock Object 链表
│
│ 用 R 的 heap_no 查 bitmap
▼
判断 R 是否被锁、被谁锁
查询 2(从事务出发找所有锁):
事务对象 trx_t
│ 直接取它维护的"持有锁列表"
▼
这个事务持有的所有 Lock Object (事务提交/回滚时统一释放)
两个方向都没有任何"遍历全局所有锁"的操作,每一跳都是有限的、可预测的开销。这正是 InnoDB 在面对海量行锁时还能保持高吞吐的根本原因。
读到这里有两个相关的概念需要澄清,它们其实是同一件事的两个侧面。
第一个:锁对象是按需创建的,不是预分配的
回想一下我们一路推下来的设计:理论上一张表的每一条记录都可能被某个事务加锁------每条记录"在概念上"都对应着一把潜在的记录锁。那是不是意味着 InnoDB 启动的时候,就要为每张表的每条记录都先准备好一个 Lock Object?
显然不行。原因有两个:
- 内存根本撑不住。一张表可能有几百万条记录,一个数据库实例有几十张表,全部预分配下来光锁对象就要吃掉几个 GB------这是无法承受的浪费。
- 完全没必要。锁是用来协调"多线程并发访问"的------只有真正被访问到的数据才需要锁。一张表里大量的页可能此刻还安静地躺在磁盘上,根本没被加载进 Buffer Pool,更没有任何线程在动它们。给这些冷数据预分配锁对象,纯粹是浪费。
所以 InnoDB 的设计选择非常自然:只在真正需要的时候才创建 Lock Object。
具体来说,数据库刚启动、没有任何事务时,全局锁哈希表是空的。只有当某个事务执行 SQL 触发加锁时,InnoDB 才会去检查 (事务, 页, 锁模式) 这个三元组的 Lock Object 是否已经存在------不存在就创建,已存在就复用(前面我们已经看到,bitmap 的存在让"复用"成为可能:同一事务在同一页上的多条记录锁全部合并到同一个对象的 bitmap 里,不必每锁一条新建一个对象)。
也就是说,哈希表里只存储"目前正在被持有的锁"对应的 Lock Object------只为正在被访问的热数据保留锁信息。一张表可能有几百万个页,但如果某一时刻只有几十个页上有事务持有的锁,哈希表里就只有这几十个对象------其余的页根本不在哈希表里出现。
第二个:加锁的时机是 SQL 执行时,不是事务开始时
更进一步的问题是:事务执行的多条 SQL 各自需要锁住不同的资源------这些锁是在事务 BEGIN 时一次性全部获取,还是每条 SQL 执行到的时候各自获取?
答案是后者:
text
BEGIN;
↓ (这一刻,事务还没持有任何锁)
SQL1 → 执行到这里,才尝试加 SQL1 需要的锁
↓ (持有锁数 += SQL1 需要的)
SQL2 → 执行到这里,再尝试加 SQL2 需要的锁
↓ (持有锁数继续增长)
SQL3 → ...
↓
COMMIT/ROLLBACK → 一次性释放所有锁
↓ (持有锁数瞬间归零)
为什么不能在事务 BEGIN 时一次性把所有要用的锁都加好? 这件事不是"做了不划算",而是逻辑上根本做不到。
要实现"提前加锁",前提是事务在 BEGIN 那一刻就知道接下来所有 SQL 要锁住哪些具体的记录。但记录不是凭空就能知道的------你看一条 UPDATE student SET ... WHERE score > 90,光分析这条 SQL 的文本,能解析出操作的表、筛选的列、筛选的条件,但得不到"具体哪些记录满足 score > 90"这个答案 。要拿到这个答案,必须真正去 B+ 树里扫描索引、读出每条记录、逐一判断------而这个过程本身就是在执行这条 SQL。
也就是说:
text
要提前加锁 ──需要──► 知道要锁哪些记录
│
│ 但只有执行 SQL 才能确定
▼
必须先执行这条 SQL
│
│ 而 SQL 只能一条一条按顺序执行
▼
所以"在所有 SQL 之前提前知道一切"
本身就是矛盾
"提前"和"加锁"放在一起就是悖论------加锁需要知道目标,知道目标需要执行,执行就不是"提前"了。所以 InnoDB 的设计只能是、也必须是按需加锁------SQL 执行到哪里,锁加到哪里。
把这两点合起来看:InnoDB 锁系统是 lazy 的 ------锁对象在哈希表里按需出现,加锁动作随 SQL 执行按需触发。这种设计让锁系统的内存占用呈现出非常优雅的特性:严格正比于"当前并发持有的锁总量"------空闲时几乎不占内存,高并发时按需增长。
为什么行锁要持有到事务结束
按需加锁解决了"什么时候加"的问题------SQL 执行到哪里,锁就加到哪里。但还有另一半问题没回答:这把锁什么时候释放?
最直觉的答案是"SQL 执行完就释放呗"------这条 SQL 已经走完了,它要锁的记录都处理完了,把锁还回去让别的事务能用,多自然。但这个直觉是错的。行锁的释放时机不是 SQL 执行完,而是整个事务结束。
为什么必须这样?要从应用层的语义谈起。
事务才是真正的逻辑执行单元
我们一开始就讲过:站在应用层的视角,一组 SQL 不是若干条独立指令,而是共同完成同一个业务目标的整体。一次"学生选课"操作虽然由 SELECT、INSERT、UPDATE 三条 SQL 组成,但业务结果只能是两种状态:要么三条 SQL 全部生效(选课成功),要么三条 SQL 全部未生效(选课失败)------绝不允许出现"INSERT 生效了但 UPDATE 没生效"这种中间态。
这个"全部生效或全部未生效"的语义保证,是事务的核心承诺。而要兑现这个承诺,事务在执行过程中操作过的记录,必须在整个事务期间都对外保持稳定------别的事务不能在事务还没结束时就把这些记录改掉。
如果锁随 SQL 释放,会发生什么?
考虑这样一个事务:
sql
BEGIN;
INSERT INTO student_course VALUES (100, 'CS101'); -- SQL1: 插入选课记录 id=100
-- ...应用层做一些判断、计算...
UPDATE student_course SET status = 1 WHERE id = 100; -- SQL2: 更新这条记录的状态
COMMIT;
如果 SQL1 执行完就立刻释放它持有的行锁,那么在 SQL1 和 SQL2 之间会出现一个脏窗口 ------别的事务可以在这一刻冲进来,把刚插入的 id = 100 这条记录直接 DELETE 掉。等到 SQL2 想 UPDATE 这条记录时,目标记录已经不存在了。事务整体的语义就垮了。
text
时间线:
事务 A: BEGIN → INSERT id=100 → [锁释放] → ...判断... → UPDATE id=100
↑
在这个间隙
↓
事务 B: DELETE id=100,COMMIT
↓
id=100 已不存在
事务 A 继续: UPDATE id=100 → 找不到记录 ❌
更危险的是另一类场景------别的事务不是删了这条记录,而是修改 了它。事务 A 的 SQL1 读到记录是某个值,做完判断后准备 SQL2 修改它;中间锁释放了,事务 B 把记录改成了别的值;事务 A 的 SQL2 基于"自己之前读到的旧值"做出的修改决策,落地之后就和现实对不上了。这就是经典的读到一半被改问题。
所以行锁必须持有到事务结束
这两类问题的根本原因都是同一个:事务内的 SQL 之间存在逻辑依赖------前一条 SQL 操作过/读到过的记录,后一条 SQL 可能要继续基于这个状态做决策。锁如果按 SQL 释放,事务内的 SQL 之间就会出现脏窗口,逻辑依赖被打断,事务的原子性和一致性都会崩溃。
要消除脏窗口,唯一的办法就是让锁的持有时长和事务的生命周期完全重合------
text
SQL1 加锁 ──► 持有 ──► SQL2 加锁 ──► 持有 ──► SQL3 加锁 ──► 持有 ──► COMMIT/ROLLBACK ──► 全部释放
(持续到事务结束) (持续到事务结束) (持续到事务结束) ↑
一次性释放
事务执行期间,锁只增不减;事务结束时,所有锁一次性释放。这个机制有一个正式的名字叫两阶段锁(Two-Phase Locking, 2PL)------事务的锁生命周期严格分为"增长阶段"(事务执行中只加不放)和"收缩阶段"(事务结束时统一释放),两个阶段之间没有交叉。
和"按需加锁"合起来看
到这里,行锁的生命周期就完整了:
text
┌─────────────────────────────────────────────────────────┐
│ 加锁时机 │ SQL 执行时按需申请(不能提前,因为不知道锁哪些) │
│ 释放时机 │ 事务 COMMIT/ROLLBACK 时统一释放 │
│ 持有时长 │ 从加锁那一刻起,持续到事务结束 │
└─────────────────────────────────────────────────────────┘
按需加锁 + 持有到事务结束------这两条规则一起,让 InnoDB 的行锁既保持了高并发(不预先锁住用不到的资源),又保证了事务级别的一致性(事务期间操作过的记录在整个事务期间都不会被外部干扰)。这正是事务正确性的根基。
加锁时如何判断冲突:
到目前为止,我们一直在说 Lock Object 里有一个"锁模式"字段------它的取值就是 S(共享锁)或 X(独占锁)。这一节我们把这个坑填上:为什么需要两种模式?它们之间什么时候兼容?加锁时具体怎么做冲突检测?
为什么需要两种模式:读和写的语义不一样
数据库的访问归根结底就两类:读 和写。这两类操作的并发安全性是不对称的------
读操作不修改数据,所以多个事务同时读同一份数据是安全的------大家都只是看一眼,不会互相影响。
写操作会修改数据,所以写和写不能并发 (两个人同时改同一条记录会互相覆盖),读和写也不能共存------如果一边在写、另一边在读,读到的可能是写到一半的"中间态",破坏了业务语义。
打个比方:想象你在画一张黑板报。
- 你正在画的时候,别人不能同时也来画------两个人一起画会把彼此的内容覆盖掉
- 你正在画的时候,别人也不能凑过来看------他看到的会是一张画到一半的黑板报,不是完整作品
- 但如果黑板报已经画完了,多个人同时来看完全没问题------大家看到的都是同一张完整的画
InnoDB 的两种锁模式就是这个语义的直接落地:
text
S 锁 (Shared Lock,共享锁) ── 读操作使用
多个事务可同时持有同一记录的 S 锁(读读不互斥)
X 锁 (Exclusive Lock,独占锁) ── 写操作使用
同一时刻同一记录上只能有一个事务持有 X 锁
也不允许其他事务同时持有该记录的 S 锁
S/X 兼容矩阵
把"什么时候兼容、什么时候阻塞"列成一张表:
text
新事务想加 S 新事务想加 X
──────────────── ────────────────
已有事务持有 S ✅ 兼容 ❌ 阻塞
已有事务持有 X ❌ 阻塞 ❌ 阻塞
对应回画黑板报的类比:
text
S+S → 多个人同时看(都没在动手改),互不影响,兼容
S+X → 有人在看,这时不能动手画(否则看的人撞上中间态),阻塞
X+S → 有人在画,这时不能凑过来看(同上),阻塞
X+X → 两个人不能同时画,会互相覆盖,阻塞
只有 "S 遇到 S" 这一种组合是兼容的,其他三种全部阻塞------这就是 S/X 锁兼容矩阵的全部内容。
加锁时的冲突检测流程
把模式判断放回到我们之前讲的加锁流程里。一个事务 T 想给某条记录加锁时,完整的检测过程是这样的:
text
事务 T 想给 (页 P, heap_no = N) 加 X 锁(或 S 锁):
│
│ 用 (space_id, P) 哈希定位到桶
▼
遍历桶下所有 Lock Object,逐一检查:
┌────────────────────────────────────────────┐
│ 对每个 Lock Object L: │
│ │
│ ① L.bitmap[N] == 0 ? │
│ 是 → L 没锁住 N,继续看下一个 Lock Object │
│ 否 → 进入 ② │
│ │
│ ② L 的事务 ID == T 的事务 ID ? │
│ 是 → 是 T 自己持有的锁,继续看下一个 │
│ 否 → 进入 ③ │
│ │
│ ③ L 的锁模式和 T 想加的模式兼容 ? │
│ 是 → 不冲突,继续看下一个 │
│ 否 → 冲突! T 阻塞等待 L 持有的事务结束 │
└────────────────────────────────────────────┘
│
│ 链表遍历完一圈,没有任何 Lock Object 阻塞 T
▼
T 创建/复用自己的 Lock Object,在 bitmap 上把 bit N 置 1
加锁成功
注意这里的几个细节:
- 同一个桶下可以同时挂多个事务、多个模式的 Lock Object ------比如
[事务 A · X 锁]、[事务 B · S 锁]、[事务 C · X 锁]完全可以共存于同一个桶下,桶本身只是按页索引的入口,不代表"这一页上只能有一种模式的锁" - 冲突检测要逐个 Lock Object 检查 bitmap------只有 bitmap 上对应位为 1 的 Lock Object 才真的锁住了目标记录,其他没锁住的可以直接跳过
- 是否冲突取决于"模式之间是否兼容",不是简单地"模式是否相同"------T 想加 S 锁,遇到别人持有的 S 锁是兼容的;遇到别人持有的 X 锁就会阻塞
InnoDB 行锁对象的双向组织与等待唤醒机制
前面我们一直在说"事务对象 trx_t 和 Lock Object 是双向关联的"------事务对象里维护一个链表记录自己持有的所有 Lock Object,Lock Object 反过来记着自己属于哪个事务。这一节用简化的代码把这个关系落到具体字段上,方便后面讨论释放路径时有明确的指代。
两个核心结构体的简化定义:
cpp
// 单个 Lock Object,链表节点指针放在结构体内
struct lock_t {
trx_t* trx; // 反指: 这把锁属于哪个事务
space_id_t space_id;
page_no_t page_no;
lock_mode mode;
uint32_t flags; // 是否包含 LOCK_WAIT 标志
// 事务链表(双向): 串起同一事务持有的所有 Lock Object
lock_t* trx_locks_next;
lock_t* trx_locks_prev;
// 桶链表(双向): 串起同一桶下的所有 Lock Object
lock_t* bucket_next;
lock_t* bucket_prev;
bitmap_t bitmap[];
};
// 事务对象
struct trx_t {
trx_id_t trx_id; // 事务 ID
trx_state state; // 事务状态(ACTIVE / COMMITTED / ...)
isolation_level iso; // 隔离级别
// 关键: 这个事务持有的所有 Lock Object 链表的头指针
lock_t* trx_locks;
// ...其他字段(undo 信息、回滚段、读视图等)
};
注意一个关键设计:同一个 Lock Object 节点同时挂在两条链表上------
- 桶链表 (通过
bucket_next/bucket_prev串联):以(space_id, page_no)哈希到桶,桶下挂着这个页上所有事务的 Lock Object,用于"从记录找锁"的冲突检测 - 事务链表 (通过
trx_locks_next/trx_locks_prev串联):以事务为锚点,串起这个事务持有的所有 Lock Object,用于"从事务找所有锁"的批量释放
通过两组独立的指针字段,同一个节点既能从"页"维度被找到(哈希表查询路径),也能从"事务"维度被找到(释放路径)。
为什么两条链表都用双向?
回答这个问题要看链表上最频繁的操作是什么。无论是桶链表还是事务链表,最高频的操作都是摘除任意一个节点------事务结束时要把它持有的每个 Lock Object 从两条链表上同时摘掉。
链表的"摘除"本质上是要让前驱节点的 next 指针跳过当前节点直接指向后继。如果是单向链表,从一个节点出发没法直接拿到它的前驱,只能从链表头开始遍历找------摘除一个节点的复杂度就退化成 O(M)(M 是链表长度)。一个事务持有 N 个 Lock Object,全部释放就要花 O(N×M),性能会很难看。
而双向链表里每个节点都直接指向自己的前驱和后继,摘除任意节点都是 O(1) ------只需要修复前驱的 next 和后继的 prev。代价只是每个节点多 8 字节的 prev 指针,相对于"释放路径常数时间"这个收益,完全值得。
加锁成功时:双向挂接
cpp
// 事务 T 加锁成功时,把新创建的 Lock Object 同时挂到两条链表上
void add_lock_to_trx_and_bucket(trx_t* trx, lock_t* lock) {
// 反指事务
lock->trx = trx;
// 挂到事务自己的持有锁链表(头插)
lock->trx_locks_next = trx->trx_locks;
lock->trx_locks_prev = nullptr;
if (trx->trx_locks) {
trx->trx_locks->trx_locks_prev = lock;
}
trx->trx_locks = lock;
// 挂到桶链表(头插)
bucket_t* bucket = lock_hash.get_bucket(lock->space_id, lock->page_no);
lock->bucket_next = bucket->head;
lock->bucket_prev = nullptr;
if (bucket->head) {
bucket->head->bucket_prev = lock;
}
bucket->head = lock;
}
头插法保持了 O(1) 的挂接复杂度。
事务结束时:遍历事务链表批量释放
cpp
// 事务 COMMIT 或 ROLLBACK 时,释放它持有的所有锁
void release_all_locks(trx_t* trx) {
lock_t* lock = trx->trx_locks;
while (lock != nullptr) {
// 先记下下一个,因为当前节点马上要被销毁
lock_t* next = lock->trx_locks_next;
// ① 从桶链表摘除当前 Lock Object (双向链表 O(1) 摘除)
bucket_t* bucket = lock_hash.get_bucket(lock->space_id, lock->page_no);
if (lock->bucket_prev) {
lock->bucket_prev->bucket_next = lock->bucket_next;
} else {
// lock 是桶链表头节点
bucket->head = lock->bucket_next;
}
if (lock->bucket_next) {
lock->bucket_next->bucket_prev = lock->bucket_prev;
}
// ② 销毁前先做唤醒检查:
// 扫描桶里剩下的 LOCK_WAIT 等待者,看哪些现在可以被唤醒
wakeup_waiters_in_bucket(bucket, lock);
// ③ 销毁 Lock Object,释放内存
delete lock;
// 走到事务链表的下一个节点
lock = next;
}
// 事务链表清空
trx->trx_locks = nullptr;
}
整个过程的时间复杂度是 O(N) ,N 是这个事务持有的 Lock Object 数量------每个节点的摘除是 O(1),遍历事务链表是 O(N),总体严格线性。不需要扫描全局哈希表去找"哪些锁是这个事务的" ------这正是 trx_locks 这个反向索引存在的价值。如果没有它,事务释放时就得遍历整张哈希表的每一个桶、对每个 Lock Object 检查 lock->trx == trx,复杂度立刻退化成 O(全局锁总数)。
直观感受双向链表
把事务 A 持有 3 个 Lock Object(分别在不同页上)的场景画出来:
text
事务对象 trx_A
│
│ trx_locks
▼
Lock Obj 1 ◄══trx_locks_next/prev══► Lock Obj 2 ◄══trx_locks_next/prev══► Lock Obj 3
(页 42) (页 87) (页 153)
▲ ▲ ▲
│ bucket │ bucket │ bucket
▼ ▼ ▼
桶 42 链表中 桶 87 链表中 桶 153 链表中
(旁边还有别的 (旁边还有别的 (旁边还有别的
事务的锁对象, 事务的锁对象, 事务的锁对象,
双向串联) 双向串联) 双向串联)
每个 Lock Object 都是两条双向链表上的一个节点------横向是事务链表,纵向是桶链表。事务 A 释放时:
text
从 trx_A.trx_locks 出发,顺着 trx_locks_next 走完整条链表
每到一个节点:
- 用它的 (space_id, page_no) 找到对应的桶
- 通过 bucket_prev/bucket_next 在 O(1) 时间内把它从桶链表里摘除
- 唤醒桶里能被唤醒的等待者
- 销毁节点
走完后, trx_A.trx_locks 置为 null
事务对象自身也可以被销毁了
有了这套数据结构作为底子,下面讲"等待型 Lock Object 怎么挂、释放后怎么唤醒等待者"就有具体的字段和函数可以指代了。
上一节我们用代码把数据结构关系落实了:lock_t 通过双向链表挂在两个维度上,事务结束时沿 trx_t.trx_locks 走完整条链表批量销毁。但前面我们一直在讲的都是"加锁成功"的那条路径------遍历桶下的 Lock Object 链表做冲突检测,没有冲突就创建/复用一个 Lock Object,bitmap 上置位,事务继续往下走。而 加锁不一定每次都成功。当冲突检测发现别的事务持有了不兼容的锁时,当前事务必须阻塞等待,这条"等待"路径是怎么实现的?
等待型 Lock Object:用同一种结构表达"在等"
InnoDB 的设计选择非常巧妙------它没有为等待者额外维护一个独立的等待队列,而是让等待中的事务也创建自己的 Lock Object,只是这个对象上多了一个"在等"的标志位。
具体说,lock_t 结构体里有一个状态字段(源码里叫 LOCK_WAIT),它把 Lock Object 分成两类:
cpp
struct lock_t {
trx_id_t trx_id;
space_id_t space_id;
page_no_t page_no;
lock_mode mode;
uint32_t flags; // 关键: 是否包含 LOCK_WAIT 标志位
// 未置位 → 这是一把"已持有"的锁
// 置位 → 这是一把"在等待中"的锁
bitmap_t bitmap[];
};
也就是说,桶下挂着的链表里同时存在两类 Lock Object------已持有的和等待中的:

事务 B 加锁失败时不是被丢进某个独立的等待队列,而是自己创建了一个 LOCK_WAIT 标志置位的 Lock Object 挂到桶下,这个对象的 bitmap 标记的是 B 想锁但没拿到的那些记录。然后 B 对应的服务线程被挂起,等待被唤醒。
为什么等待和持有用同一种结构?
这种统一表示带来三个直接的好处:
- 等待顺序天然保留:链表本身就是有序的,谁先加锁失败就先挂进去,后来者只能排在已等待者之后
- 死锁检测一次扫描就能拿到完整信息:死锁检测要构造"等待图"------谁在等谁。因为持有者和等待者都在同一条链表上、用同样的结构,引擎一次遍历就能同时拿到"谁持有"和"谁在等",直接构造等待图做环检测
- 唤醒逻辑不跨结构:所有的"释放 + 检查能否唤醒"操作都在同一条链表上完成,逻辑非常清晰
加锁路径(含等待分支)
把"等待"补进我们之前的加锁流程,整个故事就完整了:
text
事务 T 想给 (页 P, heap_no = N) 加锁:
│
▼
遍历桶下所有 Lock Object,做冲突检测
│
├─ 没有冲突 ──► T 创建/复用一个 LOCK_WAIT=0 的 Lock Object
│ 在 bitmap 上置位 → 加锁成功 → T 继续执行
│
└─ 发现冲突 ──► T 创建一个 LOCK_WAIT=1 的 Lock Object
挂到同一个桶下
T 对应的服务线程被挂起
...
(等到冲突方释放锁,T 被唤醒)
...
T 重新做一次冲突检测
─ 还有新冲突 → 继续等
─ 没冲突了 → 清掉 LOCK_WAIT 标志 → T 继续执行
释放路径:销毁 + 唤醒
上一节的 release_all_locks 伪代码里,我们把释放概括成了"摘除 → 唤醒检查 → 销毁"三步,但当时没展开第二步------wakeup_waiters_in_bucket 具体在做什么?现在有了等待型 Lock Object 这个概念,我们可以把这一步讲清楚了。事务 COMMIT/ROLLBACK 时,对每个要释放的 Lock Object L:
text
事务 A 释放它持有的某个 Lock Object L (假设挂在页 P 的桶上):
│
① 把 L 从桶链表里摘除、销毁、释放内存
│
② 扫描桶链表里剩下所有 LOCK_WAIT 状态的 Lock Object:
│ 对每个等待者 W,重新做一遍冲突检测
│ ─ 还有冲突 → W 继续等
│ ─ 没冲突了 → 清掉 W 的 LOCK_WAIT 标志,唤醒 W 的线程
│
▼
继续处理 A 的下一个 Lock Object...
这里有几个值得注意的细节:
- 释放不是销毁就完了,必须顺带做"唤醒检查"------否则等待中的事务永远没人通知,会一直挂着。这是锁系统能正常运转的关键
- 不是只唤醒链表头部那一个,而是扫描整条链表 ------因为一把锁的释放可能同时解开多个兼容等待者的阻塞。比如 A 释放了页 P 的 X 锁后,B、C、D 三个事务都在等这个页,它们想加的都是 S 锁,彼此 S+S 兼容,可以全部一起被唤醒。如果只唤醒头部那一个,就浪费了"S+S 可并发"的机会
- 唤醒不代表加锁成功------W 醒来后还会再做一次完整的冲突检测,万一等待期间桶里又挂上了别的事务的锁,W 可能要继续等
- 释放只动 A 自己的 Lock Object------桶里其他事务的对象(B/C/D 各自的 Lock Object)不受影响,A 的释放只是让它们旁边的"邻居"少了一个
为什么释放方式是销毁整个对象、而不是把 bitmap 里的 bit 清 0 保留对象?因为事务都结束了,"事务 A · 页 P · X 锁" 这个三元组本身在概念上已经不存在了。即使后续有新事务(哪怕事务 ID 看起来相似)来加锁,那也是另一个事务,需要新的 Lock Object------A 留下的空壳没有任何复用价值,留着只会拖慢后续的冲突检测遍历。
整个生命周期闭环
到这里,一把行锁从诞生到消亡的完整轨迹就拼齐了:
text
加锁路径
├─ 没冲突 → 创建/复用 Lock Object (LOCK_WAIT=0) → 事务继续
└─ 有冲突 → 创建 Lock Object (LOCK_WAIT=1) → 线程挂起等待
事务结束(COMMIT/ROLLBACK)
└─ 沿 trx_t 反向索引找到所有持有的 Lock Object
└─ 对每个对象:
├─ 从桶链表摘除、销毁
└─ 唤醒检查: 扫描桶里 LOCK_WAIT 的等待者
├─ 还有冲突 → 继续等
└─ 没冲突 → 清 LOCK_WAIT、唤醒线程
→ 等待者醒来,回到加锁路径重新检测
事务执行期间不断在桶里挂自己的 Lock Object(已持有的或等待中的),事务结束时把这些对象逐一摘除,并顺手把因这些对象阻塞的等待者唤醒
InnoDB 行锁的逻辑语义:锁模式、锁类型(Record Lock,Gap Lock,Next-Key Lock)与 Lock Object 落地
在探究了全局锁哈希表和 Lock Bitmap 如何以极低的内存开销管理海量锁对象之后,我们已经从物理存储层面彻底打通了 InnoDB 的加锁机制。但是,锁作为并发控制的核心工具,仅仅有物理存储是不够的,我们还需要定义它的逻辑行为 ------具体而言,一把行锁要回答两件事:它和别人冲突吗?它锁住了多大范围?
InnoDB 把这两件事拆分成两个独立维度:锁模式(Lock Mode) 和 锁类型(Lock Type) 。这两个维度是互相独立的------任何一把行锁都同时具有模式和类型两个属性,可以自由组合。比如"X 锁的 Record Lock"就是给单条记录加独占锁,"S 锁的 Gap Lock"就是给某段间隙加共享性的间隙锁。
下面分别看这两个维度。
1. 锁模式(Mode):读与写的冲突规则
锁模式定义了不同事务在并发访问同一份资源时,能否"和平共处"。主要分为最基础的两种:
- 共享锁(Shared Lock,简称 S 锁):即读锁。当事务 A 对资源加上 S 锁后,允许其他事务继续对该资源加 S 锁,但不允许加 X 锁。所谓"读读共享"。
- 独占锁(Exclusive Lock,简称 X 锁):即写锁。当事务 A 对资源加上 X 锁后,其他事务无论想加 S 锁还是 X 锁,都必须阻塞等待。所谓"写写互斥,读写互斥"。
这两种模式的兼容矩阵我们前面已经画过了,这里不再展开。
2. 锁类型(Type):锁住的具体范围
如果说"锁模式"是基本功,那么"锁类型"就是 InnoDB 在标准行锁之外的精细化设计。读者可能会觉得奇怪:既然叫"行锁",锁住的不就是那一行记录吗,还有什么"类型"可言?
这正是关系型数据库设计中的精妙之处。在实际业务中,我们不仅要防止'已有的记录被别人改掉',还要防止'别人在我们查过的范围里插入新记录'。
举个具体例子。某门课程的成绩录入业务里,事务 A 想统计"分数大于 90 的学生有几个,然后给每人发一封邮件":
text
时间线:
事务 A: SELECT * FROM student WHERE score > 90 → 查到 5 条
...准备循环遍历这 5 条发邮件...
↓
事务 B: INSERT 一条 score=95 的新记录, COMMIT
↓
事务 A: SELECT * FROM student WHERE score > 90 → 这次查到 6 条
同一个事务里,前后两次执行同一条 SQL 查到的记录数不一样------多出来的那一条是事务 B 在中间偷偷插进来的。事务 A 拿到的"分数大于 90 的学生集合"在事务期间不稳定,业务逻辑无法依赖这个结果做决策。
为了应对这两类需求,InnoDB 根据锁定的具体范围,把行锁衍生出三种类型。注意一个重要前提:InnoDB 的所有行锁都加在索引上,不是加在数据本身上------所以下面说的"记录"、"间隙"指的都是索引上的位置:
- Record Lock(记录锁):最基础的行锁,仅锁定索引上的单条记录
Gap Lock(间隙锁):不锁记录本身,而是锁定索引上两条相邻记录之间的"间隙",禁止其他事务在这个间隙里插入新记录。
这里的"间隙"听起来抽象,其实就是索引上两条相邻记录之间那段"还没有人占用的键值范围"。比如某个索引上现在有三条记录,键值分别是 10、20、30:
text
索引上的记录: 10 20 30
● ● ●
◄─────► ◄─────► ◄────► ◄────►
间隙 1 间隙 2 间隙 3 间隙 4
(-∞,10) (10,20) (20,30) (30,+∞)
索引上不只有三条记录,记录之间和两端还存在四段"键值真空"------任何键值落在这些区间里的新记录都还没出现,但未来任何时候都可能被插入进来。Gap Lock 锁的就是这些真空区间。
举个具体例子。假设事务 A 锁住了"间隙 2" (10, 20),那么:
sql
-- ❌ 别的事务想在 (10, 20) 之间插入,会被阻塞
INSERT INTO t VALUES (15, ...); -- 阻塞,15 落在 (10, 20) 区间内
INSERT INTO t VALUES (17, ...); -- 阻塞,17 落在 (10, 20) 区间内
-- ✅ 但边界上的记录是不受影响的
UPDATE t SET ... WHERE id = 10; -- 正常执行,Gap Lock 不锁记录本身
DELETE FROM t WHERE id = 10; -- 正常执行
UPDATE t SET ... WHERE id = 20; -- 正常执行
DELETE FROM t WHERE id = 20; -- 正常执行
-- ✅ 区间外的记录也不受影响
INSERT INTO t VALUES (25, ...); -- 正常执行,落在间隙 3
DELETE FROM t WHERE id = 30; -- 正常执行
关键约束:Gap Lock 必须严格锁"相邻记录之间"
读到这里有一个非常重要的概念需要澄清:Gap Lock 锁住的"间隙"必须是索引上两条相邻记录之间的真空地带,不能跨越任何已有记录。
这听起来像是废话,但很多人初学时会下意识地把 Gap Lock 等同于"任意键值范围锁"------比如直觉上以为可以"用一把 Gap Lock 锁住 (10, 30) 这一段"。这是错的。
把"间隙"和"范围"这两个概念严格分开:
text
键值范围 (range): 任意指定的一段键值区间,可以横跨任意多条记录
这是"逻辑上的"区间,是一个上层概念
间隙 (gap): 索引上两条相邻记录之间的物理真空地带
由索引当前状态决定,必然不包含任何记录
这是 Gap Lock 唯一能锁的对象
回到刚才那个索引(10、20、30),如果"想锁住 (10, 30) 这段范围"------不存在这样一把 Gap Lock ,因为 20 这条记录把这段范围一切两半,物理上的间隙只有 (10, 20) 和 (20, 30) 两段独立的真空,中间被记录 20 隔开了。
那"锁住一段范围"在 InnoDB 里是怎么实现的?
考虑这条 SQL:
sql
UPDATE t SET status = 1 WHERE id BETWEEN 100 AND 200;
事务想保护"键值在 [100, 200] 这段范围"------既要锁住已存在的所有记录(防止别人修改它们),也要锁住中间所有的真空(防止别人在范围内插入新记录)。假设索引上当前实际存在的记录是 100、120、150、180、200 五条,那么 InnoDB 实际加的锁是:
text
范围 [100, 200] 被分解成:
Record Lock (100) ← 锁住记录 100
Gap Lock (100, 120) ← 锁住 100 和 120 之间的真空
Record Lock (120) ← 锁住记录 120
Gap Lock (120, 150) ← 锁住 120 和 150 之间的真空
Record Lock (150) ← 锁住记录 150
Gap Lock (150, 180) ← 锁住 150 和 180 之间的真空
Record Lock (180) ← 锁住记录 180
Gap Lock (180, 200) ← 锁住 180 和 200 之间的真空
Record Lock (200) ← 锁住记录 200
一段"范围"被拆成了 5 个 Record Lock + 4 个 Gap Lock ------每个 Record Lock 守一条已有记录,每个 Gap Lock 守一段相邻真空。InnoDB 没有"一把锁直接锁住整段范围"这种东西,所有的"范围保护"都是用 Record Lock 和 Gap Lock 拼接出来的。
这也是为什么 InnoDB 把 Record Lock 和 Gap Lock 打包成了 Next-Key Lock------它就是这种"拼接"的天然单元,把"一条记录 + 它前面的一段间隙"作为基本砖块,多个 Next-Key Lock 串起来就能保护任意范围。
text
[Next-Key (120)] + [Next-Key (150)] + [Next-Key (180)] + ...
↓ ↓ ↓
锁记录 120 锁记录 150 锁记录 180
+ (100, 120) 间隙 + (120, 150) 间隙 + (150, 180) 间隙
所以 Gap Lock 就是 InnoDB 用来保护范围的"砖块"之一------本身只能砌一小段(两条相邻记录之间的真空),但和 Record Lock 配合起来能砌出任意大的范围。这正是 Gap Lock 单独存在的意义:它是个原子单元,不是一个"通用范围锁"。
几个收束要点
- Gap Lock 锁的是"位置"而不是"记录"------区间
(10, 20)里现在没有任何记录,所以它只能阻止"插入",没有"修改/删除"可言(连记录都没有,谈不上改) - 边界上的记录 10 和 20 本身不在 Gap Lock 的保护范围内------区间是开区间
(10, 20),两端的记录还需要 Record Lock 才能保护 - Gap Lock 锁的间隙是开区间
(10, 20)------和 Next-Key Lock 的左开右闭(10, 20]形成对照,差别就在那个右端点 20 是否被锁
这也是 Gap Lock 和 Record Lock 最根本的区别:Record Lock 锁的是已经存在的具体记录,Gap Lock 锁的是"未来可能会有记录但现在还空着"的位置。两者各司其职------Record Lock 守"现在的记录",Gap Lock 守"未来的位置"------而 Next-Key Lock 把两者合二为一,成为 InnoDB 行锁的默认形态。
- Next-Key Lock(临键锁) :
Record Lock + Gap Lock的组合体。锁定一条索引记录本身,同时锁定该记录前面的间隙,形成一个左开右闭 的保护区间。例如索引上有相邻记录键值10和20,加在记录20上的 Next-Key Lock 锁住的是(10, 20]------既包含20这条记录,也包含它前面那段间隙
Next-Key Lock 是 InnoDB 在 REPEATABLE READ 隔离级别下默认使用的行锁形态------这也是为什么 InnoDB 默认能避免幻读,而很多别的数据库不能。具体的隔离级别和这三种锁的对应关系,我们会在后面专门的章节里展开。
模式与类型在 Lock Object 中的落地
我们前面讲了行锁的两个独立维度------模式 (S/X)和类型(Record/Gap/Next-Key)。但这两个抽象概念到了 Lock Object 这个具体的数据结构里,是怎么承载的?bitmap 的语义又会不会因锁类型变化而变化?
type_mode:一个位字段编码两个维度
之前我们给出的简化版 lock_t 结构体里,把锁的元信息塞进了一个 mode 字段------那是为了行文简洁。InnoDB 实际的设计更精巧:模式、类型、状态全部打包在一个 32 位的位字段 type_mode 里,用不同的位段表达不同的属性:
cpp
struct lock_t {
trx_t* trx;
space_id_t space_id;
page_no_t page_no;
uint32_t type_mode; // 关键: 一个位字段,同时编码"模式"+"类型"+"状态"
//
// 低位段: 锁模式
// LOCK_S → 共享锁
// LOCK_X → 独占锁
//
// 高位段: 锁类型 + 状态(可通过位或组合)
// LOCK_REC_NOT_GAP → Record Lock
// LOCK_GAP → Gap Lock
// (REC_NOT_GAP 和 GAP 都不置位) → Next-Key Lock
// LOCK_WAIT → 等待中
// ...其他状态标志
lock_t* trx_locks_next;
lock_t* trx_locks_prev;
lock_t* bucket_next;
lock_t* bucket_prev;
bitmap_t bitmap[];
};
InnoDB 通过位运算来读写这些属性:
cpp
// 判断模式
if ((lock->type_mode & LOCK_MODE_MASK) == LOCK_X) {
// 这是一把 X 锁
}
// 判断类型
if (lock->type_mode & LOCK_REC_NOT_GAP) {
// 这是一把 Record Lock
} else if (lock->type_mode & LOCK_GAP) {
// 这是一把 Gap Lock
} else {
// 默认: Next-Key Lock
}
// 判断状态
if (lock->type_mode & LOCK_WAIT) {
// 这是一把等待中的锁
}
为什么用一个位字段而不是几个独立字段?省内存 + 取值原子 。32 位的 type_mode 一次内存读写就能拿到所有元信息,不用拷贝多个字段;位运算判断属性也比多次比较快得多。这是高频访问的数据结构常用的优化手段。
bitmap 的语义随锁类型变化
这里是真正有意思的地方。bitmap 的物理布局在所有锁类型下都一样------每个 bit 对应页内一个 heap_no ------但 bit 被置 1 时所表达的"语义含义"会随 type_mode 里的类型位变化。
具体说:
text
对于 Lock Object 里 bitmap[i] = 1 这个事实:
如果 type_mode 表明这是 Record Lock:
→ 含义: heap_no = i 这条记录本身被锁住
如果 type_mode 表明这是 Gap Lock:
→ 含义: heap_no = i 这条记录"前面那段间隙"被锁住
(注意:不是 i 这条记录本身,而是 i 之前的间隙)
如果 type_mode 表明这是 Next-Key Lock:
→ 含义: heap_no = i 这条记录本身 + 它前面的间隙都被锁住
(Record + Gap 的合并语义)
也就是说------bit 的"索引含义"(指向哪个 heap_no)是固定的,但"语义解读"取决于这个 Lock Object 整体的 type_mode。
举个具体例子。假设页内有索引记录 heap_no=2 (键值=10)、heap_no=3 (键值=20)、heap_no=4 (键值=30),三种锁类型下"在 bit3 上置 1"的含义分别是:
text
情形 A: Lock Object [type_mode = LOCK_X | LOCK_REC_NOT_GAP] bitmap: bit3=1
→ Record Lock,锁住键值=20 这条记录本身
→ 别的事务想修改/删除键值=20 会被阻塞
→ 但别的事务可以在 (10,20) 或 (20,30) 之间插入新记录
情形 B: Lock Object [type_mode = LOCK_X | LOCK_GAP] bitmap: bit3=1
→ Gap Lock,锁住"键值=20 这条记录前面的间隙",也就是 (10, 20) 这个开区间
→ 别的事务想在 (10, 20) 之间插入新记录(比如键值=15)会被阻塞
→ 但别的事务可以正常修改/删除键值=20 这条记录本身
情形 C: Lock Object [type_mode = LOCK_X] bitmap: bit3=1
→ 没有显式的 REC_NOT_GAP 也没有 GAP 标志,默认是 Next-Key Lock
→ 锁住键值=20 这条记录 + 它前面的间隙,也就是 (10, 20] 这个左开右闭区间
Next-Key Lock 的左开右闭区间是 bitmap 物理布局的自然结果 ------这是一个值得停下来体会的细节。bitmap 的每个 bit 是按 heap_no 索引的,而每个 bit 表达"这条记录 + 它前面的间隙",自然就形成了 (前一条记录键值, 当前记录键值] 这个左开右闭的形态。Next-Key Lock 的区间形态不是设计者刻意选的,是 bitmap 的物理布局衍生出来的几何性质------这正是这个数据结构精妙的地方。
合并规则的修正
之前讲 Lock Object 复用规则时,我们说合并条件是"(事务, 页, 锁模式) 三元组完全相同"。现在引入了"锁类型"这个维度,严格说合并条件应该是 (事务, 页, type_mode)------锁类型也是合并条件的一部分。
具体说:
text
✅ 可以合并到同一个 Lock Object:
事务 A 在页 P 上加 X 锁 + Record Lock 给 hno=2
事务 A 在页 P 上加 X 锁 + Record Lock 给 hno=4
→ (A, P, LOCK_X | LOCK_REC_NOT_GAP) 完全相同
→ 合并到一个对象,bitmap 上 bit2 和 bit4 都置 1
❌ 不能合并,必须新建对象:
事务 A 在页 P 上加 X 锁 + Record Lock 给 hno=2
事务 A 在页 P 上加 X 锁 + Gap Lock 给 hno=4
→ 模式相同(都是 X),但类型不同(REC_NOT_GAP vs GAP)
→ 必须创建两个独立的 Lock Object
这意味着事务 A 在同一页上可能同时挂着好几个 Lock Object ,分别对应不同的 (模式, 类型) 组合:

每个对象的 bitmap 索引规则都一样(按 heap_no),但 bit=1 的语义解读取决于这个对象的 type_mode。
收束
到这里,我们就把"模式 vs 类型"这两个抽象维度完整地落到了 Lock Object 的具体字段上:
text
模式 + 类型 → type_mode (一个 32 位位字段)
锁了哪些 heap_no → bitmap (语义解读由 type_mode 决定)
属于哪个事务 → trx 反向指针
挂在哪条桶/事务链表上 → bucket_next/prev + trx_locks_next/prev
InnoDB 用一个非常紧凑的数据结构,同时表达了"哪个事务"、"哪种模式"、"哪种类型"、"锁住了哪些记录或间隙"四件事------而 bitmap 这个最简单的数据结构在不同 type_mode 下展现出三种完全不同的语义,正是这个设计最巧妙的地方。
我们从"为什么需要锁"一路推到现在,已经把 InnoDB 行锁系统的每一块拼图都讨论过了------全局哈希表、Lock Object 的字段布局、bitmap 的语义、Page Latch 与 Row Lock 的分工、按需加锁、持有到事务结束、等待型 Lock Object、双向链表、模式(S/X)和类型(Record/Gap/Next-Key)的自由组合......每一块单独看都不复杂,但它们组合起来才是这个系统真正的样子。
下面这张全景图把所有结构同时画在一张图里,作为这一整章的总收束。建议你对着图回顾一遍,看每一个组件在你脑海里能不能立刻还原出"它解决了什么问题、为什么这么设计"。

Page Latch 的存储管理:和 Row Lock 完全相反的设计哲学
在这一章的开头,我们引入了 Page Latch 和 Row Lock 这两套锁机制------前者保护页的物理结构,后者保护记录的事务级访问。后面我们花了大篇幅讲 Row Lock 的存储管理(全局哈希表、Lock Object、bitmap、按需创建......),但 Page Latch 这一边一直留着个黑盒:它存在哪?也和 Row Lock 一样按需创建吗?
这一节就来填这个坑。先说结论:Page Latch 的存储设计和 Row Lock 完全相反------它不是放在某个全局结构里、按需创建的,而是直接内嵌在每个内存页的控制块里、随页一起预分配。
Page Latch 存在哪:内嵌在 buf_block_t 里
我们之前讲过,InnoDB 的所有读写都发生在 Buffer Pool 中的内存页上。Buffer Pool 里每个加载到内存的数据页都对应一个 buf_block_t 结构体(页控制块),它记录这个页的元信息------页号、表空间 ID、是不是脏页、LRU 链表指针等等。Page Latch 就是这个控制块里的一个字段,物理上和这个页相邻:
cpp
struct buf_block_t {
space_id_t space_id;
page_no_t page_no;
byte* frame; // 指向真正的 16KB 页数据
bool is_dirty; // 是不是脏页
lsn_t newest_lsn; // 这个页最新修改的日志序号
// ...LRU 链表指针、刷盘相关字段...
rw_lock_t page_latch; // ★ 关键: Page Latch 直接内嵌在这里
// 一个读写锁结构,保护这个页的物理结构
};
也就是说,每个页和它的 Page Latch 是一体的 ------页控制块在哪,Page Latch 就在哪,两者位于同一段连续内存中 。线程要访问某个页,只要拿到这个页对应的 buf_block_t 指针,就能直接通过 block->page_latch 一次指针解引用拿到 latch------零查找开销。
预分配还是按需?要分两个角度看
回到你心里那个"按需还是预分配"的问题,要先理解 Buffer Pool 本身的工作方式。
Buffer Pool 的内存是预先分配 的------MySQL 启动时就划出一块固定大小(由 innodb_buffer_pool_size 参数决定,通常几 GB 到几十 GB)的连续内存,里面预先创建好一堆 buf_block_t(控制块)和与之对应的 16 KB 页帧空间。这些 buf_block_t 一开始都是空的,等待被使用。
所以从两个角度看:
-
从 Page Latch 这个对象本身看:是预分配的 。因为 Page Latch 内嵌在
buf_block_t里,而buf_block_t在 Buffer Pool 启动时就预分配好了------Page Latch 的内存随启动一起就位,不会在运行时动态创建/销毁。 -
从"哪些页有有效的 Page Latch"看:是按需的 。磁盘上的某个页只有被加载到 Buffer Pool 后,它对应的
buf_block_t才被激活、它的 Page Latch 才开始服务于这个页。
text
磁盘上的某个页 P:
│
│ 一开始没被任何线程访问 → 不在 Buffer Pool 里
│ 也没有任何 buf_block_t 与它绑定
│
▼
某个线程要访问页 P
│
│ Buffer Pool 从空闲池里挑一个 buf_block_t
│ 把页 P 从磁盘加载到这个 buf_block_t 的 frame 里
│ 这个 buf_block_t 的 page_latch 字段开始为页 P 服务
│
▼
页 P 现在在 Buffer Pool 里 → 它有了一把"激活"的 Page Latch
这把 latch 跟着 buf_block_t 走,直到 buf_block_t 被淘汰出去
也就是说------Page Latch 跟着 buf_block_t 走,buf_block_t 又跟着 Buffer Pool 的页分配策略走 。从语义上看,"页 P 的 Page Latch"在页 P 被加载到内存时才"激活",被淘汰出 Buffer Pool 时就"失效"------这种绑定到内存页生命周期的形态,就是 Page Latch 的"按需"。
对比 Row Lock:两种完全相反的设计哲学
把 Row Lock 和 Page Latch 的存储管理放在一起看,会出现一种非常鲜明的对照:
text
Row Lock Page Latch
───────────── ──────────────
存储位置 全局哈希表 rec_hash 内嵌在 buf_block_t 里
(独立的中央数据结构) (跟着内存页走)
内存来源 运行时按需 malloc Buffer Pool 启动时预分配
(作为 buf_block_t 的字段)
数量上限 理论无上限 上限 = Buffer Pool 中
(取决于活跃事务持有量) 可容纳的页控制块数量
生命周期 事务持有期间存在 和 buf_block_t 绑定
事务结束时销毁 页加载/淘汰时激活/失效
获取一把锁的开销 哈希查找 + 链表遍历 一次指针解引用
+ bitmap 位运算
为什么两者的设计哲学完全相反? 答案藏在它们各自的访问模式里:
-
Row Lock 是稀疏的 :在任意时刻,整个数据库里"被加了行锁的记录"是极小一部分------绝大多数记录此刻没被任何事务锁着。如果给每条记录都预分配锁对象,几乎全是浪费。所以用全局哈希表 + 按需创建,让锁对象的存在数量正比于"实际有锁的记录数"。优化的核心目标是内存占用。
-
Page Latch 是稠密的 :Page Latch 的访问粒度跟着 Buffer Pool 走------只要这个页在 Buffer Pool 里,几乎每次访问页都要拿一下 latch。访问频率极高。如果每次都要去全局哈希表里查找"这个页的 latch 对象在哪",开销会让数据库性能塌掉。所以直接把 latch 嵌进页控制块里,一次指针解引用就能拿到 。优化的核心目标是访问速度。
换句话说------Row Lock 优化的是"内存占用"(按需分配,不为冷数据浪费一字节),Page Latch 优化的是"访问速度"(直接内嵌,零查找开销)。两种设计选择都是被各自的工作负载"逼"出来的,没有谁对谁错------稀疏的资源用按需分配,稠密的资源用预分配 + 内嵌,这是两种完全合理但完全相反的设计哲学。
到这里,本章一开始抛出的"Page Latch 和 Row Lock 两套并行机制"这条主线就完整闭环了------从功能分工(保护对象、持有时长),到存储管理(内嵌 vs 哈希表),InnoDB 在所有维度上都把它们彻底分开,让两套机制各自服务最适合的工作负载。
事务原子性的实现:Undo Log 的产生、存储与空间分配
从锁机制回到事务本身:ACID 四大特性的引出
前面我们花了大篇幅深入 InnoDB 的锁机制底层,把 Page Latch、Row Lock、Lock Bitmap、双向链表这套并发控制的兵器库摸清楚了。但回到这一章一开始抛出的问题------锁只能解决多线程并发踩踏,解决不了单线程半路崩溃,所以我们需要一种比锁更高层的抽象:事务。
锁是兵器,事务是兵法 。有了兵器还不够,我们还得回答一个核心问题:一个合格的事务,究竟要达成什么样的终极目标?
在关系型数据库的世界里,前人们将这个目标高度概括为四个专业术语,合称为 ACID。它们构成了事务安全运转的四大特性:
- A - 原子性(Atomicity):同生共死
"原子"在物理学中曾被认为是不可分割的最小微粒。事务的原子性,就是指把一个业务需求包含的一组 SQL 语句,看作是一个不可分割的整体。
- 目标 :这组操作要么全部成功执行,要么全部失败回滚。绝不允许出现"钱扣了,商品没发货"这种执行到一半的尴尬"中间态"。
- 实现机制 :如果事务执行到一半失败(比如主动 ROLLBACK、连接断开、SQL 报错),MySQL 会依靠底层的 Undo Log(回滚日志) 把已经做过的一半操作撤销掉。
- I - 隔离性(Isolation):高并发下的护城河
这就是我们刚才花大量篇幅学习"锁机制"的真正用武之地。
- 目标:当多个事务并发访问同一个数据库表时,它们之间不能互相干扰。每个事务在执行期间,都应该感觉自己是"独占"这个数据库的。
- 实现机制 :为了不让并发事务互相踩踏(避免脏读、不可重复读、幻读),InnoDB 正是通过刚才提到的各种 锁(Record Lock、Gap Lock 等) ,再配合上大名鼎鼎的 MVCC(多版本并发控制) 来构筑这道护城河的。
- D - 持久性(Durability):落子无悔
- 目标 :只要一个事务宣布自己完成了(
COMMIT成功),那么它对数据库所做的修改就是永久性的。哪怕下一秒机房断电、服务器主板烧毁,只要硬盘还在,数据就绝不会丢失。 - 实现机制 :磁盘 I/O 那么慢,怎么保证断电不丢数据?这要归功于 MySQL 底层的 Redo Log(重做日志) 机制------事务提交时先把"我改了什么"以日志形式追加写入 redo log 并刷盘,真正的数据页可以稍后再慢慢刷。即使崩溃,重启后按 redo log 重放就能恢复出已提交事务的修改。
- C - 一致性(Consistency):最终目标
- 目标:数据库在事务执行前后,都必须处于一种"逻辑上正确"的状态。比如转账前后两个账户的总额必须保持不变;又比如选课场景,课程剩余人数绝对不能变成负数。
- 定位 :如果说原子性、隔离性、持久性是数据库底层提供的技术手段 ,那么一致性就是我们最终要达成的业务目的------A、I、D 三兄弟齐心协力,最终都是为了保卫 C。
但需要特别注意一点:一致性不仅依赖数据库的 A、I、D 三个特性,也依赖应用层把业务规则正确地写进事务里。引擎能保证"你写出的事务被原子地、隔离地、持久地执行",但不能保证"你写得对"------比如转账事务里如果你只写了扣钱、忘了加钱,那 ACID 再完美也救不了业务一致性。一致性是数据库和应用层共同的责任。
从原子性到 Undo Log:事务回滚机制的引出
接下来的内容我们将围绕事务是如何实现 A、I、D 这三个性质展开。首先就是原子性,而原子性的含义我们已经十分明确了,就是一组 SQL 语句要么全部执行,要么全部都不执行。
要做到这个原子性单单靠锁机制是不够的------锁机制只能保证多线程并发互斥访问、让数据状态在事务执行期间稳定。但如果这里一旦发生连接断开等异常,事务只执行到一半,之前执行的 SQL 语句已经生效,那就得想办法把这些已经生效的 SQL 语句撤销掉。而要完成这个撤销机制,就和 undo log 有关了。
对"执行一条 SQL"流程的进一步完善
要理解 undo log,我们需要先完善对"执行一条 SQL 语句底层行为"的认识。此前我们知道,执行一条修改性 SQL,引擎会按需申请锁------创建或者复用访问的记录对应的行锁对象,加锁成功之后才能执行 SQL。但其实在加锁和真正修改之间,还有一步:
text
执行一条修改性 SQL (以 UPDATE 为例):
① 找到目标记录 (B+ 树查找)
② 给目标记录加 X 锁
③ 在 undo page 里写入一条 undo record ← ★ 这一步
记下"这条记录原来的样子",用于将来回滚
④ 真正修改记录
也就是在真正修改记录之前,引擎会先在 undo page 里写入一条 undo record,记下"这条记录原来的样子"。这个时序很重要------必须先把"老值"备份下来,才能放心地去改。如果先改后写 undo,万一写 undo 失败,老值就丢了,回不去了。
读者可能突然看到冒出来的 undo record 以及 undo page 这两个专业术语有点蒙圈,我们慢慢来。
undo record 是什么:逆向操作的记录
我们先来认识 undo record 究竟是什么。事务是一组 SQL 语句的集合,而我们无法避免一组事务只执行了部分 SQL 语句的情况------一旦出现这种情况,我们就得回滚到事务执行之前的初始状态。
如何回滚?有这样一个直观思路:我们是按顺序执行 SQL 语句,这些 SQL 对记录的影响也是按顺序叠加的,所以回滚就按照逆序、执行这些 SQL 的逆向操作。
注意这里讨论的范围------SQL 语句包含 SELECT、INSERT、UPDATE、DELETE 四类,但 SELECT 是读操作,对数据没有造成任何影响,自然没什么"逆操作"可言。真正需要逆向执行的是修改性的 SQL,也就是 INSERT、UPDATE、DELETE。
来看每种修改操作的逆向:
-
UPDATE:UPDATE 的语义是给某条记录的某个字段赋一个新值。比如:
sqlUPDATE student SET age = 21 WHERE id = 100;把 id=100 这条记录的 age 字段改成了 21。它的逆向操作就是记下这个字段的旧值(比如 age=20),将来回滚时把 age 恢复成 20 即可。
-
INSERT :INSERT 插入了一条新记录。它的逆向操作就是 DELETE 这条新插入的记录------记下被插入记录的主键,将来回滚时按这个主键删除。
-
DELETE :DELETE 在 InnoDB 里其实不是物理删除,而是把记录头的
delete_mark标志位置 1,记录的数据内容原封不动还留在页里。所以它的逆向操作非常轻量------只要把这个标志位重新置回 0,记录就"复活"了,根本不需要重新 INSERT 。undo record里只需要记下"被删记录的位置",回滚时定位到那条记录、清掉标志位即可。
而这些产生的"逆向操作描述",本质上就是 undo record------它记录了"如果要撤销刚才那个修改,应该怎么做"。
undo record 存在哪:undo page
既然 undo record 是数据,那必然需要物理空间来存储。而对于 MySQL,我们再熟悉不过了------MySQL 存储任何数据的基本存储单元就是 16 KB 大小的页。所以事务执行修改性 SQL 产生的 undo record,就落在 undo page 当中。
undo log:一个逻辑上的集合
只要事务在执行过程中执行了修改性的 SQL 语句,就必然会产生 undo record。而前面提到的 undo log,其实是一个逻辑上的概念 ------它的含义是这个事务执行过程中所有修改性 SQL 产生的 undo record 的集合。随着事务中修改性 SQL 不断执行,这个集合会持续增长。
刚才特意强调 undo log 是一个"逻辑概念",是因为它只是把一个事务产生的所有 undo record 归为一类------至于底层是如何实现存储 undo log 的(这些 undo record 实际怎么排布在 undo page 里、不同事务的 undo 怎么区分、提交后怎么清理)......我们后文马上会展开。
先描述,再组织
我们知道 MySQL 是一个客户端-服务器架构的网络服务进程,会与大量客户端建立连接,每个连接都可能产生事务------意味着 MySQL 内部存在大量的 undo log 同时存在,它需要把这些 undo log 管理起来。而管理的方式我们再熟悉不过了,先描述,再组织------下一节我们就来看 InnoDB 是怎么把这些 undo log 落地成具体的物理存储结构的。
Undo Log 如何落盘:从 Undo Page 到 Rollback Segment
而在此前,我们学习过 InnoDB 下存储表的数据是在一个 .ibd 文件当中,而这个文件的内容就是一个线性的页数组------数组中的每个元素是固定 16 KB 大小的页。这些页的用途各不相同,有的承载 B+ 树的叶子节点内容,有的承载 B+ 树的内部节点内容,有的则是管理页。
undo page 也是 16 KB 大小的页,只是承载的内容变了------它不再承载业务数据,而是承载 undo record。
undo page 存在哪:专属的 undo 表空间
不管是哪个事务产生的 undo record 所在的 undo page,它们都被统一存放在专门用于存储 undo 数据的表空间里------和存储业务数据的表空间是相互独立的两套。
为了方便理解,我们这里给出一个极简模型 :undo 表空间内部的开头是若干管理页 ,剩下的都是真正存储 undo record 的 undo page。
注:InnoDB 实际的内部组织更精细(涉及 rollback segment、undo segment 等多层结构),主要是为了支持高并发下多个事务同时分配 undo。这些细节不影响你理解 undo log 的核心思想,本节先按上面的极简模型来。
undo page 内部结构:和索引页相比少了什么、多了什么
之前我们学过索引页的内部结构------一个页由 7 个部分组成:File Header、Page Header、Infimum、Supremum、User Records、Free Space、Page Directory、File Trailer。
undo page 也是页,但内部结构和索引页不完全相同:
text
索引页: File Header + Page Header + Infimum + Supremum
+ User Records + Free Space + Page Directory + File Trailer
undo page: File Header + Page Header + Undo Page Header
+ Undo Records 区域 + Free Space + File Trailer
↑ ↑
多了一个 没了 Page Directory
Undo Page Header
两个关键差别:
- 多了 Undo Page Header:记录这一页特有的元信息------比如它存的是哪类 undo(INSERT 类 / UPDATE 类)、当前 undo record 写到了哪个偏移量等。这些信息在后面讲 undo log 怎么串联管理时会用到。
- 没有了 Page Directory :索引页里的 Page Directory 是为了支持按键值二分查找,快速定位到目标记录。但 undo page 不存在这种"按键值查找"的需求------回滚时是按写入顺序逆序遍历,根本用不到二分。
undo record 在页内是按写入顺序排列的
存储 undo record 的区域里,多条 undo record 按写入顺序排列------越靠近开头的越先写入、越靠近尾部的越后写入。这种顺序排列正好对应回滚的需求:
text
事务执行过程中,undo record 持续追加写入:
写入方向 ──────────────────────────────►
undo record 1 ← 第一条写入(对应事务里第一个修改性 SQL)
undo record 2
undo record 3
undo record 4 ← 最后写入(对应事务里最后一个修改性 SQL)
回滚时,从最后一条往前逆序遍历:
回滚方向 ◄──────────────────────────────
从 undo record 4 开始,按反方向走完整条记录
正好就是"先撤销最后做的修改,最后撤销最先做的修改"
也就是说------undo page 不需要 Page Directory,是因为它的访问模式是顺序遍历,不是随机查找。这种"用途决定结构"的设计哲学,和我们前面看到的索引页(为了支持 B+ 树查找而设计 Page Directory)形成了鲜明对比。
而在此前,我们学习过 InnoDB 下存储表的数据是在一个 .ibd 文件当中,而这个文件的内容就是一个线性的页数组------数组中的每个元素是固定 16 KB 大小的页。这些页的用途各不相同,有的承载 B+ 树的叶子节点内容,有的承载 B+ 树的内部节点内容,有的则是管理页。
undo page 也是 16 KB 大小的页,只是承载的内容变了------它不再承载业务数据,而是承载 undo record。
undo page 存在哪:专属的 undo 表空间
不管是哪个事务产生的 undo record 所在的 undo page,它们都被统一存放在专门用于存储 undo 数据的表空间里------和存储业务数据的表空间是相互独立的两套。
undo page 内部结构:和索引页相比少了什么、多了什么
之前我们学过索引页的内部结构------一个页由 7 个部分组成:File Header、Page Header、Infimum、Supremum、User Records、Free Space、Page Directory、File Trailer。
undo page 也是页,但内部结构和索引页不完全相同:
text
索引页: File Header + Page Header + Infimum + Supremum
+ User Records + Free Space + Page Directory + File Trailer
undo page: File Header + Page Header + Undo Page Header
+ Undo Records 区域 + Free Space + File Trailer
↑ ↑
多了一个 没了 Page Directory
Undo Page Header
两个关键差别:
- 多了 Undo Page Header:记录这一页特有的元信息------比如它存的是哪类 undo(INSERT 类 / UPDATE 类)、当前 undo record 写到了哪个偏移量等。这些信息在后面讲 undo log 怎么串联管理时会用到。
- 没有了 Page Directory:索引页里的 Page Directory 是为了支持按键值二分查找,快速定位到目标记录。但 undo page 不存在这种"按键值查找"的需求------后面我们会看到,访问 undo record 用的是别的机制。
Undo Records 区域:物理是数组,逻辑是链表
这一节是这部分内容里最容易绕的地方,需要把两件事彻底分开看。
物理布局:紧凑追加写的数组
Undo Records 区域物理上就是一个紧凑的追加写数组 ------多条 undo record 按写入时间顺序紧挨着排列,不留空隙。每条 undo record 都自带一个 length 字段,所以"物理上的下一条在哪"可以直接算出来(当前位置 + 当前 length)。
text
Undo Records 区域(物理布局):
┌─────────────────────┐
│ undo_rec_A1 │ ← 第一条写入,从区域起始位置开始
│ (length=80) │
├─────────────────────┤
│ undo_rec_B1 │ ← 紧挨着 A1 写入
│ (length=60) │
├─────────────────────┤
│ undo_rec_A2 │ ← 紧挨着 B1 写入
│ (length=50) │
├─────────────────────┤
│ undo_rec_A3 │
├─────────────────────┤
│ ... │
├─────────────────────┤
│ free space │ ← 还没写到的空间
└─────────────────────┘
注意一个关键点:这个数组可能同时混着多个事务的 undo record------上图里 A1、A2、A3 属于事务 A,B1 属于事务 B,物理上挨在一起但归属不同。这是因为我们前面提到,一个 undo page 可以承载多个 undo log 的 undo record(极端情况一条 undo record 就独占一个 16 KB 页太浪费),所以 InnoDB 让多个事务共享 undo page。
逻辑链表:叠加在数组之上
那回滚时怎么知道"哪些 undo record 属于我这个事务"?InnoDB 在这个紧凑数组之上叠加了一层逻辑链表结构 ------每条 undo record 内嵌一个 prev 指针 ((page_no, offset) 形式),指向同一个 undo log 里前一条 undo record。

也就是说------物理上是紧凑数组(按写入时间排),逻辑上是多条独立链表(按 undo log 分)。两套结构同时存在,但解决两个不同的问题:
- 物理数组:解决"怎么紧凑地把多个事务的 undo record 塞进同一个 undo page" → 提高空间利用率
- 逻辑链表:解决"怎么把属于同一个 undo log 的 undo record 区分出来" → 让回滚能精确走自己这条链
回滚时的具体流程
回滚时引擎不"线性扫描" undo page,它走的是 undo log 自己的逻辑链:
- 引擎从事务对象
trx_t找到"事务的 undo log 链表头"------指向最后一条写入的 undo record(也就是事务最近一次修改产生的那条) - 从这条 undo record 开始,沿着 prev 指针往回走
- 每跳一次,读出一条 undo record、按它的内容回滚一次修改
- 一直走到链表尾(事务最早的那条 undo record),整条链遍历完毕,事务回滚结束
整个过程完全不依赖 undo page 内部的物理顺序------指针就是"地图",引擎按地图走,邻居是谁完全不影响。这也解释了为什么 undo page 可以放心地让多个事务共享:物理混合不影响逻辑分离,每条链各走各的。
undo 表空间按区、段管理
讲完单个 undo page 的内部结构,再看 undo 表空间这个整体的组织方式。
我们知道,对于一个表空间即一个线性的页数组来说,这里除了页,还有区 的概念。所谓的区就是更大粒度的空间管理单元,这点我们在学习 .ibd 文件的时候十分熟悉了------一个区的大小是 1 MB,而页的大小是 16 KB,所以一个区包含连续的 64 个页。InnoDB 在给页数组扩容的时候,是以区为单位进行扩容。
在区之上还有段 的概念。所谓的段就是相同用途的页的集合------在存储表数据的 .ibd 文件当中,段分为了索引段 (存储聚簇索引或二级索引的内部节点所在的页)以及数据段 (存储聚簇索引或二级索引的叶子节点所在的页)。这些段会登记在 INODE 页(page 2) 中------这是一个管理页,除去页头页尾,中间是一个登记项数组:每一个登记项包含一个碎片页数组 (用于零散分配的少数几个页)以及三条 extent 链表(追踪空闲、半满、全满的区),用来追踪这个段的页分布情况。
undo 表空间同样按段管理
对于 undo page 所在的表空间来说,同理,这里也存在段的概念------因为区和段本来就是 InnoDB 管理表空间的通用策略,无论存储什么内容的表空间都会用到。
但这里有一个有趣的差别:在 .ibd 中段是按"页的用途"分的(索引段 vs 数据段),而 undo 表空间里所有的实际数据页都是 undo page,存储类型上没有差别------那 undo 表空间里的段是按什么维度分的?
答案藏在事务的语义里。我们之前学过 undo log 这个概念------它是一个事务产生的所有 undo record 的逻辑集合 。而且要注意一个细节:执行一条修改性的 SQL 不一定只产生一条 undo record------比如一条 UPDATE 语句修改了 100 条记录,就会产生 100 条 undo record。所以一个事务可能会产生大量的 undo record。
而一个 undo page 存储的 undo record 数量是有上限的(受限于页的 16 KB 大小),所以一个 undo log 里所有的 undo record 通常会分布在多个 undo page 上 。InnoDB 把"承载某个 undo log 的所有 undo page"组织成一个独立的段------这就是 undo segment。
所以在 undo 表空间里,段的划分维度是 "这些 undo page 服务于哪个 undo log(也就是哪个事务)",而不是页的存储类型。
一个 undo page 可能跨多个 undo log 共享
不过有一个细节需要注意:不是所有的 undo log 都包含大量 undo record------有的可能只有寥寥几条,最极端只有一条。我们不可能给一条 undo record 就专门分配一个 16 KB 的 undo page,那太浪费了。所以 InnoDB 允许一个 undo page 同时承载来自不同 undo log(不同事务)的 undo record ------也就是说,同一个 undo page 可能被注册在多个 undo segment 当中。这是空间利用率的优化。
rollback segment:undo segment 之上的管理层
在 undo segment 之上,还有一个层级叫 rollback segment(回滚段) 。它和 undo segment 的关系,不是"包含",而是"指向"------
具体来说,rollback segment 本质上是一个页 (rollback segment header page),页内部维护着一个 1024 个槽位的数组 。每个槽位存储一个页号,指向一个 undo segment 的管理页(undo segment header page)。换句话说,rollback segment 是个"目录页"------它通过 slot 数组追踪并分配 undo segment,自己并不真的"装"着 undo segment 的内容。
text
Rollback Segment Header (一个页)
│
├─ slot[0] → undo_segment_X 的 header 页号
├─ slot[1] → undo_segment_Y 的 header 页号
├─ slot[2] → 空闲
├─ slot[3] → undo_segment_Z 的 header 页号
├─ ...
└─ slot[1023]
事务第一次执行修改性 SQL 产生 undo record 时,引擎会从某个 rollback segment 里找一个空闲的 slot,然后给这个事务分配一个新的 undo segment(具体的分配流程后面再展开)。所以对一个 undo segment 来说,它的"身份"是双重的------
- 在表空间管理层面:它必须像所有段一样,登记到 INODE 页(page 2)的登记项数组里,让 InnoDB 知道它在表空间里占用了哪些页/区
- 在事务管理层面:它属于某一个 rollback segment 的 slot,让 InnoDB 知道"哪个事务在用它"
rollback segment 自己也是个段
有的读者可能会困惑------"段"这个词的语义很容易让人觉得它必须包含多个页,但实际上 InnoDB 里"段"的语义是"相同用途的页的集合",即使这个集合只有一个页也可以。
rollback segment 自己本身只有一个 header 页,所以它作为段在 INODE 登记里的形态非常轻------只占用碎片页数组的一个槽位,不会用到 extent 链表(因为根本没有 extent 级别的扩展)。
128 个 rollback segment
InnoDB 默认有 128 个 rollback segment 同时存在------这是为了支持高并发下大量事务可以并发分配 undo 资源。这 128 个 rollback segment 由系统层面的一张全局目录页统一索引,目录页内部是一个 128 槽位的数组,每个槽位指向一个 rollback segment header 的页号。
所以整个 undo 系统的层级关系就清楚了:
text
全局 rollback segment 目录(系统层)
│ 128 个槽位
▼
Rollback Segment Header (×128)
│ 每个 1024 个槽位
▼
Undo Segment Header (×?)
│ 管理一个 undo log 的所有 undo page
▼
Undo Pages (×?)
│ 实际存 undo record
四层结构层层管理:rollback segment 是资源分配中心 ,undo segment 是单个事务的 undo 容器 ,undo page 是实际承载 undo record 的物理页。
undo segment 内部:用链表把 undo page 串起来
最后再看 undo segment 内部是怎么组织它的 undo page 的。
我们知道,对于 .ibd 文件中的段,段内的页是按照 B+ 树的逻辑结构组织的------索引段的页通过 B+ 树的父子指针连接,数据段的页通过叶子节点的双向指针连接。
但 undo segment 没有 B+ 树这种层次结构 ------它内部用的是更简单的链表:undo segment 中的 undo page 按照"产生的先后顺序"被指针关联在一起,形成一条链。每个 undo page 内部存储的 undo record 也按产生顺序追加排列。
整个 undo segment 的"管理页"是 undo segment header page------它是这个 undo segment 的根/入口页,记录了整个 segment 的元信息(比如这个 segment 的 first undo page 在哪、last undo page 在哪、当前写入位置等),这些字段都存在 segment header page 自己的 Undo Page Header 里。
text
Undo Segment 的内部组织:
Undo Segment Header Page (管理页/入口页)
│
│ first_page、last_page 等指针
│
└─→ undo_page_1 ──→ undo_page_2 ──→ undo_page_3 ──→ ...
(按产生顺序连成一条链表,跨页时通过指针跳转)
回滚时引擎从 undo segment header page 拿到入口指针,找到 undo log 链表头(最后一条 undo record),然后沿着我们前面讲过的 prev 指针逆序遍历整条 undo log 链------这条链可能跨越多个 undo page,但有 page header 里的元信息和 undo record 内嵌的 prev 指针,引擎可以精确地一步步走完。

Undo Log 的空间分配
前面我们提到,undo log 是一个逻辑概念,那么它在物理磁盘上究竟是如何安家的呢?
当一个事务第一次 执行修改性的 SQL 语句时,InnoDB 不会随便找个地方就写 undo record,而是会触发一套层次清晰的物理空间分配机制。这套机制采用了一种类似 "目录 → 章节 → 页码" 的分级管理架构。
整个分配流程可以归纳为四个步骤:
1. 寻找大本营:定位回滚段
InnoDB 默认有 128 个回滚段------把它理解为 128 个独立的"大目录",每个目录有自己的资源池,多个事务可以并行从不同的大目录里分配资源,彼此不抢。
当事务首次执行 DML 操作时,InnoDB 通过轮询策略给这个事务分配一个回滚段:
text
事务依次首次执行 DML 时,引擎按当前轮询位置依次分配:
事务 A → 第 1 个回滚段
事务 B → 第 2 个回滚段
事务 C → 第 3 个回滚段
...
事务 第 128 个 → 第 128 个回滚段
事务 第 129 个 → 取模折返,回到第 1 个开始下一轮
...
这种轮询策略的目的是把分配压力均匀分散到 128 个回滚段上,避免热点。
2. 锁定导航枢纽:回滚段管理页
分配到某个回滚段后,引擎会去读取这个回滚段对应的那个回滚段管理页 ------回滚段本质上就是这一个页,里面维护着 1024 个空闲槽位。
这 1024 个槽位就像是 1024 个停车位,每个槽位将来用来记录"哪个事务的 undo 段在哪个页号"。
3. 扫描空闲槽位:寻找栖身之所
接下来,引擎会遍历这 1024 个槽位,寻找一个空闲槽位(槽位里的页号无效,表示还没被任何事务占用)。找到空闲槽位之后,就可以继续往下走分配流程。
这也意味着------一个回滚段最多同时支持 1024 个活跃事务,128 个回滚段叠加起来就是 128 × 1024 ≈ 13 万的并发上限。这是 InnoDB 在并发能力上的一个硬指标。
4. 开辟新天地:分配 undo 段与管理页
找到空闲槽位后,真正的"开荒"开始了:
- 分配段 :InnoDB 向 undo 表空间申请一个新的 undo 段------这个段将来专门承载这个事务的所有 undo record
- 建立段的入口 :这个新 undo 段的第一个页,就是该段的管理页------既承载段的元信息(first_page、last_page、写入指针等),也是后续 undo record 数据页链表的起点
- 注册与连接 :把这个新诞生的 undo 段管理页的页号 ,写入到刚才找到的那个空闲槽位中
至此,一条完整的物理链条就建立起来了:
text
全局 → 回滚段总目录 → 回滚段管理页的槽位 → 事务的 undo 段管理页 → 后续的 undo 数据页
之后这个事务执行任何修改性 SQL 产生的 undo record,都会通过这条链找到自己的归宿------一路从全局目录走到 undo 段、再到具体的 undo 数据页,最终落到 16 KB 页里某个具体的偏移量上。
事务边界控制:BEGIN、COMMIT、ROLLBACK 与 autocommit
在彻底搞懂了 undo log 是如何在底层兜底"原子性"之后,我们从微观的存储引擎回到宏观的客户端。
既然事务本质上是"一组 SQL 语句的逻辑集合",那么对于 MySQL 来说,它怎么知道你输入的这几条 SQL 是一伙的?它又怎么知道什么时候该结束打包呢?这就需要我们显式地通过 SQL 指令来划定事务的边界。
1. 划定边界:事务的核心控制指令
掌控一个事务的生命周期,主要依靠以下几个核心 SQL 指令:
- 开启事务(
START TRANSACTION或BEGIN):事务的起点。从这一刻开始,后续输入的所有 SQL 语句都会被打包到同一个事务上下文中。 - 手动回滚(
ROLLBACK) :如果在执行一组 SQL 的半路,应用层发现了业务逻辑错误(例如转账时发现对方账号被冻结),可以输入ROLLBACK;。此时引擎会读取 undo log,逆向执行所有的 undo record,把BEGIN之后的所有数据修改撤销,回到事务开始前的状态。 - 局部存档(
SAVEPOINT) :在一个包含几十条 SQL 的长事务中,如果只是最后一步出错就把前面所有修改全部ROLLBACK,代价太大。这时可以用SAVEPOINT 存档名;建立一个存档点 ------后续如果某段操作失败,只需执行ROLLBACK TO 存档名;就能精准回退到存档时的状态,而不会撤销整个事务。 - 提交事务(
COMMIT) :所有业务逻辑顺利执行完毕、确认无误后,输入COMMIT;让事务正式结束,所有修改都将永久生效(这就是 ACID 中的持久性)。
例 1:完整提交一个事务
sql
-- 开启事务
BEGIN;
-- 业务操作 A:扣减课程 101 的名额
UPDATE course SET seats = seats - 1 WHERE id = 101;
-- 业务操作 B:插入一条选课记录
INSERT INTO student_course (student_id, course_id) VALUES (1001, 101);
-- 业务逻辑完成,提交事务
COMMIT;
-- 此时 A 和 B 两个操作同时永久生效
例 2:发现错误后整体回滚
sql
BEGIN;
-- 1. 扣减名额(假设原本 seats=5,执行后变成了 4)
UPDATE course SET seats = seats - 1 WHERE id = 101;
-- 2. 准备插记录时发现该学生学分已满,没有选课资格
-- 必须撤销刚才的扣名额操作
-- 3. 整体回滚
ROLLBACK;
-- 引擎读取 undo log,把刚才减掉的名额加回去
-- 再次查询时,名额依然是 5
例 3:用存档点做局部回滚
sql
BEGIN;
-- 1. 学生先选了一门必修课 101
UPDATE course SET seats = seats - 1 WHERE id = 101;
INSERT INTO student_course (student_id, course_id) VALUES (1001, 101);
-- ★ 必修课选好了,打个存档点
SAVEPOINT s1;
-- 2. 接着抢一门火爆的选修课 202
UPDATE course SET seats = seats - 1 WHERE id = 202;
-- 选修课名额不足,失败
-- 3. 不能直接 ROLLBACK,否则连必修课也没了
-- 精准回退到存档点 s1
ROLLBACK TO s1;
-- 4. 选修课的操作被撤销,必修课的操作依然保留
COMMIT;
2. autocommit:默认的隐式事务
学到这里,很多初学者会产生一个疑惑:
在此前学习数据库基础时,我们每天都在客户端里敲单独的
INSERT或UPDATE语句。当时我们既没有敲BEGIN,也没有敲COMMIT,数据怎么也都修改成功了?难道它们不受事务的管辖吗?
答案是:在 InnoDB 中,所有的用户操作都必须在事务中运行 。你之所以没敲 BEGIN 也能成功,是因为 MySQL 默认开启了一个机制叫自动提交(autocommit)。
autocommit = 1(默认开启) :在这种模式下,如果你没有显式地敲入BEGIN,MySQL 会把你的每一条单独的 SQL 语句自动包装成一个独立的事务 ------执行 SQL 之前自动BEGIN,没有报错就执行完立刻COMMIT。autocommit = 0(手动关闭) :任何INSERT/UPDATE/DELETE操作都不会自动提交,MySQL 会默默开启一个事务并保持挂起状态,直到你亲自敲下COMMIT或ROLLBACK为止。
这就是为什么平时单条 SQL 练习中我们从未感知到事务存在的原因。
可以通过以下两种方式查看当前的 autocommit 设置:
sql
-- 方式一:查看系统变量(默认返回 1,表示开启)
SELECT @@autocommit;
-- 方式二:使用 SHOW 语句(默认返回 ON,表示开启)
SHOW VARIABLES LIKE 'autocommit';
-- 当我们直接执行一条独立的 SQL 时:
UPDATE course SET seats = seats - 1 WHERE id = 101;
-- 由于 autocommit 是开启状态,MySQL 实际执行的是:
-- BEGIN;
-- UPDATE course SET seats = seats - 1 WHERE id = 101;
-- COMMIT;
-- 所以这条语句执行完后,数据瞬间落盘生效

3. autocommit 不负责异常回滚
很多初学者会产生一个误区:既然有 autocommit,那断网时的自动回滚肯定也是这个机制来兜底的吧?
完全不是 。这两者解决的是完全不同维度的问题:
autocommit:和平时期的便利机制
autocommit 本质上是为了方便开发者而设计的默认规则------它解决的痛点是:在客户端与 MySQL 保持正常、健康连接 的情况下,如果你懒得手动输入 BEGIN,系统帮你把每条 SQL 自动包装成事务并提交。
它针对的是活着的连接,是便利层面的服务,不涉及任何异常处理。
异常断开时的回滚:底层为了一致性的强制清理
当你开启了一个事务(无论是手动 BEGIN 还是关闭 autocommit 隐式开启),如果在执行过程中突然遭遇关闭终端、拔掉网线或者客户端进程崩溃------此时触发的回滚是 MySQL 底层为了保护数据一致性而强行执行的异常清理。
它的执行流程大致是:
- 第一步:感知连接断开。MySQL 服务端在和客户端的网络通信中察觉到连接异常断开(比如 read 失败、TCP 超时等),就会清理这条连接对应的会话------这时它会发现这个会话还有未提交的事务挂在那里。
- 第二步:识别问题 。既然客户端已经断开,这个事务永远也等不到
COMMIT指令了。如果不处理,它不仅会白白占用 undo 资源,更致命的是------它持有的所有行锁会一直锁住记录,导致其他事务全部阻塞卡死。 - 第三步:强制清理 。为了保住数据库的原子性 和一致性,引擎会自动 ROLLBACK 这个未完成的事务------读取 undo log 把已做修改全部撤销,并释放所有持有的锁资源。
类比来理解
这两件事用饭店做比喻就很清楚------
- autocommit 像是热情的服务员:看你光盘了一道菜,主动走过来问"这道菜给您结账(提交)了吗?"
- 异常断开回滚 则是饭店里的清理规则:你吃饭吃到一半突然跑路了,经理为了不影响后续生意,强行出面把没动的菜撤回后厨倒掉(撤销数据),把桌子清理干净(释放锁),好让下一桌客人能正常坐下。
这跟服务员的工作机制没有任何关系------这是饭店为了运营秩序必须执行的底线规则。
从 MVCC 到隔离级别:InnoDB 事务隔离性的实现
隔离性的核心矛盾:读写互斥与并发性能
根据上文,我们已经知道如何实现事务的原子性以及如何在应用层手动开启一段事务。接下来我们进入事务要达成的第二个目标------ACID 中的 I(隔离性)。
回顾:纯靠锁会带来什么问题
之前我们认识了 InnoDB 的锁机制,知道执行事务中的 SQL 语句之前必须先尝试加锁。一旦加锁成功,这把锁会一直持有到事务结束才统一释放。
如果事务是一个长事务(包含大量 SQL),执行期间锁会不断累积------而且这些锁直到事务结束才会一次性释放。MySQL 是客户端-服务器架构,每个连接对应一个服务线程;如果大量服务线程在加锁时频繁撞上别的事务持有的锁、陷入阻塞,并发性能会塌掉。
矛盾:读和写真的不能同时进行吗?
对表中数据的访问可以分为两个行为------读 和写。其中"两个事务同时写同一条记录"显然不行(会破坏数据一致性),这一点没有争议。
但读和写是不是也必须互斥?凭直觉(特别是有多线程编程经验的人),答案是"是的"------如果允许读和写同时进行,读到的就是一个修改了一半的中间态,结果不可预测。
但 MySQL 是一个高并发服务端场景 :可能有几十几百个事务同时想读同一张表的同一个页里的同一条记录,只要有一个事务在写,按直觉所有读都得阻塞------这种性能在高并发场景下完全不可接受。
思路:让读不去碰最新版本
所以这里就需要在隔离性的实现上做一些权衡------既要保证事务之间互相独立,又不能让读操作完全阻塞在写操作上。
InnoDB 解决这个问题的关键思路是:让读操作不去碰那个被写锁锁住的最新版本,而是访问一个旧版本。
但这里马上引出一个新问题------旧版本是哪个旧版本?读旧版本会不会导致问题?
为什么不能读"被锁的最新版本"
考虑这样的场景:事务 A 启动,开始访问某条记录;但在 A 启动之前,事务 B 已经启动了,B 正在修改这条记录的写锁还在,B 还没提交。
如果允许 A 读到 B 修改后的最新值会发生什么?
我们知道,事务在没结束之前随时有回滚的可能 。如果 A 读到了 B 修改后的值,但 B 后来回滚了,那 A 看到的那个值就是根本不应该存在的------一个被回滚的"幻影修改"。
而事务中的 SQL 之间存在逻辑依赖------A 读到的值会影响 A 后续的决策。如果 A 基于一个"不应该存在的值"做了判断、做了修改,整个事务的语义就崩了。
这种"读到了别人尚未提交的中间值,最终别人却回滚了"的问题,有一个标准的术语叫 脏读(dirty read) ------ 读到了根本不应该存在的数据。这是隔离性最基本要避免的问题之一。
那读什么?读"之前已提交"的旧版本
既然不能让 A 阻塞、又不能让 A 读到 B 还没提交的脏值,那 A 应该读什么?
答案是------这条记录在被 B 修改之前的、最近一次已提交的值。也就是说,引擎绕过那个"写到一半还没提交"的最新版本,去找一个"已经被某个事务提交过、稳定不会再变"的版本来读。
那这个"之前已提交的版本"具体怎么找?InnoDB 用了一套精巧的机制来管理记录的多个历史版本,这就是 MVCC(多版本并发控制)------下一节我们专门展开。
MVCC 的前置基础:事务 ID 与 Undo 段细分
MVCC 的两个铺垫:事务 ID 与 undo 段的细分
要在具体讲解 MVCC 机制之前,我们首先得有两个铺垫------事务 ID 和 undo 段的内部细分。
铺垫一:事务 ID
我们知道,每个会话都会产生事务,系统中存在多个会话意味着同时存在多个事务。InnoDB 需要管理这些尚未结束的事务------管理方式我们再熟悉不过了:先描述,再组织。
InnoDB 给每个事务定义一个事务对象 (trx_t,前面我们已经多次提到过),同时为了标识和区分不同的事务对象,会给事务对象分配一个事务 ID(trx_id)。
事务 ID 的分配方式是按需分配------事务在第一次需要被持久化标识时(比如第一次执行修改性 SQL)才申请一个 ID,按申请顺序递增。所以一个关键结论是:
事务 ID 越小,说明这个事务的首次写操作发生得越早;事务 ID 越大,说明它的首次写操作发生得越晚。
这个"事务 ID = 写操作的先后序号"的性质,是后面 MVCC 判断"哪个版本对当前事务可见"的核心依据------读到一条记录的某个版本时,引擎只需要看那个版本上记录的 trx_id 是大是小,就能判断"它对我可不可见"。
铺垫二:undo 段的 insert/update 细分
接下来需要回到之前讲过的 undo log。我们知道事务在执行修改性 SQL 之前会产生 undo record,落到对应的 undo page 当中------undo record 是这个修改的逆操作,而 undo page 之间通过指针(页号 + 偏移量的二元组)顺序关联,每条 undo record 内嵌的 prev 指针把同一个事务的 undo record 串成独立的逻辑链。
这里要补充一个之前为了简化而省略的细节:上文我们说"每个事务一个 undo 段",但实际上 InnoDB 把每个事务的 undo 段进一步细分------对每个事务,都会分别维护:
- 一个 insert 段:装这个事务执行 INSERT 操作产生的 undo record
- 一个 update 段:装这个事务执行 UPDATE / DELETE 操作产生的 undo record
为什么要分两个段?关键在于这两类 undo record 的生命周期完全不同:
- INSERT undo :事务一旦提交就立即可销毁------因为没有任何场景需要看到"这条记录被插入之前的样子"(它之前根本不存在)
- UPDATE/DELETE undo :事务提交后还要保留一段时间------因为这些 undo record 后面 MVCC 要用来构造历史版本(这正是这一章的主题)
把它们混在一个段里管理会让"提交后该清理什么"变得混乱,所以 InnoDB 从一开始就把它们分开。
这两个段都会登记在 page 2 中(page 2 是表空间统一的段登记中心,所有段都登记在这里);同时回滚段管理页里的 1024 个槽位指向的,就是各事务自己的 insert 段或 update 段的管理页。每个段内部的 undo page 各自串成一条独立链表。
铺垫到这里就齐了------后面我们看 MVCC 怎么用这两件事:
- 用事务 ID 判断 "记录的某个版本对当前事务是否可见"
- 用 update 段里的 undo record 沿着记录的版本链往回走,找到"对当前事务可见的那个旧版本"
MVCC 的物理基础:隐藏列、Undo Record 与版本链
MVCC 的物理基础:索引记录的三个隐藏列
有了上文的铺垫之后,接下来我们便可以理解底层是如何实现 MVCC 机制的。要理解 MVCC 的原理,首先得从索引记录的物理存储格式说起。
在之前的博客中,我们讲过 InnoDB 的索引记录在物理上分为两个区域:元数据区 和真实数据区。元数据区包含变长字段列表、NULL 值列表、记录头等元信息;真实数据区则按列顺序存储所有用户字段的值。
但其实在元数据区和真实数据区之间,还藏着几个隐藏列------这些列是 InnoDB 自己加的,用户既看不到也不能直接访问,但它们正是 MVCC 机制的物理基础:
text
InnoDB 索引记录的完整布局:
┌──────────────┬───────────────┬────────────────┐
│ 元数据区 │ 隐藏列 │ 真实数据区 │
│ │ │ │
│ 变长字段列表 │ DB_ROW_ID(可选)│ 用户字段 1 │
│ NULL 值列表 │ DB_TRX_ID │ 用户字段 2 │
│ 记录头 │ DB_ROLL_PTR │ ... │
└──────────────┴───────────────┴────────────────┘
这三个隐藏列分别承担不同的职责,重要性也不同:
DB_TRX_ID(6 字节,必有) :保存最后一次修改这条记录的事务 ID。每次有事务修改这条记录,引擎都会把这个事务的 trx_id 写到这个字段里DB_ROLL_PTR(7 字节,必有) :回滚指针(roll pointer) ------指向这条记录最近一次被修改时产生的 undo record。这条 undo record 里又有自己的 prev 指针指向更早的 undo record,所以从DB_ROLL_PTR出发,沿着 undo record 的 prev 链一直走,可以遍历这条记录的所有历史版本DB_ROW_ID(6 字节,可选):当表既没有主键、也没有非空唯一索引时,InnoDB 自己生成一个 row_id 来当聚簇索引的 key。这个字段和 MVCC 本身没什么关系,只是 InnoDB 没主键时的兜底机制
为什么这两个核心字段是 MVCC 的物理基础?
把 DB_TRX_ID 和 DB_ROLL_PTR 的作用结合起来看:
DB_TRX_ID告诉我们 "这条记录的当前内容是被哪个事务最后修改的"DB_ROLL_PTR告诉我们 "这条记录之前的版本可以从哪条 undo record 开始往回追"
update 类 undo record 的结构
update 类 undo record 承载的是 UPDATE/DELETE 操作的逆向信息------也就是"被修改记录的旧版本"。它的内部结构大致是这样:

其中元数据区里的 trx_id 和 prev_undo 这两个字段,和索引记录的两个隐藏列形成完美对照:
text
索引记录里的隐藏列 ←→ undo record 元数据区里:
DB_TRX_ID trx_id
DB_ROLL_PTR prev_undo
(指向"最近一条 undo") (指向"更早的一条 undo")
也就是说------记录头部的 DB_TRX_ID 和 DB_ROLL_PTR,在每条 update 类 undo record 内部都有一对完全对应的字段。这两组字段拼接起来,构成了完整的版本链。
为什么必须复制这两个字段到 undo record 里?
如果 undo record 里没有 prev_undo 字段 ,那从记录的 DB_ROLL_PTR 跳到第一条 undo record 之后,就断了 ------没法继续往前追更老的版本。正因为每条 undo record 里都自带一个 prev_undo 指针,版本链才能像单向链表一样一节一节往前走,直到走到链尾。
而每条 undo record 自带的 trx_id 字段,记录的是"这个版本是哪个事务造的"------这是记录"历史归属"的元信息,至于怎么用、什么时候用,后面 MVCC 章节会展开。
把这两件事合起来------
text
索引页里的记录(最新版本):
...用户数据...
DB_TRX_ID = 110
DB_ROLL_PTR = (page=482, off=240) ──┐
│
▼
undo_page_482 第 240 字节:
undo_record_3:
元数据: trx_id=110, prev_undo=(page=482, off=180) ──┐
真实数据: 主键 + 被改字段的旧值 │
▼
undo_page_482 第 180 字节:
undo_record_2:
元数据: trx_id=105, prev_undo=(page=475, off=80) ──┐
真实数据: 主键 + 被改字段的旧值 │
▼
undo_page_475 第 80 字节(注意跨页):
undo_record_1:
元数据: trx_id=100, prev_undo=NULL ← 链尾
真实数据: 主键 + 被改字段的旧值
每条 undo record 自带的 trx_id + prev_undo 这一对字段,让整条版本链具备了自描述能力------从记录头出发往前走,每一步都能立刻知道"这个版本是谁造的、再往前的版本在哪"。
关键洞察:所有这些指针都是 (page_no, offset) 二元组
注意一个统一性------DB_ROLL_PTR 和 undo record 里的 prev_undo,本质上是同一种"指针"的两次出现:
- 都是
(page_no, offset)二元组 - 都用来在 undo 表空间的"页 + 页内位置"层面定位某条 undo record
- 寻址逻辑完全一致:拿到二元组 → 找到那页 → 跳到那个偏移量
这种统一性贯穿整个 InnoDB 持久化层------B+ 树父节点指向子节点、Lock Object 里的页位置、回滚段槽位里的页号......几乎所有"指向页内某个位置"的引用都用这套二元组形式。InnoDB 的整个持久化数据结构就是一张靠 (page_no, offset) 二元组串起来的图。
对照:insert 类 undo record 的结构
最后再看 insert 类 undo record 的内部构造,它和 update 类有一个关键差别------
text
insert 类 undo record 的内部布局:
┌─────────────────────────────────────────────┐
│ 元数据区 │
├─────────────────────────────────────────────┤
│ length 这条 undo record 的总长度 │
│ type undo 类型 (INSERT) │
│ trx_id 生成这条 undo record 的事务 ID │
│ │
│ ★ 没有 prev_undo! │
├─────────────────────────────────────────────┤
│ 真实数据区 │
├─────────────────────────────────────────────┤
│ 被插入记录的主键 │
└─────────────────────────────────────────────┘
为什么 insert 类 undo record 没有 prev_undo 字段 ?因为 INSERT 操作创造了一条全新的记录 ------这条记录之前根本不存在,没有"更老的版本"。既然没有更老的版本,prev_undo 也就没有意义。
所以 insert 类 undo record 内部更简单:只装一个 trx_id(用来识别归属)和被插入记录的主键(用来支持回滚时按主键删除)。真实数据区也比 update 类 undo record 简单得多------只有主键,没有其他字段。
这正是前面"insert 段 vs update 段"分开管理的根源------它们承载的 undo record 类型不同、内部结构不同、生命周期也不同。
从 MVCC 到隔离级别:一致性与并发性能的权衡
根据上文,我们已经认识了实现 MVCC 机制的物理基础------索引记录的隐藏列、update 类 undo record 的内部构造、版本链的拼接方式。这些是"实现层"的工具,但还没回答一个上层问题:这些工具到底用来实现什么样的隔离规则?
回想上一节我们讨论的脏读场景------读到了别的事务尚未提交的中间值,最终别人却回滚了,这种"读到不应该存在的数据"会破坏事务的一致性。但有意思的是:脏读并不是被绝对禁止的。
为什么?因为不同的业务场景对"事务隔离的严格程度"有不同的需求------
- 有的场景能容忍数据稍有偏差但需要极高的并发(比如统计类查询、报表展示,数据稍微不准没关系,但响应必须快)
- 有的场景对一致性要求极高,一点偏差都不允许(比如金融转账、库存扣减,错一分钱都不行)
- 还有的场景介于两者之间
数据库无法预判上层的业务需求------它能做的是提供一组可选方案,让应用层根据自己的场景自由选择:想要高并发就选宽松的隔离规则、能接受一定的并发异常;想要强一致性就选严格的隔离规则、但要付出一定的性能代价。
这组方案,就是 SQL 标准里定义的 事务隔离级别(Isolation Level)------它本质上是数据库提供给应用层的一组"权衡按钮",让应用层在"并发性能"和"一致性"两端之间挑一个合适的位置。
- 读未提交(Read Uncommitted, RU):追求极致性能,允许裸奔(脏读)。
- 读已提交(Read Committed, RC):守住底线,只能读已提交的数据(解决脏读)。
- 可重复读(Repeatable Read, RR):保证事务内视角的绝对一致(解决脏读、不可重复读,大幅缓解幻读)。
- 串行化(Serializable):追求极致安全,牺牲一切并发(排队串行)。
接下来我们先认识 SQL 标准定义的四个隔离级别------它们各自允许什么并发异常、禁止什么并发异常。然后再回到 MVCC,看 InnoDB 是怎么用前面讲过的物理基础(隐藏列 + 版本链)来实现这些隔离级别的。
在正式揭秘 MVCC 机制之前,我们先插播一段重要的实战知识:在实际开发中,我们该如何在 SQL 层面查看和动态修改隔离级别?
正如前文所述,MySQL 把隔离级别的选择权交给了应用层。为了兼顾全局的稳定性与单次业务的灵活性,MySQL 设计了**"全局(Global)"和"会话(Session)"**两个维度的隔离级别,它们之间存在着严密的继承关系:
- 作用域与继承规则
- 继承机制 :当一个新的客户端与 MySQL 建立连接(开启一个新会话)时,这个会话的隔离级别会默认继承当前系统的全局隔离级别。
- Session(会话)级别修改 :如果你在代码中修改了会话级别的隔离级别,这个改变仅仅只会影响当前这一个连接。你在这个终端里不管怎么折腾,都不会干扰到其他正在运行的会话。
- Global(全局)级别修改 :如果你拥有管理员权限并修改了全局隔离级别,注意,这不会影响当前已经建立好的任何会话 ,它只对未来新建立的连接生效。
- 核心 SQL 演示
在排查并发问题时,我们第一步要做的往往就是确认当前的隔离级别:
查看隔离级别:
sql
-- 查看当前会话(Session)的隔离级别
SELECT @@SESSION.transaction_isolation; -- MySQL 8.0 及以上
-- 或者
SELECT @@tx_isolation; -- MySQL 5.7 及以下
-- 查看全局(Global)的隔离级别
SELECT @@GLOBAL.transaction_isolation;

修改隔离级别:
sql
-- 场景 A:当前业务需要极其严格的数据校验,临时将当前会话提升为"串行化"
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 此时只有当前连接的查询会排队,其他用户的连接依然是默认的 RR 级别。
-- 场景 B:由于公司架构调整,DBA 决定将未来的默认级别改为"读已提交(RC)"
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 执行后,已经连在数据库上的老应用毫无感知;但新启动的应用实例连上来时,就会自动变成 RC 级别。
💡
在我们动手修改隔离级别之前,需要特别铭记一点:无论是全局(Global)还是新建立的会话(Session),MySQL InnoDB 引擎出厂默认的事务隔离级别都是 可重复读(Repeatable Read,简称 RR)。
从 RU 到 RC:普通读、锁定读与不可重复读
认识了隔离级别之后,接下来我们来看一下这些隔离级别的具体含义:
不过在正式进入隔离级别之前,需要先区分一个非常关键的概念:普通读 和锁定读。
普通读指的是普通的 SELECT 语句:
sql
SELECT remain FROM course WHERE id = 101;
在 RC、RR 这些隔离级别下,普通 SELECT 通常会走 MVCC,也就是通过 ReadView 判断版本可见性,读取一个对当前事务可见的记录版本。
而锁定读指的是下面这类语句:
sql
SELECT remain FROM course WHERE id = 101 FOR UPDATE;
SELECT remain FROM course WHERE id = 101 FOR SHARE;
SELECT remain FROM course WHERE id = 101 LOCK IN SHARE MODE;
其中,FOR UPDATE 会尝试给读取到的索引记录加 X 锁 ;FOR SHARE 或老版本中的 LOCK IN SHARE MODE 会尝试给读取到的索引记录加 S 锁。
锁定读不是单纯"看一眼",而是带着明确目的来的:我读到这条记录之后,后续还要基于它做判断、修改或者保证它在事务期间不要被别人改掉。
所以锁定读属于当前读,它读取的是记录的当前最新版本,并且会参与锁冲突检测。如果目标记录已经被其他事务持有不兼容的锁,那么当前事务就会阻塞等待,而不是绕到 undo log 里去读历史版本。
也就是说,后面讨论 RU、RC、RR 时,如果没有特别说明,我们默认讨论的是普通 SELECT 的行为 ,而不是 FOR UPDATE / FOR SHARE 这种锁定读。
- 读未提交(RU):直接读最新版本,不做并发隔离
首先是最低的隔离级别:读未提交,也就是 Read Uncommitted,简称 RU。
在 RU 级别下,普通 SELECT 基本不做 MVCC 版本可见性判断。更准确地说,它不会创建 ReadView,也不会沿着记录中的 DB_ROLL_PTR 去 undo log 版本链里寻找某个"已提交的旧版本"。
它的行为非常直接:读最新版本,但不加锁、也不参与锁冲突检测------所以即使别的事务正持有 X 锁修改这条记录,RU 下的普通 SELECT 也能直接读到那个被改到一半的最新值。
这和 FOR UPDATE 这种锁定读完全不同------后者不仅读最新版本,还会加锁,遇到不兼容锁会阻塞等待。RU 下的普通 SELECT 是 "读最新 + 不加锁 + 不参与冲突检测" 这三件事的组合,这正是脏读能发生的物理原因。
比如现在课程表中有一条记录:
text
course_id = 101
remain = 1
事务 A 正在修改这条记录:
sql
BEGIN;
UPDATE course
SET remain = 0
WHERE id = 101;
-- 此时事务 A 还没有 COMMIT
在 RU 隔离级别下,事务 B 执行普通查询:
sql
SELECT remain FROM course WHERE id = 101;
事务 B 可能直接读到:
text
remain = 0
但问题是,事务 A 此时还没有提交,它随时可能回滚:
sql
ROLLBACK;
一旦事务 A 回滚,remain = 0 这个修改就会被 undo log 撤销,数据库会恢复到:
text
remain = 1
这意味着事务 B 刚才读到的 remain = 0,本质上是一个后来被证明"不应该存在"的值。
这种现象就是脏读:一个事务读到了另一个事务尚未提交的修改,而这个修改最终可能被回滚。
所以,RU 下的普通读可以理解为:它读的是最新版本,但这个最新版本不一定是已经提交的稳定版本。
2.读已提交(RC):守住脏读的底线
为了避免脏读,隔离级别提升到了 读已提交,也就是 Read Committed,简称 RC。
RC 的核心底线是:
一个事务不能读到其他事务尚未提交的修改。
还是刚才的例子:
事务 A 修改课程剩余名额,但还没有提交:
sql
BEGIN;
UPDATE course
SET remain = 0
WHERE id = 101;
-- 事务 A 暂未 COMMIT
事务 B 在 RC 级别下执行普通查询:
sql
SELECT remain FROM course WHERE id = 101;
这时候事务 B 在 RC 隔离级别下会为这次 SELECT 创建一个 ReadView ------这个 ReadView 会判断"哪些事务已经提交、哪些还没",然后通过 MVCC 沿版本链找到当前 SELECT 时刻已经提交的最新版本,从而绕过事务 A 尚未提交的 remain = 0,读到上一个已提交的稳定版本:
text
remain = 1
这样,脏读问题就被解决了。
这里有一个关键细节先记住------RC 下每次执行 SELECT 都会创建一个新的 ReadView。这正是后面 RR 和 RC 的根本区别(RR 在事务开始时只创建一次 ReadView 并复用)。
但 RC 只是守住了"不读未提交数据"这条底线,并不代表它已经解决了所有隔离性问题。这就引出了 RC 级别下的经典问题:不可重复读。
- RC 下的不可重复读问题
很多初学者第一次看到"不可重复读"时,会觉得这个问题有点牵强:
既然别人已经提交了,我读到最新的真实数据不是很正常吗?这有什么问题?
这个想法没错,但它只适用于短查询、简单查询。
对于复杂业务来说,一个事务内部的多条 SQL 往往不是彼此独立的。前一次查询结果可能会参与后续判断、流程推进或者最终落库校验。如果同一个事务内部前后两次读取同一条关键数据,读到的结果不一致,那么整个业务流程就可能前后矛盾。
我们还是用选课业务来理解。
假设课程 101 当前只剩下 1 个名额:
text
course_id = 101
remain = 1
现在事务 A 要为学生 1001 完成选课流程。这个流程不是一条 SQL 就结束的,它可能包含很多业务校验:
text
检查课程是否还有名额
检查学生是否已经选过这门课
检查学生学分是否超限
检查课程时间是否冲突
检查是否满足先修课条件
最后插入选课记录并扣减名额
事务 A 开始执行:
sql
BEGIN;
SELECT remain
FROM course
WHERE id = 101;
第一次查询结果:
text
remain = 1
于是事务 A 判断:
text
课程还有名额,可以继续进入选课流程。
接下来事务 A 开始做一系列耗时校验:
text
检查学分上限
检查课程时间冲突
检查先修课
检查是否重复选课
就在这个过程中,事务 B 介入了。
事务 B 也在处理另一个学生的选课请求,并且它已经成功扣减了课程名额:
sql
BEGIN;
UPDATE course
SET remain = 0
WHERE id = 101;
COMMIT;
现在课程 101 的剩余名额已经从 1 变成了 0。
事务 A 做完前面的业务校验后,在真正插入选课记录之前,为了保险,又进行一次最终校验:
sql
SELECT remain
FROM course
WHERE id = 101;
由于当前隔离级别是 RC,而事务 B 已经提交,所以事务 A 这一次会读到最新的已提交版本:
text
remain = 0
于是事务 A 内部出现了前后不一致:
text
第一次读:remain = 1
事务 A 判断:可以选课
第二次读:remain = 0
事务 A 判断:不能选课
注意,这里第二次读到的 remain = 0 并不是脏数据,它确实是事务 B 已经提交后的真实数据。
RC 的问题不在于"读到了假的数据",而在于:
同一个事务内部的多次普通读,可能分别看到不同时间点的已提交数据。
这样一来,事务 A 的业务流程就不再基于一个稳定的数据视图,而是像站在一个不断变化的地面上做判断。第一次查询时,事务 A 看到的是"课程还有名额";经过一系列业务校验后,第二次查询又看到"课程没有名额"。这会导致事务内部的业务判断前后冲突。
这就是不可重复读的本质:
在同一个事务中,多次读取同一条记录,由于其他事务在中间提交了修改,导致前后读取结果不一致。
4.RC 的真正问题:每条 SELECT 都看到"当时最新的已提交世界"
现在我们可以收束 RC 这一节。
RC 解决了 RU 的脏读问题。它保证:
text
我不会读到别人尚未提交的修改。
但 RC 没有保证:
text
我在同一个事务内部,多次读取同一条记录时,结果始终一致。
所以事务 A 第一次读课程名额时,看到的是第一次 SELECT 开始前已经提交的世界;事务 A 第二次读课程名额时,看到的是第二次 SELECT 开始前已经提交的世界。这两个世界之间,如果有其他事务提交了修改,那么事务 A 前后两次读取的结果就可能不同。
因此,RC 的特点可以这样总结:
text
RU:
普通读直接读最新版本,不加锁、不参与冲突检测
↓
问题:脏读
RC:
普通读通过 ReadView 只读已提交版本
↓
解决:脏读
↓
但每条 SELECT 都创建新的 ReadView
↓
问题:不可重复读
RC 并不是读到了错误数据。它读到的每一个版本单独看都是真实、已提交的。问题在于:
同一个事务内部的多次读取,可能来自不同时间点的数据库快照。单条 SQL 看是正确的,但多条 SQL 组合成一个业务流程时,前后数据可能不再自洽。
这就是不可重复读的真正危害------也是 RR 隔离级别要解决的核心问题。
5.(旁支补充)锁定读:用 FOR UPDATE / FOR SHARE 强制独占
刚才不可重复读的例子里,讨论的是普通 SELECT。
如果事务 A 第一次查询课程名额时,不是使用普通 SELECT,而是使用锁定读:
sql
BEGIN;
SELECT remain
FROM course
WHERE id = 101
FOR UPDATE;
那么情况就完全不一样了。
FOR UPDATE 会读取当前最新版本,并尝试给 course_id = 101 这条索引记录加 X 锁。如果加锁成功,那么事务 A 在提交或回滚之前会一直持有这条记录的 X 锁。此时事务 B 如果想修改这条课程记录:
sql
UPDATE course
SET remain = 0
WHERE id = 101;
事务 B 就会被阻塞------它也需要对这条记录加 X 锁,而 X 锁与 X 锁不兼容。
也就是说,FOR UPDATE 的语义是:
我不只是看一眼这条记录,我后面还要基于它做修改,所以在我的事务结束前,别人不要动它。
如果事务 A 使用的是共享锁定读:
sql
SELECT remain
FROM course
WHERE id = 101
FOR SHARE;
或者老写法:
sql
SELECT remain
FROM course
WHERE id = 101
LOCK IN SHARE MODE;
那么事务 A 会给这条记录加 S 锁。其他事务仍然可以对这条记录加 S 锁进行共享读取,但不能加 X 锁修改它。所以事务 B 的 UPDATE 仍然会被阻塞。
这说明一个关键点:
不可重复读主要是普通快照读在 RC 级别下的问题;如果你用锁定读主动把记录锁住,那么你是在用锁机制强行保证这条记录在事务期间不被其他事务修改。
但是锁定读的代价也很明显:它会降低并发度。
普通 SELECT 通过 MVCC 读取历史版本,通常不会阻塞写事务;而 FOR UPDATE / FOR SHARE 会真的加锁,一旦锁冲突,其他事务就会等待。
所以在高并发系统里,不能把所有 SELECT 都写成 FOR UPDATE。只有当你明确知道"我读完这条记录之后,后面要基于它做强一致判断或修改"时,才应该使用锁定读。
6.(旁支补充)同一事务内的锁升级:S 锁能不能变成 X 锁?
这里再补一个和锁定读相关的细节。
假设事务 A 先用共享锁读取课程记录:
sql
BEGIN;
SELECT remain
FROM course
WHERE id = 101
FOR SHARE;
此时事务 A 对这条记录持有 S 锁。
后面事务 A 发现自己不仅要读,还要修改这条记录:
sql
UPDATE course
SET remain = remain - 1
WHERE id = 101;
那么事务 A 就需要对这条记录申请 X 锁。
这时要注意:事务不会被自己已经持有的锁阻塞。也就是说,事务 A 自己持有的 S 锁,不会阻止事务 A 继续申请 X 锁。
但能不能升级成功,要看有没有其他事务也持有这条记录的 S 锁。
如果当前记录上只有事务 A 自己的 S 锁:
text
记录 R 上已有锁:
事务 A:S 锁
那么事务 A 申请 X 锁可以成功------事务 A 在记录上拥有了一把 X 锁。
但如果还有事务 B 也持有 S 锁:
text
记录 R 上已有锁:
事务 A:S 锁
事务 B:S 锁
此时事务 A 再申请 X 锁,就不能直接成功------X 锁与事务 B 的 S 锁不兼容,事务 A 必须等待事务 B 释放 S 锁。
更极端一点,如果事务 A 和事务 B 都先持有 S 锁,然后又都想升级成 X 锁,就可能出现死锁:
text
事务 A 持有 S 锁,等待事务 B 释放 S 锁以升级 X 锁
事务 B 持有 S 锁,等待事务 A 释放 S 锁以升级 X 锁
双方互相等待,死锁检测机制就需要介入。
Lock Object 视角下的"升级"是怎么发生的?
从 Lock Object 的角度看,"升级"这个词其实有些误导------InnoDB 不会真的把原来的 S Lock Object 改造成 X Lock Object。
回想一下我们之前讲过的:同一事务在同一页上,S 锁和 X 锁是两个独立的 Lock Object ------它们的 type_mode 不同,不能合并。所以 InnoDB 实际做的事是:
text
事务 A 申请 X 锁时:
① 检查别的事务有没有持有不兼容的锁(忽略事务 A 自己的 S Lock Object)
② 如果没有冲突,新建一个事务 A 的 X Lock Object
(如果该事务在这页上之前已经有 X Lock Object 就复用,否则新建)
③ 在新 X Lock Object 的 bitmap 上把对应 heap_no 置位
④ 原来的 S Lock Object 保留不动
也就是说------S Lock Object 不会被销毁,只是新增了一个 X Lock Object。事务 A 在这条记录上同时持有 S 锁和 X 锁,但因为 X 锁的语义已经覆盖了 S 锁,所以实际生效的就是 X 锁。
这样设计的好处是:避免了"破坏性改造"------如果直接把 S Lock Object 改成 X,bitmap 里标记的同页其他记录也会被一起升级,扩大锁范围。新建独立的 X Lock Object 可以精确控制只对申请的那条 heap_no 加 X 锁,其他位置依然只有 S 锁覆盖。
通用规则:自己不阻塞自己
InnoDB 行锁的冲突检测有一条通用规则:
事务自己持有的锁永远不阻塞自己------冲突检测只看其他事务持有的锁是否和当前申请的锁不兼容。
这条规则让"升级"这种场景能自然发生,不需要任何特殊路径。冲突检测路径上代码非常简洁:
text
申请锁 → 遍历桶里其他事务的 Lock Object → 检查兼容性
事务自己的 Lock Object 在遍历时会被直接跳过------不参与冲突判断。
ReadView 的可见性判断:RR 如何解决不可重复读
认识了 RU 和 RC 之后,接下来就要进入 RR,也就是可重复读。RR 可以看作是在 RC 的基础上进一步解决了不可重复读问题。
在前文中,我们一直提到一个关键概念:ReadView。要理解 RC 为什么会出现不可重复读,以及 RR 为什么能够解决不可重复读,就必须正式理解 ReadView 究竟是什么。
ReadView 直译过来叫"读视图",它是 InnoDB 在执行普通快照读时创建的一个可见性判断对象。需要注意的是,ReadView 并不是把数据库中的所有记录都复制一份,也不是保存某个时刻所有数据的值;它保存的是创建 ReadView 那一刻系统中事务 ID 的分布情况,用来判断某个记录版本对当前事务是否可见。
在 InnoDB 中,一个 ReadView 主要由以下四个核心字段构成:
text
creator_trx_id:
创建这个 ReadView 的事务 ID。
用来处理一个特殊情况:当前事务自己修改的记录,自己必须能看见。
m_ids:
ReadView 创建时刻,系统中仍然活跃的读写事务 ID 列表。
这是一个集合,记录了创建快照那一刻所有"还在执行、尚未提交"的事务。
up_limit_id:
ReadView 创建时刻,系统中最小的活跃事务 ID。
所有 ID 严格小于它的事务,在 ReadView 创建时一定已经提交。
它是判断"已提交"区间的下边界。
low_limit_id:
ReadView 创建时刻,系统下一个即将分配的事务 ID。
所有 ID 大于等于它的事务,一定在 ReadView 创建之后才启动。
它是判断"未来事务"区间的上边界。
注意:low_limit_id 不是 m_ids 中的最大值,而是下一个待分配的事务 ID,
两者通常不相等(后面的例子会看到)。
为了让 ReadView、事务对象以及记录隐藏列之间的关系更加直观,我们可以先用一段简化版的伪代码把它们抽象出来。
需要提前说明的是,下面的代码并不是 InnoDB 源码中的真实结构体定义,而是为了帮助理解而抽象出来的模型。真实的 InnoDB 内部实现会更加复杂,但核心关系是一致的:
cpp
struct trx_t {
trx_id_t trx_id; // 当前事务 ID
trx_state state; // 事务状态:ACTIVE / COMMITTED / ROLLBACKED ...
isolation_level iso; // 隔离级别:RU / RC / RR / SERIALIZABLE
// 当前事务使用的 ReadView
// 在 RR 下,第一次快照读创建后,后续快照读复用同一个 ReadView
// 在 RC 下,每条快照读语句都会重新创建一个新的 ReadView
ReadView* read_view;
// 当前事务使用的 undo log 信息
// 这里简化为两个入口,实际 InnoDB 内部更复杂
undo_log_t* insert_undo; // INSERT 操作产生的 undo log
undo_log_t* update_undo; // UPDATE / DELETE 操作产生的 undo log
// 当前事务持有的行锁链表
lock_t* trx_locks;
};
struct ReadView {
trx_id_t creator_trx_id; // 创建这个 ReadView 的事务 ID
std::vector<trx_id_t> m_ids; // 创建 ReadView 时,仍然活跃的读写事务 ID 列表
trx_id_t up_limit_id; // m_ids 中的最小事务 ID
// 小于该值的事务,在 ReadView 创建前一定已经提交
trx_id_t low_limit_id; // 创建 ReadView 时,系统即将分配的下一个事务 ID
// 大于等于该值的事务,一定是在 ReadView 创建之后才出现
};
总的看下来,这四个字段把整个事务 ID 空间切成了三段:
text
已提交 灰色地带 未来事务
─────────────│ ─────────────────── │─────────────
up_limit_id low_limit_id
灰色地带 = [up_limit_id, low_limit_id) 之间的事务 ID
= 已经分配过 ID,但创建 ReadView 时未必已提交
其中部分活跃的子集 = m_ids
creator_trx_id 不在这条数轴里------它是当前事务自己的标识,单独参与判断。
接下来再看记录版本链是如何形成的。
在 InnoDB 的聚簇索引记录中,除了用户定义的字段之外,还有一些隐藏列,其中和 MVCC 关系最密切的是:
text
DB_TRX_ID:最后一次修改该记录的事务 ID
DB_ROLL_PTR:指向本次修改产生的 undo record
当某个事务要修改一条记录时,它会先为这次修改生成一条 update undo record,用来保存记录修改前的信息。随后,事务才会真正修改聚簇索引记录,并把记录中的 DB_TRX_ID 改成当前事务 ID,同时把 DB_ROLL_PTR 指向刚刚生成的 undo record。
这样一来,记录的版本链就形成了:
text
当前记录版本
│
│ DB_ROLL_PTR
▼
undo record 1 → 可以构造出上一个旧版本
│
▼
undo record 2 → 可以构造出更老的版本
│
▼
...
这里要特别注意,MVCC 中的版本链是围绕"同一条记录的历史版本"展开的。它和事务回滚时使用的 undo 顺序不是同一个概念。事务回滚关注的是当前事务内所有修改操作的逆序撤销;而 MVCC 版本链关注的是某一条记录从当前版本一路追溯到旧版本的过程。
当一个事务执行普通快照读时,InnoDB 会先拿到当前记录版本上的 DB_TRX_ID,然后用这个 trx_id 去和 ReadView 做可见性判断。
判断规则可以概括为:
text
1. 如果 trx_id == creator_trx_id
→ 说明这是当前事务自己生成的版本,可见。
2. 如果 trx_id < up_limit_id
→ 说明生成该版本的事务在 ReadView 创建前已经提交,可见。
3. 如果 trx_id >= low_limit_id
→ 说明生成该版本的事务是在 ReadView 创建之后才启动的,不可见。
4. 如果 up_limit_id <= trx_id < low_limit_id
→ 需要检查 trx_id 是否在 m_ids 中:
- 如果在 m_ids 中,说明创建 ReadView 时该事务仍未提交,不可见;
- 如果不在 m_ids 中,说明创建 ReadView 时该事务已经提交,可见。
为什么中间区间还要查 m_ids
第 1、2、3 条规则都很直观------靠 trx_id 和边界值的大小关系就能立刻判断。但第 4 条规则为什么需要额外查 m_ids?这里需要稍微展开。
落在中间区间 [up_limit_id, low_limit_id) 的事务有一个共同点:它们的事务 ID 都是在 ReadView 创建之前被分配的 。但是,它们在 ReadView 创建那一刻到底是已经提交了,还是仍然活跃,不能只靠 ID 大小判断------因为 ID 是按申请顺序递增分配的,但事务的提交时机和 ID 分配时机没有必然关联。
text
中间区间里的事务,有两种可能:
- 已经提交: ID 已分配,且创建 ReadView 时它已经结束
- 仍然活跃: ID 已分配,但创建 ReadView 时它还在执行
这两种情况靠 ID 大小区分不出来,所以需要 m_ids 这个集合来区分。
m_ids 就是这个区分依据------它精确记录了"创建 ReadView 那一刻还活跃的事务"。
text
如果 trx_id 在 m_ids 中:
说明创建 ReadView 时,这个事务仍然活跃、尚未提交;
因此它生成的版本对当前 ReadView 不可见。
如果 trx_id 不在 m_ids 中(且 trx_id < low_limit_id):
说明这个事务的 ID 已被分配、但已经不在活跃列表里
→ 它已经提交;
因此它生成的版本对当前 ReadView 可见。
注意第二条推理里有两个前提合一:"trx_id 已被分配(小于 low_limit_id)" + "trx_id 不在活跃列表中" = "已提交"。光说"不在 m_ids 中"是不够的------一个还没分配出去的 ID 也"不在 m_ids 中",但它代表的是未来事务(已经被规则 3 拦下了)。
举个具体例子
假设当前系统已经分配过的事务 ID 是:
text
1, 2, 3, 4, 5, 6, 7, 8
其中创建 ReadView 那一刻仍然活跃的事务是:
text
m_ids = [3, 6, 8]
那么:
text
up_limit_id = 3 (m_ids 中的最小值)
low_limit_id = 9 (下一个待分配的事务 ID)
注意------m_ids 中的最大值是 8,但 low_limit_id 是 9。这两者不相等,正是因为 low_limit_id 表示的是"下一个待分配的 ID",而不是"活跃事务里的最大 ID"。
现在读取到某个记录版本,它的 DB_TRX_ID = 5。因为:
text
3 ≤ 5 < 9
所以它处在中间区间,需要查 m_ids。
text
5 不在 m_ids 中,且 5 < low_limit_id
→ 事务 5 的 ID 已经被分配出去了
→ 但创建 ReadView 时它已经不在活跃列表里
→ 所以它已经提交
→ 事务 5 生成的版本对当前 ReadView 可见
如果读取到的版本是 DB_TRX_ID = 6:
text
6 在 m_ids 中
→ 创建 ReadView 时事务 6 仍未提交
→ 事务 6 生成的版本对当前 ReadView 不可见
即使事务 6 后来提交了,也不会影响这个已经创建好的 ReadView------ReadView 表示的是创建那一刻的可见性边界,创建之后的世界变化和它无关。
所以,ReadView 的可见性判断可以总结为:

一句话概括:
m_ids记录的是创建 ReadView 那一刻仍然活跃的事务;up_limit_id是判断"已提交"区间的下边界,low_limit_id是判断"未来事务"区间的上边界。对于落在二者之间的事务 ID,只有通过是否出现在m_ids中,才能判断它在快照创建时到底是"已经提交"还是"仍未提交"。
如果当前记录版本对 ReadView 可见,那么快照读直接读取当前版本;如果不可见,InnoDB 就会沿着 DB_ROLL_PTR 找到对应的 undo record,构造出上一个历史版本,然后继续拿这个历史版本上的 trx_id 去和 ReadView 做同样的可见性判断。这个过程会一直重复,直到找到一个对当前 ReadView 可见的版本,或者版本链被遍历完。
到这里,前面铺垫的知识就串起来了:
text
DB_TRX_ID:
判断某个记录版本由哪个事务生成
DB_ROLL_PTR:
在当前版本不可见时,沿着版本链寻找旧版本
undo record:
保存构造旧版本所需的信息
ReadView:
定义当前快照读能看见哪些事务生成的版本
因此,MVCC 的快照读本质上就是:
用 ReadView 判断当前版本是否可见;如果不可见,就借助 undo log 版本链不断向旧版本回退,直到找到一个对当前事务可见的版本。
而 RC 和 RR 的区别,并不在于可见性判断规则不同,而在于 ReadView 的生成时机不同。
在 RC 隔离级别下,每一条普通 SELECT 都会创建一个新的 ReadView。因此,同一个事务内的两次查询可能看到不同时间点已经提交的版本,这就导致了不可重复读。
而在 RR 隔离级别下,事务中的第一次普通快照读会创建一个 ReadView,后续普通快照读会复用这个 ReadView。也就是说,整个事务期间快照读使用的是同一套可见性边界。即使其他事务在中途提交了新的修改,只要这个修改对当前 ReadView 不可见,当前事务后续的普通快照读仍然看不到它。
这就是 RR 能够解决不可重复读的根本原因------RC 和 RR 共用同一套可见性判断规则,唯一的区别在于 ReadView 的生成时机。RC 是"每条 SELECT 都活在自己的快照里",RR 是"整个事务都活在第一次 SELECT 那一刻的快照里"。
Serializable 隔离级别:将普通读也纳入锁系统
认识了 RR 之后,我们知道 InnoDB 在 RR 隔离级别下,主要通过两套机制来解决隔离性问题:
text
普通 SELECT:
使用快照读,复用同一个 ReadView,保证同一事务内多次读取结果稳定
当前读:
使用锁机制,借助 Record Lock / Gap Lock / Next-Key Lock 防止并发修改和幻读
也就是说,RR 并不是让所有事务真的一个接一个执行,而是通过 MVCC + 锁机制,在大多数场景下既保证较强的一致性,又尽量保留并发性能。
但是在隔离级别继续提升到 Serializable(串行化) 之后,策略就变得更加保守了。
Serializable 是 SQL 标准中最高的隔离级别,它追求的目标是:
多个事务并发执行的最终效果,必须等价于这些事务按照某个顺序一个接一个串行执行。
注意这里说的是"效果等价于串行执行",并不是说 MySQL 真的只允许一个事务运行,其他事务全部停止。MySQL 仍然可以让多个事务并发执行,但一旦它们之间存在读写冲突、范围插入冲突等可能破坏串行化语义的行为,就会通过锁机制让其中一方等待,从而保证最终结果看起来像是事务按顺序执行出来的一样。
在 RR 隔离级别下,普通 SELECT 默认是快照读:
sql
SELECT remain FROM course WHERE id = 101;
它通常不会加行锁,而是通过 ReadView 去读取一个对当前事务可见的版本。这样做的好处是读操作不会轻易阻塞写操作,写操作也不会轻易阻塞普通读,因此并发性能比较好。
但是在 Serializable 隔离级别下,情况发生了变化。
在显式事务中,普通 SELECT 会被 InnoDB 当成一种加锁读来处理。也就是说,它不再只是通过 ReadView "看一眼历史快照",而是会给读取到的记录加共享锁,效果接近于:
sql
SELECT remain
FROM course
WHERE id = 101
FOR SHARE;
或者老版本中的写法:
sql
SELECT remain
FROM course
WHERE id = 101
LOCK IN SHARE MODE;
这意味着:在 Serializable 隔离级别下,普通读也开始参与锁冲突检测。
还是以前面的选课场景为例。
假设课程 101 当前剩余名额为 1:
text
course_id = 101
remain = 1
事务 A 在 Serializable 隔离级别下执行:
sql
BEGIN;
SELECT remain
FROM course
WHERE id = 101;
虽然这看起来只是一条普通查询,但在 Serializable 下,它会给这条课程记录加 S 锁。此时如果事务 B 想修改这条记录:
sql
UPDATE course
SET remain = 0
WHERE id = 101;
事务 B 需要申请 X 锁,而 X 锁与事务 A 已经持有的 S 锁不兼容,所以事务 B 会被阻塞,直到事务 A 提交或回滚。
也就是说,Serializable 的策略是:
text
既然你读了这条记录,
那在你的事务结束之前,
别人就不能随便修改它。
这和 RC / RR 下普通快照读的行为完全不同。RC / RR 下普通 SELECT 通常不会阻塞 UPDATE,因为它可以通过 MVCC 读取历史版本;但 Serializable 下普通 SELECT 也会加锁,因此读写之间会重新变得互斥。
对于范围查询,Serializable 的限制会更明显。
比如事务 A 查询课程 101 当前有哪些学生已经选课:
sql
BEGIN;
SELECT *
FROM student_course
WHERE course_id = 101;
如果 course_id 上有索引,那么 Serializable 下这个范围查询不仅会锁住已经存在的选课记录,还可能通过 Next-Key Lock 锁住对应的索引范围,防止其他事务在这个范围中插入新的记录。
此时事务 B 如果想插入一条新的选课记录:
sql
INSERT INTO student_course(student_id, course_id)
VALUES (1006, 101);
那么事务 B 可能会被阻塞。
原因是事务 A 的范围查询已经把 course_id = 101 对应的范围保护起来了。事务 B 如果在这个范围内插入新记录,就会破坏事务 A 的串行化视图。
所以 Serializable 不仅防止别人修改已经读到的记录,也会尽量防止别人在当前事务读过的范围中插入"新记录"。
这正是它能够避免幻读的原因。
从效果上看,Serializable 的隔离性最强。
它可以避免:
text
脏读
不可重复读
幻读
因为普通读也会被纳入锁系统中,只要某个事务读过某条记录或者某个范围,其他事务如果想修改这些记录,或者往这个范围中插入新记录,就可能被阻塞。
但它的代价也非常明显:并发性能会显著下降。
因为在 RC / RR 下,普通 SELECT 可以通过 MVCC 快照读绕开写锁,不必和写事务互相等待;而在 Serializable 下,普通 SELECT 也可能加锁,使读写冲突大幅增加。
可以这样对比:
text
RC / RR 下的普通 SELECT:
快照读
读历史可见版本
通常不加行锁
读写并发性能较好
Serializable 下的普通 SELECT:
加锁读
读取当前版本
会加 S 锁
更容易阻塞写操作
所以 Serializable 虽然隔离性最强,但在真实高并发业务中并不常作为默认选择。它更适合那些对一致性要求极高、并发压力相对较小的场景。
到这里,四种隔离级别的演进关系就比较清楚了:
text
RU:Read Uncommitted
普通读直接读取最新版本,可能读到未提交数据
问题:脏读
RC:Read Committed
每条普通 SELECT 创建新的 ReadView
解决:脏读
问题:不可重复读
RR:Repeatable Read
同一事务内复用同一个 ReadView
解决:不可重复读
普通快照读下也能避免幻读
当前读依靠 Next-Key Lock 防止幻读
Serializable:
普通 SELECT 也变成加锁读
通过更强的锁约束,让并发事务执行结果等价于串行执行
隔离性最强,但并发性能最差
因此,Serializable 可以理解为事务隔离性的"终极保守方案":
它不再主要依赖"读历史版本"来提高并发,而是倾向于通过加锁把读写冲突强行管理起来。
这样可以获得最强的一致性,但代价是大量并发操作会被串行化,系统吞吐量会明显下降。
补充:INSERT Undo 与 UPDATE Undo 分离后的全局逆序回滚
这里还需要补充一个非常关键的细节:虽然 InnoDB 会将 INSERT 操作产生的 undo record 和 UPDATE / DELETE 操作产生的 undo record 分开管理,但事务回滚并不是简单地"先整体回滚 insert undo,再整体回滚 update undo",或者反过来处理。
cpp
// 简化版事务对象 trx_t
// 注意:这不是 InnoDB 源码原样,只是为了帮助理解事务对象内部需要管理哪些核心信息
struct trx_t {
// -------------------------------
// 1. 事务基础信息
// -------------------------------
trx_id_t trx_id; // 当前事务 ID
trx_state_t state; // 事务状态:ACTIVE / COMMITTED / ROLLBACKED ...
isolation_level_t isolation; // 隔离级别:RU / RC / RR / SERIALIZABLE
// -------------------------------
// 2. ReadView:用于普通快照读
// -------------------------------
ReadView* read_view; // 当前事务使用的 ReadView
// RC:每条 SELECT 创建新的 ReadView
// RR:第一次快照读创建,后续复用
// -------------------------------
// 3. Undo Log:用于回滚和 MVCC
// -------------------------------
undo_log_t* insert_undo; // INSERT 操作产生的 undo log
// 主要用于事务回滚
// 事务提交后通常可以较快释放
undo_log_t* update_undo; // UPDATE / DELETE 操作产生的 undo log
// 既用于事务回滚
// 也用于 MVCC 构造历史版本
// 当前事务已经分配到的最大 undo_no
// 每产生一条 undo record,undo_no 递增
undo_no_t undo_no_counter;
// -------------------------------
// 4. 回滚游标:用于 ROLLBACK 时定位当前最新 undo record
// -------------------------------
undo_rec_ptr_t insert_undo_top; // insert undo log 当前最新 undo record 的位置
undo_rec_ptr_t update_undo_top; // update undo log 当前最新 undo record 的位置
// 回滚时会比较 insert_undo_top 和 update_undo_top 指向的 undo record 的 undo_no
// 谁的 undo_no 更大,说明谁对应的修改更晚,就先回滚谁
// -------------------------------
// 5. 行锁链表:用于事务结束时统一释放锁
// -------------------------------
lock_t* trx_locks; // 当前事务持有的所有 Lock Object 链表
// COMMIT / ROLLBACK 时沿着该链表统一释放
};
原因很简单:在一个事务内部,INSERT、UPDATE、DELETE 这些修改操作可能是交替发生的。
例如一个事务的执行顺序如下:
text
undo_no = 1:INSERT 记录 A → insert undo
undo_no = 2:UPDATE 记录 B → update undo
undo_no = 3:INSERT 记录 C → insert undo
undo_no = 4:DELETE 记录 D → update undo
从物理存储上看,INSERT 产生的 undo record 会进入 insert undo log,UPDATE / DELETE 产生的 undo record 会进入 update undo log,因此它们可能分布在不同的 undo page、不同的 undo log 结构中:
text
insert undo log:
undo_no = 1
undo_no = 3
update undo log:
undo_no = 2
undo_no = 4
但是从事务语义上看,回滚必须严格按照事务修改发生顺序的逆序执行。因为事务中的修改效果是一层一层叠加上去的,如果要恢复到事务开始之前的状态,就必须从最后一次修改开始撤销。
所以正确的回滚顺序应该是:
text
undo_no = 4 → 先撤销 DELETE 记录 D
undo_no = 3 → 再撤销 INSERT 记录 C
undo_no = 2 → 再撤销 UPDATE 记录 B
undo_no = 1 → 最后撤销 INSERT 记录 A
也就是说,回滚关注的不是 undo record 属于 insert 段还是 update 段,而是关注这条 undo record 在当前事务中的产生顺序。
为了保证这一点,InnoDB 会为事务产生的 undo record 维护一个事务内递增的编号,也就是 undo_no。undo_no 越大,说明这条 undo record 对应的修改发生得越晚;回滚时就应该越先被处理。
因此,事务回滚可以理解为这样一个过程:
text
insert undo log 当前顶部:undo_no = 3
update undo log 当前顶部:undo_no = 4
比较 3 和 4:
4 更大 → 先回滚 update undo 中的 undo_no = 4
回滚完成后,update undo log 的当前位置向前移动:
insert undo log 当前顶部:undo_no = 3
update undo log 当前顶部:undo_no = 2
比较 3 和 2:
3 更大 → 回滚 insert undo 中的 undo_no = 3
继续移动并比较:
insert undo log 当前顶部:undo_no = 1
update undo log 当前顶部:undo_no = 2
比较 1 和 2:
2 更大 → 回滚 update undo 中的 undo_no = 2
最后:
insert undo log 当前顶部:undo_no = 1
update undo log 已经没有待回滚记录
回滚 insert undo 中的 undo_no = 1
最终回滚顺序就是:
text
4 → 3 → 2 → 1
这就保证了即使 insert undo 和 update undo 在物理上分开存储,事务回滚依然能够按照所有修改操作的全局逆序执行。
需要注意的是,这里的"顶部"不是简单理解成"最后一个 undo page 的最后一条记录",而是指当前 undo log 管理结构中记录的最新 undo record 位置 。它本质上是一个 (page_no, offset) 形式的位置引用,用来定位当前 undo log 中最新产生的那条 undo record。回滚完这一条之后,当前 undo log 的位置再向前移动到上一条 undo record,继续参与下一轮比较。
因此,事务回滚并不是沿着一条跨越 insert undo 和 update undo 的全局 prev 指针链一路回退。更准确地说:
text
undo_no:
决定不同类型 undo log 之间的全局回滚顺序
undo log 内部的位置关系:
负责在某一个 undo log 内部找到上一条 undo record
这样,undo_no 和 undo log 内部结构配合起来,就能完成事务级别的全局逆序回滚。
这里还要和 MVCC 版本链区分开:
text
MVCC 版本链:
以"同一条记录"为中心
通过 DB_ROLL_PTR 和 undo record 中保存的旧 roll pointer
追溯这条记录的历史版本
服务于快照读
事务回滚顺序:
以"同一个事务"为中心
通过 undo_no 判断所有 undo record 的产生先后
按照 undo_no 从大到小撤销修改
服务于 ROLLBACK
所以,update undo record 可能同时参与两套逻辑:一方面,它可能作为某条记录历史版本链上的一个节点,服务于 MVCC;另一方面,它也是当前事务产生的一条 undo record,回滚时需要按照 undo_no 参与事务级别的逆序撤销。
一句话总结:
版本链是"同一条记录的历史链",回滚顺序是"同一个事务的操作逆序链"。前者靠
DB_ROLL_PTR追溯旧版本,后者靠undo_no保证全局逆序回滚,二者服务的目标不同,不能混为一条链。
结语
那么这就是本篇文章的全部内容,剖析了事务的底层,下一期我会更新视图,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!感谢各位大佬对我的支持!
