FXJ Wiki

Back

再看 MySQL:被术语盖住的三个地方Blur image

引言:从一个页分裂的推导矛盾谈起#

在讨论 InnoDB 的存储与并发机制时,有几句常见的总结经常被直接引用:例如“redo log 是物理日志”、“WAL 就是先写日志再写数据”、“隔离级别是自顶向下划分的四个严格档位”。

这些概括在入门阶段提供了直观的认知,但在面对更具体的工程推演时,往往会显露出推导矛盾。

一个典型的问题是:如果 redo log 严格记录的是“对某表空间中某数据页在特定偏移量上写入了特定字节”,那么当 B+ 树发生一次页分裂时,究竟会产生多大的 redo 日志?

按照纯物理日志的假设进行推算:InnoDB 默认页大小为 16 KB,分裂意味着原页大约一半的记录需要被搬迁到新建的数据页中,同时还需要修改父节点的索引目录项,以及前后兄弟页的双向链表指针。通常情况下,单行插入生成的生理 redo 日志仅在几十到上百字节之间;若将这半页记录的物理搬移完全作为字节镜像写入日志,单次插入产生的 redo 就会飙升至数 KB 的量级,体积膨胀数十倍。在每秒数千次并发写入的场景下,顺序写的磁盘吞吐收益很容易被成倍放大的日志写入量抵消。

事实上,InnoDB 的 redo log 并没有采用纯物理字节镜像的方案。这种推导上的断层反映出一个普遍现象:高度浓缩的术语在传播中往往滤掉了其背后的工程约束与折中。

本文从这个疑问出发,重新审视三个常见的技术概念:

  1. redo log 在物理与逻辑之间的划分(生理日志);
  2. WAL 在工程实现上依赖的硬性不变量;
  3. 事务隔离级别在两阶段锁与 MVCC 演进中的历史形成过程。

在阅读页分裂相关内容时,也可以结合下方的交互式内核实训台对照查看 B+ 树的物理节点分裂与记录分布:

MySQL InnoDB B+ Tree

页结构、记录格式到分裂合并 —— 可操作的 InnoDB 索引内核台。

打开可视化

一、redo log 的层次划分:生理日志(Physiological Logging)#

页分裂场景下的写放大疑问#

在对比 redo log 与 binlog 时,常见的分类往往将二者概括为:

维度redo logbinlog
所属层级InnoDB 存储引擎层MySQL Server 层
写入方式固定大小循环写追加写入
日志类型物理日志逻辑日志

若将这里的“物理日志”理解为“记录修改后物理字节镜像”,就会得出前文提到的写放大矛盾。

再看一个更常见的普通单行插入场景:InnoDB 页内的用户记录按主键顺序组织成单向链表,但记录在页内的物理存储空间并不需要连续。向页中间插入一条新记录,不需要整体搬移后续记录的字节,只需修改前驱记录的 next_record 指针,并在空闲空间(Free Space)中写入该记录。但与此同时,页目录(Page Directory)中的槽位(slot)可能会因为记录数增加而重新分配,页头中的 PAGE_N_RECSPAGE_HEAP_TOP 以及 PAGE_LAST_INSERT 等元数据字段也需要同步更新。

这些字段分散在页内的不同物理偏移量处。若完全采用纯物理字节记录,一次普通的单行插入就需要生成多条互不相邻的“偏移量 + 新字节值”日志。频繁的插入会导致日志文件被快速填满,进而引发检查点(checkpoint)频繁推进与脏页强制刷盘,违背了通过追加日志缓解随机 I/O 的初衷。

生理日志的折中设计#

针对这一问题,工业级关系型数据库普遍采用的是 Jim Gray 与 Andreas Reuter 在《Transaction Processing: Concepts and Techniques》中所定义的 生理日志(physiological logging)

其核心原则可以概括为:页间物理,页内逻辑(physical across pages, logical within a page)。

  • 物理定位(页间):每条 redo 记录明确绑定到一个具体的数据页,通过 (space_id, page_no) 唯一定位。崩溃恢复期间,重放引擎不需要遍历 B+ 树索引,也不需要理解表结构的全局元数据,直接从磁盘读取对应的物理数据页即可应用日志。
  • 逻辑操作(页内):日志记录的不是最终的物理字节镜像,而是在该数据页上执行的具体操作指令。例如“在此页插入该条记录”——插入后槽位如何重新划分、记录计数器如何递增、空闲指针如何移动,均不记录在日志中,恢复时交由重放逻辑在内存中重新执行一次页内计算。

以页分裂为例,在生理日志模型下,所产生的 redo 大致由以下几部分构成:

  1. 分配新页的物理分配日志;
  2. 批量记录拷贝日志(对应 MLOG_LIST_END_COPY_CREATED),记录操作起点并将迁移的记录体以前缀压缩格式写入;
  3. 父节点插入新目录项的记录;
  4. 调整前后相邻页双向链表指针的记录。

在这一过程中,页内所有衍生结构(如槽位重排、页头计数器与指针更新)均被省略,恢复时由代码在本地重新计算。因此,实际写入的 redo 日志量与搬移的数据体积成比例,远低于直接写入 8 KB 镜像数据的开销,同时又避免了纯逻辑日志在恢复时需要全局重算索引树的高昂代价。

从 MLOG_* 类型看物理与逻辑边界#

InnoDB 的 redo 记录在源码头文件 mtr0types.h 中定义了数十种日志类型。观察其中有代表性的几种类型,可以直观地看到物理与逻辑两种方式的共存:

/* 纯物理记录:直接记录指定偏移的字节值 */
MLOG_1BYTE          /* 在页的指定偏移写入 1 字节 */
MLOG_2BYTES         /* 写入 2 字节 */
MLOG_4BYTES         /* 写入 4 字节 */
MLOG_8BYTES         /* 写入 8 字节 */
MLOG_WRITE_STRING   /* 在指定偏移写入连续字节串 */

/* 生理记录:描述页内的高级操作语义 */
MLOG_REC_INSERT           /* 在页内插入单条记录 */
MLOG_REC_DELETE           /* 从页内删除单条记录 */
MLOG_REC_UPDATE_IN_PLACE  /* 原地更新页内记录 */
MLOG_LIST_END_COPY_CREATED /* 将一段记录批量拷贝至新页(页分裂主要日志) */
MLOG_LIST_START_DELETE     /* 批量删除从页头到指定记录的区间 */

/* 纯逻辑记录:涉及文件系统或全局元数据 */
MLOG_FILE_CREATE    /* 创建表空间物理文件 */
MLOG_FILE_RENAME    /* 重命名物理文件 */
MLOG_TABLE_DYNAMIC_META /* 表级别动态元数据变更 */
c

不同场景选择不同的记录粒度:

  • 修改页头计数器等短字段时,直接使用 MLOG_4BYTES 记录新值,若采用操作语义描述反而会消耗更多元数据空间。
  • 插入记录时,使用 MLOG_REC_INSERT 记录数据内容与相对位置,省略页内辅助结构的联动修改。
  • 表空间文件的增删则使用逻辑记录直接描述文件系统层面的动作。

非幂等日志与 LSN 状态推进#

生理日志在节约日志空间的同时,带来了一个设计约束:操作本身不再天然具备幂等性

对于纯物理日志,将偏移量 100 处的 4 字节赋值为 0x12345678,执行一次与重复执行多次的状态完全相同。而生理日志记录的是操作语义,若将“向该页插入一条记录”重复执行两次,页内就会出现两份重复记录。因此,崩溃恢复系统必须能够精确判断某条 redo 日志所包含的修改是否已经持久化到磁盘数据页中。

InnoDB 借助 LSN(Log Sequence Number) 解决了这一状态判定问题。

LSN 是单调递增的全局字节计数器,代表系统自初始化以来 redo 日志的累计生成量。系统不仅在每条 redo 日志中打上对应的 LSN,同时在每个数据页的头部(第 16 字节处的 FIL_PAGE_LSN 字段)记录最后一次修改该页的 redo 记录对应的 LSN。

在崩溃恢复阶段,状态判定逻辑如下:

读取 redo 记录(LSN = L,目标页 = P):
    从磁盘读出数据页 P,解析其页头中的 P.FIL_PAGE_LSN
    if L <= P.FIL_PAGE_LSN:
        跳过重放(该修改在崩溃前已经落盘)
    else:
        执行重放(该修改尚未落盘,在内存中执行页内操作)
text

通过这一比较,非幂等的操作日志在恢复时获得了确定性的重放结果。

撕裂页与 Doublewrite Buffer#

依赖数据页头部的 FIL_PAGE_LSN 进行恢复判定,暗含了一个关键前提:从磁盘读出的数据页必须具备结构自洽性。如果读取的页本身损坏,恢复逻辑不仅无法信赖其 FIL_PAGE_LSN,在执行 MLOG_REC_INSERT 沿链表定位时也会引发内存非法访问。

然而,现代操作系统的物理写入单位与数据库页大小通常不一致。InnoDB 的默认页大小是 16 KB,而底层文件系统与磁盘常见的原子写入块大小多为 4 KB。将一个内存脏页刷入磁盘需要完成 4 次底层写入操作。如果在写入过程中发生断电或系统崩溃,磁盘上可能残留一部分旧块与一部分新块,这种现象被称为 部分页写入(Partial Page Write)撕裂页(Torn Page)

在发生撕裂的页面中,若前 4 KB 包含了最新的页头,FIL_PAGE_LSN 呈现为最新值,但后续数据块仍为旧数据,单纯依靠 redo log 无法正确恢复。

Doublewrite Buffer(双写缓冲区) 的引入正是为了应对这一硬件约束:

  1. 脏页在刷盘时,先顺序写入磁盘上的 doublewrite 连续物理存储区域(MySQL 8.0.20 之前位于系统表空间共享区,8.0.20 开始使用独立的 .dblwr 文件),并执行 fsync 确保落盘;
  2. 完成后再将脏页离散写入表空间的目标数据文件中。

在系统崩溃重启时,InnoDB 会通过校验和(checksum)检查每个数据页的完整性。一旦发现撕裂页,便从 doublewrite 区域读取该页的完整镜像覆盖损坏页面,随后再基于恢复完好的页面执行 redo 日志的重做。

不同数据库对该问题的权衡路径有所不同:PostgreSQL 采用的是 full_page_writes 机制,在每次检查点过后,数据页初次被修改时将整个 8 KB 数据页完整写入 WAL,后续修改再记录增量变化。两者本质上都是在增量日志带来的空间节约与硬件原子写边界之间做出平衡。


二、WAL 的底层约束与刷盘机制#

刷盘协作中的三个时序不变量#

在经典数据库恢复理论中,缓冲池管理策略通常划分为两个核心维度:

  • No-Force:事务提交时无需强制将修改的数据页同步刷盘,借助顺序写入的 Redo Log 保障持久性(Durability);
  • Steal:允许内存紧张时将未提交事务的脏页提前置换落盘,借助 Undo Log 在崩溃或中止时保障原子性(Atomicity)。

InnoDB 采用的正是 Steal + No-Force 组合,以此换取缓冲池的最大读写吞吐与内存利用率。这种高灵活性的代价,是修改数据的落盘与事务提交彻底解耦。

在实际的多线程引擎中,数据页的刷盘由后台 Page Cleaner 线程负责,日志的刷盘则由独立线程调度。“先写日志”绝非粗略的时间巧合,而是依赖存储引擎内部严格保障的三个不变量:

  1. 页落盘前,其依赖的最新日志必须已落盘
  2. 日志在物理文件中的记录顺序必须与页修改的时序一致
  3. 日志的循环复用必须以相关脏页已经持久化为前提

脏页刷新前的依赖检查:log_write_up_to#

为保障第一个不变量,InnoDB 在脏页刷盘的关键路径上设置了显式的同步检查点。

缓冲池维护了一个名为 Flush List 的链表,链表中的脏页按照 oldest_modification(该页自上次刷盘后初次被修改时的 LSN)由小到大排序。后台 Page Cleaner 线程按序获取脏页准备执行 I/O 时,执行如下判定:

准备将脏页 P 写入磁盘:
    获取该页当前的最新修改点 P.newest_modification
    if P.newest_modification > flushed_to_disk_lsn:
        调用 log_write_up_to(P.newest_modification)
        等待日志刷盘线程将 redo 日志持久化到指定 LSN
    执行页 P 的落盘操作
text

log_write_up_to() 是落实 WAL 的同步栅栏。即使脏页调度跑在前面,刷盘线程也会在此处阻塞等待,直到对应日志完成 fsync

页头记录的 FIL_PAGE_LSN 反映的是 newest_modification,用于保障 WAL 检查与恢复期判断;而 Flush List 排序依据的则是 oldest_modification,用于推算日志空间的安全推进位置。

物理结构变更的原子单位:Mini-Transaction (mtr)#

并发事务在处理业务逻辑时,会产生交替出现的更新。然而在崩溃恢复期,重放过程需要严格按照物理变更的因果关系推进。特别是一个业务逻辑操作可能涉及多个页面的修改(例如 B+ 树分裂涉及源页、新页、父页与相邻页),这些修改必须具备物理上的原子性,避免出现只重放了部分页面的中间断裂状态。

InnoDB 使用 Mini-Transaction(mtr) 作为物理一致性的最小单位:

mtr_start()
    修改页 A,生成对应的 redo 记录,暂存于 mtr 本地私有缓冲区
    修改页 B,生成对应的 redo 记录,暂存于本地缓冲区
mtr_commit()
    将该 mtr 内暂存的全部 redo 一次性追加到全局 Log Buffer
    将修改的页挂入 Flush List
    释放所涉及数据页上的 latch(读写锁)
text

mtr_commit() 期间,这批 redo 日志被连续写入全局缓冲区,并打上明确的结束标记(例如 MLOG_MULTI_REC_END)。恢复阶段遇到一个未包含结束符的残缺 mtr 记录段时,会将其视为无效日志直接忽略,确保物理结构变更不会停留在半途。

由于 mtr 执行期间会长时间持有内存数据页的读写锁(latch),InnoDB 内核规范要求 mtr 内部严禁包含任何可能导致阻塞的外部 I/O 操作,所有涉及的页面必须预先加载至缓冲池。

环形缓冲与 Checkpoint 推进#

InnoDB 的 redo log 采用固定容量的环形文件组组织(如 ib_logfile0ib_logfile1)。日志文件持续循环写入,已写入的历史日志必须在确认安全后才能被新日志覆盖。

安全覆盖的标准由 Checkpoint LSN 决定。其定义为:该 LSN 之前的所有修改,其对应的数据页均已完整写入磁盘

Checkpoint LSN 的取值直接由 Flush List 中最小的 oldest_modification 决定。这形成了环形空间中两个指针的追逐关系:

  • write_pos 随事务推进向前移动,消耗空闲日志空间;
  • checkpoint 随脏页刷盘向前移动,释放可覆盖的日志空间。

当并发写入过高导致 write_pos 即将追上 checkpoint 时,引擎将触发同步刷脏,所有写操作都会受限,等待 Page Cleaner 强行刷出脏页以推进 checkpoint。这种状态反映在系统监控上就是突发的 I/O 停顿。

调整 innodb_log_file_size 可以在吞吐与可用性之间调整取舍:增大日志容量允许脏页在缓冲池中停留更长时间以合并写操作,减少抖动,但也会相应增加机器意外断电后的崩溃恢复耗时。

刷盘策略:innodb_flush_log_at_trx_commit 的安全边界#

事务提交时,redo log 从内存到磁盘介质经历两个阶段:

Log Buffer (MySQL 用户进程内存)

    │  write()

OS Page Cache (操作系统内核内存)

    │  fsync()

Disk (持久化存储介质)
text

参数 innodb_flush_log_at_trx_commit 的三个取值对应了不同的持久化阶段:

参数值事务提交时的动作MySQL 进程崩溃操作系统断电
0不主动调用写操作,依赖主线程每秒调用 write() + fsync()丢失最近至多约 1 秒的数据丢失最近至多约 1 秒的数据
1每次提交均调用 write() 并执行 fsync()不丢失数据不丢失数据
2每次提交调用 write() 写入 OS 缓存,每秒执行一次 fsync()不丢失数据丢失最近至多约 1 秒的数据

值为 2 的机制依赖于操作系统的生命周期保障:一旦日志数据通过 write() 进入操作系统 Page Cache,即便 mysqld 进程因异常或 OOM 被系统杀死,内核仍会负责将该页缓存同步到磁盘中。因此,在宿主机硬件与供电相对可靠、主要防范应用进程崩溃的容器化部署场景中,设置为 2 能够在保证进程级安全的同时避免频繁执行同步 fsync

跨引擎协同:两阶段提交与 XID#

在开启 binlog 的 MySQL 实例中,事务提交不仅涉及 InnoDB 的 redo log,还涉及 Server 层的 binlog。两者分别负责单机崩溃恢复与主从复制/点位恢复。

为防止单侧日志写入成功而另一侧丢失造成数据不一致,系统引入基于分布式两阶段提交(2PC)的协调机制,使用内部事务 ID(XID)串联两个日志:

sequenceDiagram
    autonumber
    participant Client as 客户端
    participant Server as MySQL Server (Binlog)
    participant Engine as InnoDB 引擎 (Redo Log)
    participant Disk as 磁盘介质

    Note over Client,Engine: 阶段一:Prepare 阶段
    Client->>Engine: 发起 COMMIT
    Engine->>Engine: 生成 Redo Log,写入 XID 并标记 PREPARE
    Engine->>Disk: redo log fsync 落盘

    Note over Client,Engine: 阶段二:Commit 阶段(决议点)
    Engine->>Server: 进入 Commit 阶段,移交 XID
    Server->>Disk: 写入 Binlog (含同一 XID) 并 fsync 落盘
    Note right of Disk: ★ 核心决议点:Binlog 落盘成功与否决定事务生死

    Server->>Engine: 通知 Binlog 已落盘
    Engine->>Engine: 内存中将 Redo 事务状态标记为 COMMIT
    Engine-->>Client: 返回提交成功

    Note over Server,Engine: 崩溃恢复决议分支 (扫描处于 PREPARE 状态的事务)
    alt Binlog 中存在该 XID
        Engine->>Engine: 自动补全 Commit,完成事务提交
    else Binlog 中不存在该 XID
        Engine->>Engine: 利用 Undo Log 版本链执行 Rollback
    end
mermaid

系统重启恢复时,决策规则如下:

  1. 扫描处于 PREPARE 状态的事务,获取其 XID;
  2. 检查 binlog 文件中是否存在该 XID 对应的事件记录;
  3. 若 binlog 中存在,说明事务已完成决议,系统在引擎层自动补全 COMMIT;若 binlog 中不存在,则调用 undo 链执行回滚。

在这个逻辑中,binlog 的持久化是事务是否最终生效的唯一判据。最后一步修改 COMMIT 标记无需再次执行耗时的磁盘同步,系统在重放阶段即可根据 binlog 的存在性完成闭环。

组提交(Group Commit)与流水线优化#

在默认的双 1 配置(innodb_flush_log_at_trx_commit=1sync_binlog=1)下,每个事务提交原本需要执行两次独立的 fsync 操作。机械硬盘与固态硬盘的物理寻道和写入确认时延,直接限制了单线程的提交 TPS。

早期版本采用全局大锁 prepare_commit_mutex 保证 redo 和 binlog 的顺序一致,但导致了并发事务提交的严重串行化。MySQL 5.6 引入了 binlog 组提交机制,并在 5.7 中进一步完善,将提交阶段解耦为三个队列管理的子阶段:

多个并发事务进入队列,队首事务成为 Leader,其余事务成为 Follower。Leader 集中获取队列中所有事务的 redo log 执行批量刷盘,随后将这批事务的 binlog 事件批量写入 OS 缓存(调用 write())。

各阶段内部通过队列维系先后顺序,各阶段之间形成流水线作业:当上一批事务处于 Sync 刷盘时,后续事务已可在 Flush 阶段进行批量写操作,消除了全局互斥锁的瓶颈。

sequenceDiagram
    autonumber
    participant App as 并发事务线程池
    participant Flush as Flush 阶段队列
    participant Sync as Sync 阶段队列
    participant Commit as Commit 阶段队列

    App->>Flush: 事务组 1 (A 作为 Leader, B, C) 排队进入
    Note over Flush: Leader 统一执行 redo write & fsync<br/>随后将该组 binlog 批量写入 OS Page Cache

    Flush->>Sync: 事务组 1 移交至 Sync 队列
    Note over Sync: Leader 调用一次 fsync 完成 binlog 集中落盘<br/>(按 delay / count 延迟参数汇聚批量)

    par 流水线并发重叠
        App->>Flush: 后续事务组 2 (D, E) 此时已可进入 Flush 队列进行批量写
    and
        Sync->>Commit: 事务组 1 进入 Commit 队列
        Note over Commit: 按队列原有顺序调用引擎接口<br/>标记 COMMIT 并释放行级锁
    end
mermaid

ARIES 恢复三阶段#

在崩溃恢复时,InnoDB 遵循经典 ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)模型的三个阶段:

  1. 分析阶段(Analysis):从最近的 Checkpoint LSN 开始顺序扫描 redo log,重建崩溃时刻的脏页表(记录各页的修改起始 LSN)以及未提交的活跃事务表(Active Transaction Table);
  2. 重做阶段(Redo / Repeating History):从脏页表记录的最小 LSN 起,向前重放所有 redo log(包括未提交事务生成的日志),利用前述的 FIL_PAGE_LSN 跳过已持久化页面,将整个存储引擎在内存中的状态精确复原至崩溃瞬间;
  3. 撤销阶段(Undo):在状态复原后,针对活跃事务表中未提交的事务,利用 undo log 沿版本链逆向回滚其修改。回滚操作本身生成的页变更会写入特殊的补偿日志(Compensation Log Record, CLR),同样受 redo 保护,确保恢复中断时无需重复回滚已撤销的操作。

“先无差别重放、再统一撤销”的设计规避了在物理重放阶段解析上层事务边界的复杂度,将物理状态复原与逻辑事务回滚清晰解耦。


三、隔离级别的历史形成与 InnoDB 的混合模型#

ANSI SQL-92 隔离级别的封锁协议本源#

在多数教程中,事务隔离级别通常被组织为标准矩阵:

隔离级别脏读不可重复读幻读
读未提交(Read Uncommitted)存在存在存在
读已提交(Read Committed)存在存在
可重复读(Repeatable Read)存在
串行化(Serializable)

这个阶梯式的分类容易让人误以为这是纯粹从理论并发模型出发自顶向下设计的规范。

实际上,该标准在制定时主要借鉴了当时主流关系型数据库所采用的 两阶段封锁协议(2PL) 实现。矩阵中的四个档位,本质上对应了读写锁在持有时长与范围维度上的四种特定组合:

隔离级别写锁持有时长读锁持有时长范围锁(谓词锁)
读未提交持有至事务结束不加读锁
读已提交持有至事务结束语句读完即释放
可重复读持有至事务结束持有至事务结束
串行化持有至事务结束持有至事务结束包含范围保护

在基于封锁的系统里:

  • 读操作不加锁,自然会读取到其他并发事务未提交的修改,即为“脏读”;
  • 读锁在读取完毕后立即释放,后续其他事务便可修改该行并提交,同一事务再次读取同一行便会出现前后不一致,即为“不可重复读”;
  • 读锁保留至事务结束,确保了已读行不会被修改,但若未对查询区间加锁,其他事务仍可向该区间插入新行,再次执行范围查询便会多出记录,即为“幻读”;
  • 加上针对查询区间的范围保护,阻止任何新记录插入该区间,便消除了上述并发异常。

标准中定义的现象,反映的是锁机制在逐步增强时的阶段性表现。

1995 年论文对 ANSI 隔离体系的修正#

1995 年,Hal Berenson、Phil Bernstein、Jim Gray 等学者联合发表了论文《A Critique of ANSI SQL Isolation Levels》,指出了 ANSI SQL-92 定义中存在的系统性缺陷:

第一,异常定义过于狭窄。

标准对脏读的原始形式化表述(记为 A1)大致要求:事务 T1 修改某行,事务 T2 读取该行;随后 T1 回滚且 T2 提交

这种表述排除了 T1 最终提交的情况。假设数据存在约束 x+y=100x + y = 100,T1 执行转账操作将 40 从 xx 转移到 yy。若 T2 在 T1 更新完 xx 但尚未更新 yy 时读取了这两行并完成了计算,随后 T1 成功提交,T2 实际读到了违背业务约束的瞬态数据,然而由于 T1 并未回滚,这一序列并不严格匹配 A1 的字面定义。

论文据此提出了不依赖回滚结局的广义定义(P1、P2、P3),强调禁止的是并发交错模式本身。

第二,漏掉了关键的“脏写”(Dirty Write)。

论文指出,标准完全忽略了针对并发写操作的基本约束(记为 P0):

P0:w1[x]    w2[x]    (c1 or a1)P0:\quad w_1[x] \;\to\; w_2[x] \;\to\; (c_1 \text{ or } a_1)

若允许一个事务覆盖另一个尚未提交事务的写入,一旦先发事务回滚,由于原值已被修改,系统将无法正确还原历史,彻底破坏事务的原子性。因此,即便在最低的读未提交级别下,写锁也必须无条件持有至事务结束

第三,快照隔离(Snapshot Isolation)无法在传统矩阵中定位。

论文提出了依托多版本机制的 Snapshot Isolation(SI):每个事务启动时获取一个全局一致的快照读时间戳,在整个生命周期中均基于该快照读取数据;写操作在提交时进行写-写冲突检测(First-Committer-Wins)。

根据推论:

  • SI 不会出现传统意义上的脏读、不可重复读与幻读;
  • 但 SI 并不能等同于完全可串行化。

传统分类矩阵假定“排除了三种异常即等于串行化”,在基于多版本控制(MVCC)的现代数据库面前不再适用。

快照隔离下的写偏斜(Write Skew)#

SI 无法达到完全串行化的核心原因在于 写偏斜(Write Skew) 异常。

经典案例是医院值班约束:系统要求“任何时刻至少保留一名医生在岗”。假设当前仅有 Alice 与 Bob 两人同时在岗,两人分别在独立的并发事务中申请请假:

-- 事务 A(Alice 提交请假申请)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;
-- 查询返回 2,大于阈值 1,条件成立
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

-- 事务 B(Bob 提交请假申请,与 A 并发执行)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;
-- 读其自身快照同样返回 2,条件成立
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
sql

由于事务 A 与事务 B 修改的是不同的记录行(分别修改了 Alice 与 Bob 的记录),在行级写冲突检测中并不会产生互斥,两个事务均顺利提交。最终导致的结果是在岗医生人数变为 0,违背了业务一致性约束。

写偏斜的特征在于:两个并发事务基于重叠的读取集合做出了决策,随后分别写入了该集合中互不重叠的不同子集。在串行调度下,无论哪一方先提交,后发事务都会读到更新后的状态并中止操作,而 SI 允许了这种非串行的交错发生。

PostgreSQL 自 9.1 起引入了 可串行化快照隔离(SSI),通过跟踪事务之间的读写依赖图(SIREAD 锁)并在检测到可能破坏可串行化的环形依赖时回滚其中一方。MySQL 则未实现 SSI,其最高隔离级别 SERIALIZABLE 通过强制将所有普通读操作转换为加锁读来实现。

InnoDB 的混合并发模型:快照读与当前读#

回到 InnoDB 的默认隔离级别 REPEATABLE READ,其实现机制既不同于纯粹基于 2PL 封锁的传统方案,也不同于纯粹的 Snapshot Isolation,而是构筑了 快照读与当前读双轨运行 的混合模型:

普通的 SELECT 查询均属于快照读。该路径走 MVCC 体系:

  • 事务在首次执行 SELECT 时创建 Read View(记录当前活跃的事务 ID 列表);
  • 沿着数据行的 roll_pointer 遍历 undo log 版本链,寻找对当前 Read View 可见的版本。

在此路径下,普通的查询不会加锁,不会感知并发插入的新行,具备快照隔离的特征。

双轨制交汇:当前读导致的可见性变更#

由于快照读与当前读并存,在两条路径相互交错时,会出现直观上看似不符合快照语义的现象。考虑如下执行时序:

-- 会话 A
BEGIN;
SELECT * FROM t_user WHERE id = 10;
-- 返回 Empty set(此时快照中无 id=10 的数据)

                                      -- 会话 B
                                      BEGIN;
                                      INSERT INTO t_user VALUES (10, 'Bob');
                                      COMMIT;

-- 会话 A 继续执行当前读更新
UPDATE t_user SET name = 'Robert' WHERE id = 10;
-- 执行结果提示:Rows matched: 1, Changed: 1

SELECT * FROM t_user WHERE id = 10;
-- 此时快照读居然能够查出该行:(10, 'Robert')
sql

在会话 A 最初的快照读中,id=10 并不存在;随后会话 B 插入并提交了该行。当会话 A 执行 UPDATE 时,由于更新属于当前读,它直接检索并锁定了磁盘上最新的提交行,将该行的事务 ID(trx_id)更新为会话 A 自身的事务 ID。

当会话 A 再次发起普通的快照读时,根据 Read View 的通用规则,事务自身做出的修改对自身永远可见。由于该行的最新版本打上了会话 A 自己的 trx_id,它便顺理成章地进入了会话 A 后续快照读的结果集中。

sequenceDiagram
    autonumber
    actor A as 会话 A (RR 级别)
    participant MVCC as MVCC 快照体系 (Read View)
    participant Storage as 物理引擎 (当前读路径)
    actor B as 会话 B (并发事务)

    Note over A: 事务 A 启动
    A->>MVCC: 首次执行 SELECT (id=10)
    Note over MVCC: 生成 Read View;记录版本链中无 id=10<br/>返回结果: Empty set

    Note over B: 事务 B 启动并执行写入
    B->>Storage: INSERT INTO t_user VALUES (10, 'Bob')
    B->>Storage: COMMIT (物理行版本打上 trx_id = B)

    Note over A: 事务 A 执行当前读更新
    A->>Storage: UPDATE t_user SET name = 'Robert' WHERE id = 10
    Note over Storage: 当前读绕过 Read View 直接定位最新提交行<br/>施加行锁,将记录的 trx_id 改写为事务 A !<br/>返回: Rows matched: 1, Changed: 1

    Note over A: 事务 A 再次发起快照读
    A->>MVCC: 再次执行 SELECT (id=10)
    Note over MVCC: 依据 Read View 可见性判定:<br/>该行最新版本的 trx_id 为事务 A 自身<br/>★ 自身修改永远可见 → 命中并返回 (10, 'Robert')
mermaid

这一现象是 MVCC 读与锁保护写双轨组合下的确定性行为:当前读更新操作将外部提交的新行打上了自身事务的版本标签,使得后续的快照查询能够识别该行。

MySQL 默认 RR 的历史兼容性原因#

从并发吞吐角度分析,读已提交(RC)不需要引入间隙锁,行级锁粒度更细,锁释放时机更早,死锁概率显著降低。而可重复读(RR)虽然引入了间隙锁,但如前所述依然无法完全抵御写偏斜。

MySQL 之所以长期将 REPEATABLE READ 设为默认隔离级别,主要源自早期主从复制架构的技术约束:

在 MySQL 5.1 之前,binlog 仅支持基于语句的复制格式(Statement-Based Replication,SBR),从库完全依靠按序重新执行主库记录的 SQL 语句来保持数据同步。

在 RC 隔离级别下,假设表内初始并不存在 id = 5 的数据:

  • 事务 1 执行 DELETE FROM t WHERE id = 5(未匹配到行,不持锁);
  • 并发事务 2 执行 INSERT INTO t VALUES (5, ...) 并优先提交;
  • 事务 1 随后提交。

由于 binlog 必须在事务提交时整体写入,从库记录的 SQL 顺序将是事务 2 的 INSERT 在前,事务 1 的 DELETE 在后。从库顺序回放时,先插入数据随后将其删除,导致从库无此数据,而主库上保留了该数据,引发主从不一致。

在 RR 隔离级别下,间隙锁填补了这一漏洞:事务 1 的 DELETE 会在 id = 5 所在的间隙加上 Gap Lock,迫使并发的 INSERT 处于等待状态,确保了主库实际执行与 binlog 提交顺序的严格对应。

随着 MySQL 5.1 引入行级复制格式(Row-Based Replication,RBR,直接记录每行数据的物理变更前像与后像),并在 5.7 后普遍成为默认推荐配置,主从复制已不再依赖 SQL 执行顺序的一致性。原先迫使系统必须默认采用 RR 的物理技术约束早已不复存在,但出于避免破坏海量存量业务预设语义的稳妥考量,这一默认配置被保留至今。


四、总结与内部状态观测#

探讨这些底层机制的目的,在于建立一个能够解释实际工程现象的逻辑链条。当对某些行为存疑时,可以通过 MySQL 自身暴露的性能视图直接观测内部状态。

查看日志与持久化状态#

通过 SHOW ENGINE INNODB STATUS\G 可以观察系统的 LOG 段指标:

SHOW ENGINE INNODB STATUS\G
-- 重点关注 LOG 部分:
-- Log sequence number          345678910   -- 当前生成的最新 LSN
-- Log flushed up to            345678000   -- 已经写入并落盘的 redo LSN
-- Pages flushed up to          345670000   -- 已完成落盘的脏页对应的最小 LSN
-- Last checkpoint at           345668000   -- 检查点位置
sql
  • Log sequence numberLog flushed up to 的差值,反映了滞留在 OS 缓存中等待 fsync 的日志量;
  • Pages flushed up toLast checkpoint at 则反映了当前脏页刷新与检查点推进的同步滞后程度。

检查事务与锁分配#

在排查事务阻塞与间隙锁影响范围时,可以通过 Performance Schema 实时检视:

-- 查看活跃事务及其执行时长
SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.INNODB_TRX;

-- 查看锁分配详情(包含锁类型与锁定数据区间)
SELECT 
    engine_transaction_id, 
    object_name, 
    index_name, 
    lock_type, 
    lock_mode, 
    lock_status, 
    lock_data 
FROM performance_schema.data_locks;
sql

在多会话并发执行 UPDATE 或范围 DELETE 时,通过 lock_mode 可以清晰确认系统施加的究竟是单行锁(X,REC_NOT_GAP)、间隙锁(X,GAP)还是临键锁(X),无需单纯依赖文字描述去推测锁范围。

数据库底层机制的演进往往是在空间占用、硬件写入开销、并发性能与向后兼容性之间的权衡结果。理解这些设计背后具体要满足的不变量与历史条件,能够更准确地推导系统在各种边界条件下的真实行为。


参考资料#

  • Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil. A Critique of ANSI SQL Isolation Levels. SIGMOD 1995.
  • Mohan, Haderle, Lindsay, Pirahesh, Schwarz. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. TODS 1992.
  • Gray, Reuter. Transaction Processing: Concepts and Techniques. Morgan Kaufmann, 1993.
  • Kleppmann. Designing Data-Intensive Applications, Chapter 7. O’Reilly, 2017.
  • MySQL 8.0 Reference Manual, InnoDB Locking and Transaction Model.

本站相关阅读:

再看 MySQL:被术语盖住的三个地方
https://fxj.wiki/blog/rethinking-mysql
Author 玛卡巴卡
Published at 2026年8月10日
Comment seems to stuck. Try to refresh?✨