MySQL日志全解
我们的故事从一个简单的 UPDATE 事务开始。
为了在 极致的性能(内存) 和 可靠(磁盘) 之间找到完美的平衡,InnoDB 内部有着一套复杂且环环相扣的机制。
到达引擎层之前还有很多步骤,本篇不涉及
Buffer Pool
Buffer Pool 是 InnoDB 在 内存中开辟的一块区域,用来 缓存磁盘上的数据页和索引页。它是 InnoDB 性能的基石。
为什么要有 Buffer Pool?
因为计算机世界存在着一条巨大的速度鸿沟——内存读写是纳秒级,而磁盘读写是毫秒级(慢几十万倍)
所以 InnoDB 为了性能,制定了一条规矩:“所有的操作,必须在内存中进行”
- 读数据: 必须先把磁盘页加载到 Buffer Pool,再由 CPU 读取。
- 写数据: 不直接写硬盘! 而是先修改 Buffer Pool 里的页。
这里就诞生了“脏页”这个概念
当我们执行 一条
UPDATE语句后:
- InnoDB 只改了 Buffer Pool 里的页,还没同步到磁盘。
- 此时,内存里的数据和磁盘上的数据是不一致的。
- 这个时候的内存页,就叫做 “脏页”。
刷盘(Flush):InnoDB 有后台线程(如 MasterThread 或 PageCleanerThread)会在系统负载较低时,或者在特定时机(如
Checkpoint),把这些“脏页”异步地写回磁盘文件。这就有了一个 问题:如果中途断电了怎么办?内存是易失性的,里面的数据会瞬间消失。
一个 UPDATE 事务的执行
我们的 UPDATE 事务登场了。它要在 Buffer Pool 中修改数据。
第 0 步:加锁
一开始它就面临着一个严峻的挑战:并发冲突。如果两个事务同时试图修改 id=1 的数据,谁先谁后?
于是,InnoDB 祭出了 锁 机制,事务在执行 UPDATE 前,会先去向锁管理器申请锁。如果申请成功,才能继续后续的操作。如果失败,当前事务就会进入等待状态,直到超时或对方释放锁。
本文不涉及关于并发安全问题以及锁机制的详细内容
第 1 步:记录“后悔药”(Undo Log)
事务的原子性 是指一个事务中的操作,要么全部成功,要么全部失败。
所以如果事务执行一半失败了,或者用户执行了 ROLLBACK,事务就需要回滚。InnoDB 需要某种机制来撤销所有已做的修改。
这就有了我们的第一个日志,在修改数据之前,InnoDB 会先记录“如何撤销”这次修改。这就是 Undo Log (撤销日志)。
它是一种 逻辑日志,记录的是“如何撤销刚才的修改”:
INSERT会记录一条对应的DELETE。DELETE会记录一条对应的INSERT。UPDATE会记录一条反向的UPDATE(旧值)。
在修改数据之前,InnoDB 会先把数据的“旧值”记录到 Undo Log 中。
值得一提的是,Undo Log 本身也是数据,它存储在 Undo 页中,Undo 页也和数据页一样,会被缓存在 Buffer Pool 中,也会变“脏”。
第 2 步:保护数据(Redo Log)
正如前文所说,内存具有易失性,一旦中途断电,BufferPool 中的数据会瞬间蒸发。
Undo Log 只能回滚,不能把丢失的数据找回来。更别提 Undo Log 本身也是数据,它也面临着丢失的问题。
所以为了实现事务的 持久性,MySQL 需要保证,在告诉用户事务“提交成功”之前,必须 把这个修改后的 16KB 的数据页,完完整整地写回到磁盘的数据文件中。
这简直就是性能杀手:
- 首先是 随机 I/O:数据库的数据页是散落在磁盘的各个角落的。你修改
id=1可能在磁盘头部,修改id=10000可能在磁盘尾部。每次提交事务都要磁头乱跳或者寻址,速度极慢。 - 其次就是 写放大: 你可能只改了一个 16KB 页面里的几个字节,但你必须把整个 16KB 的页重新写入磁盘。
而且这没有解决“崩溃恢复”的问题,断电重启后,数据还是丢了。
所以为了解决这个问题,InnoDB 引入了 Redo Log (重做日志),它是一种 物理日志,它记录的是“对数据页做了什么修改”,比如:“在 Page5 的偏移量 100 处,写入了’ABC’”。
目前为止,本文一直说的都是 InnoDB 引入,因为 Undo 和 Redo log 确实就是 InnoDB 独有的。
MySQL 采用的是 “可插拔”存储引擎架构,Server 层是它的“大脑”,负责“指挥”,它 不涉及数据的物理存储细节,核心负责 SQL 的解析、优化、执行,以及一些公共功能(比如全局日志(binlog),函数等等)。这一层是 MySQL 原生实现的,无法替换。
而存储引擎层是数据的物理落地层,负责数据的实际存储和检索 以及 引擎专属的特性实现,像 InnoDB 引擎就实现了 事务、行锁、MVCC、Redo/Undo Log、 索引组织表等。Server 层定义了一套通用的 Handler API,任何存储引擎只要适配了这套接口,就能无缝接入。
但只引入这么个东西,问题还是没有解决。
因此 InnoDB 还为它设计了一个特殊的工作机制——Write-Ahead Logging (WAL)(日志先行)。
核心原则就是:对数据的修改,必须先写日志,再写实际的数据文件。
- 修改数据时先在
Buffer Pool中修改,同时 把这个修改动作写入内存中的Redo Log Buffer。 - 在事务提交时,必须确保
Redo Log已经从Redo Log Buffer刷入到磁盘上的Redo Log File。 - 至于
Buffer Pool中的“脏页”什么时候刷回磁盘,可以晚一点,不着急。
只要日志写进去了,就认为数据安全了。哪怕内存崩了,重启后也能根据日志把数据“重做”一遍。
innodb_flush_log_at_trx_commit控制事务提交时Redo Log的刷盘策略:
1:默认值,每次事务提交时,都必须将Redo Log从 buffer 同步写入到磁盘文件,才算提交成功。0:事务提交时,不立即将Redo Log写入磁盘,而是等待主线程每秒一次的刷盘操作。如果 MySQL 在这 1 秒内宕机,会丢失最多 1 秒的数据。2:事务提交时,只将Redo Log写入操作系统的页缓存,而不是直接写入磁盘文件。由操作系统决定何时将缓存刷到磁盘。如果只是 MySQL 进程宕机而操作系统未宕机,数据不会丢失;但如果服务器整机断电,则会丢失数据。
为什么能行?
- 它将对数据文件的 随机 I/O,转换成了对日志文件的 顺序 I/O。脏页刷盘是随机分布在磁盘各处的(随机 I/O,很慢)。但写
Redo Log永远是追加写入(顺序 I/O,快)。 - 只要
Redo Log刷盘了,就算数据库崩溃,“脏页”没来得及刷盘,重启时 MySQL 也可以根据Redo Log把“脏页”上的修改“重做”一遍,恢复到崩溃前的状态。
其实在 InnoDB 的 物理存储层面,也存在原子性问题。
想象一下 B+ 树的分裂:当你插入一条数据,导致叶子节点满了,InnoDB 需要申请一个新页,把旧页的一半数据挪过去,修改父节点的指针,甚至可能引起父节点的分裂。
这是一系列复杂的物理操作。如果在这个过程中断电了:新页申请了但没挂到树上,或者父指针指错了地方,整棵 B+ 树的索引结构就 彻底损坏 了。
为了保证底层物理结构的完整性,InnoDB 引入了 Mini-Transaction (MTR)。
- 一组对 B+ 树的物理修改(涉及多个页),被视为一个不可分割的单元。InnoDB 会在内存中将这组操作对应的
Redo Log准备好。- 在 MTR 结束时,这一批
Redo Log会被 原子地 拷贝到全局的Redo Log Buffer中。你要么在Redo Log里看到完整的“页分裂”过程,要么完全看不到。绝不会出现“只分裂了一半”的日志。
但需要注意,这里的 Redo Log 日志以及它的 WAL 机制都 只是为了持久化而做的性能优化方案,在系统正常运行时,Buffer Pool 中的脏页刷回磁盘,完全不需要看 Redo Log。Redo Log 只有在“非正常”时刻(崩溃恢复)才会被读取。
那既然
Redo Log是顺序读取,速度很快,为什么不干脆让后台线程去读Redo Log,然后根据日志内容更新磁盘上的数据文件呢?这样岂不是连“脏页”都不用管了。在 “正常运行” 场景下,它有三个巨大的问题:
第一个是 合并同类项效应:
Redo Log记录的是“动作”: 如果你对同一个数据页修改了 100 次,Redo Log会记录 100 条日志,如果要根据Redo Log刷盘,你需要读取这 100 条日志,然后对磁盘文件执行 100 次修改操作。Buffer Pool里的脏页记录的是“结果”:无论你修改了 Page A 多少次,在内存里的这个脏页,永远只有 1 个。它是这 100 次修改后的最终状态。等到要刷盘的时候,只需要把这个页写 1 次 磁盘。第二点,
Redo Log只是把 写日志 变成了顺序 I/O,但它并没有把 应用到数据文件 这个动作变成顺序 I/O。即使你顺序读取了Redo Log,知道了要改哪,但你要改的 目标文件(.ibd 数据文件)依然是散落在磁盘各处的,当你去更新数据文件时,磁头还是得到处跳。相比之下,InnoDB 的后台刷脏页机制会做很多优化,比如它会检测哪些脏页在物理磁盘上是相邻的,尽量把它们一起刷,或者按照 Space ID 排序刷,这比盲目照着
Redo Log刷要高效得多。第三点,也是这个方案最不合理的一点:
Redo Log是 物理日志,它记录的是“在 Page X 的 Offset Y 写入了 Z”。它 不包含 整个数据页的完整内容。如果你想通过Redo Log去更新磁盘上的数据,你手里只有一条日志:“在偏移量 100 处写入 ‘A’”。你不能直接往磁盘写一个只有 ‘A’ 的文件。你必须先把 原始数据页 从磁盘读出来。把 ‘A’ 拼接到这个页里。再把这个页写回磁盘。这就尴尬了,既然这个“修改后的完整页”已经在Buffer Pool里躺着了,你直接把它写下去不就好了吗?为什么非要“读日志 -> 读磁盘旧页 -> 拼装 -> 写盘”这么折腾一圈呢?
Redo Log 在磁盘上的存储形式比较特别,磁盘上的物理文件,通常命名为 ib_logfile0、ib_logfile1 等。这些文件构成一个逻辑上的环形队列,从头到尾循环写入,当写到末尾时,会回到开头覆盖旧的日志。
- InnoDB 使用
writepos(当前写入位置)和checkpoint(擦除点)两个指针来管理这个环形缓冲区。 writepos追上checkpoint时,表示Redo Log空间耗尽。- 此时 MySQL 必须 阻塞所有更新操作,强制将
Buffer Pool中的脏页刷盘,以推进checkpoint释放日志空间(这被称为 “FuriosFlushing“,是性能抖动的常见原因)。
为了避免这种“性能抖动”,InnoDB 引入了 Adaptive Flushing (自适应刷盘) 算法。
- 后台线程会持续监控两个核心指标:
Redo Log的生成速率 和 脏页的产生速率。- InnoDB 不需要等到
Redo Log快满才动手。它会根据当前的生成速率,计算出一个合适的“刷盘速度”。如果 Log 涨得快,后台线程就默默加快刷脏页的频率;如果 Log 涨得慢,就降低频率节省 I/O。这个机制保证了数据库的 I/O 负载是一条平滑的曲线,而不是锯齿状的波峰波谷,让业务感受不到后台的“忙碌”。
第 3 步:提交数据(Binlog 与 2PC)
客户端执行了 COMMIT,提交了事务。
这里需要补充一个背景,一般来说,我们不会只有一台 MySQL,而是有多台,形成一个集群,并实现 主从复制(Replication)——就是一台 MySQL 作为主库,其它作为从库,分担读数据的压力。
MySQL 的 Server 层 通过 Binlog (归档/二进制 日志) 实现主从复制。它记录了 所有对数据库造成数据更改的 DDL(Data Definition Language,数据定义语言,建表/改结构)和 DML(Data Manipulation Language,数据操纵语言,增删改)。
其实从这里就能看出,Binlog 必须设计在 Server 层,只有 Server 层才具备解析 SQL 的能力,它能区分什么是 DDL 什么是 DML。底层的存储引擎只负责执行具体的物理读写指令,无法感知上层的业务逻辑。
Binlog 是逻辑日志,而且有多种日志格式。
STATEMENT:记录原始的 SQL 语句。优点是日志文件小,缺点是在某些情况下可能导致主从数据不一致(例如,SQL 中包含UUID()、NOW()等不确定性函数)。ROW:记录每一行数据被修改的具体内容。这是目前 默认且推荐 的格式。优点是能精确地记录数据变更,保证主从一致性,但 缺点是日志文件可能会变得很大。MIXED:是STATEMENT和ROW的混合模式。MySQL 会根据执行的 SQL 语句,自动选择更合适的格式。一般情况下使用STATEMENT,但在可能导致数据不一致时,会自动切换到ROW格式。
Binlog 的写入也是先写内存,每个线程都有一个独立的 binlog cache。
如果事务过大,超过了
binlog_cache_size设置的大小,MySQL 会被迫将多余的数据写入 临时文件。这会导致磁盘 I/O 飙升。因此,对于经常有大事务(如大批量导入、大字段更新)的系统,需要适当调大此参数。
由 sync_binlog 参数控制 Binlog 的刷盘策略:
0:默认值,但在高可用环境下不推荐。事务提交后,Binlog只写入操作系统的页缓存,由操作系统决定何时刷盘。性能最好,但服务器宕机时Binlog可能会丢失。1:每次事务提交,都将Binlog同步写入磁盘。最安全,但对性能有一定影响。N(N > 1):每 N 次事务提交,才将Binlog同步写入磁盘。
所谓双 1 配置就是指的把
innodb_flush_log_at_trx_commit和sync_binlog两个都配置为 1,这样每次事务提交时,都会同步把Redo Log和Binlog写入磁盘,一致性最高。
在主从架构中,主库将其 Binlog 传递给从库,从库接收并重放 Binlog 中的事件,从而实现与主库的数据同步(主从复制)。
利用 Binlog 还能进行精确的 数据恢复——通过使用全量备份(如 mysqldump)加上 Binlog,可以将数据库 恢复到任意一个精确的时间点(先恢复全量备份,然后重放指定时间范围内的 Binlog 事件)。
以及一个让 Binlog 在 MySQL 里几乎无法被去掉的场景——审计(Auditing)。
在现代大数据架构中,Binlog 的地位甚至已经超越了 MySQL 本身。其作用不仅是复制,更是 CDC (Change Data Capture)。
大数据(Flink/Spark)、搜索引擎(Elasticsearch)、缓存(Redis)的数据更新,几乎全靠监听 MySQL 的 Binlog(通过 Canal 或 Debezium)。通过分析 Binlog 的内容,追踪数据库的所有数据变更历史,进行数据分析与问题排查。
Binlog为什么不能用来做 Crash Recovery?假设 MySQL 崩溃了。重启时,
Binlog里记录着:“给 ID = 2 的 c 字段加 1”。此时磁盘上的数据页可能有两种状态:
- 已落盘:该操作对应的脏页已经刷入磁盘。
- 未落盘:该操作仅在内存中完成,磁盘上的数据页还是旧的。
Binlog 是追加写的,它只知道操作发生了,但 不知道这个操作对应的物理数据页是否已经真正写入了磁盘。
这就是
Binlog的问题所在,它没法告诉你数据页的状态。同时因为 Binlog 是 逻辑日志。所以它 无法保证幂等性(Idempotency)。如果 MySQL 重启后强制重放 Binlog,对于“已落盘”的数据页,再次重放会导致数据被 重复修改(例如值从 2 变成 3,再变成 4),从而导致数据错乱。
Redo Log记录的是物理修改(其实也不是纯物理日志),所以主要还是靠数据页头部的 LSN 作为判断依据,以此来实现 Idempotency。
既然有主从架构,那就会有主从切换。在传统的 MySQL 主从复制中,从库同步数据依赖的是“文件名 + 物理偏移量”(例如:master-bin.00032 pos 45602),被称为 物理坐标。这简直是运维的噩梦,因为一旦主库宕机,新主库上位,从库必须手动计算并重新指向新的物理位置。这个过程极易出错,稍有偏差就会导致数据丢失或同步中断。
现代 MySQL 引入了 GTID (全局事务标识符) 来解决这个问题——每一个事务在提交时,都会被分配一个全局唯一的 ID(Server_UUID:Transaction_ID),这个 ID 会随着 Binlog 传遍整个集群,且在整个生命周期内保持不变。在主从切换时,从库只需要对新主库说:“我有 GTID 1 到 100 的数据,后面的全给我。”新主库就能自动计算出缺少的数据并发送过去。GTID 屏蔽了底层的物理文件位置,让高可用架构(如 MHA、Orchestrator)的实现变得简单可靠。
在 MySQL 的分层架构中,Server 层和引擎层各自维护了一份日志:
- Binlog(归档日志):属于 Server 层(上层)。它是下游(从库、数据分析)的数据源头,决定了 “主从是否一致”。
- Redolog(重做日志):属于 InnoDB 引擎层(底层)。它是崩溃恢复的唯一依据,决定了 “本机是否不丢数据”。
由于这两份日志由不同组件维护,如果不能保证它们 原子性 地写入成功,就会出现 数据一致性问题。
这是一个发生在一个单机系统的 Server 层 和 引擎层 之间的“分布式事务问题”。
假设我们执行一个 UPDATE 操作:
场景 A(先写 Redolog,后写 Binlog):Redolog 写完后系统宕机,Binlog 没来得及写。
- 结果:重启后,主库通过 Redolog 恢复了这行数据。但因为 Binlog 缺失,从库永远收不到这条更新。
- 判定:主从不一致(主库多,从库少)。
场景 B(先写 Binlog,后写 Redolog):Binlog 写完后系统宕机,Redolog 没来得及写。
- 结果:重启后,主库因为 Redolog 缺失,事务回滚(数据消失)。但从库收到了 Binlog,执行了更新。
- 判定:主从不一致(主库少,从库多)。
为了解决这个问题,MySQL 引入了内部 XA 事务机制,也就是 两阶段提交 (Two-Phase Commit, 2PC) 机制。
这是一种严密的协作协议,将事务提交拆解为两个阶段:
Prepare 阶段:
- InnoDB 将事务的 Redo Log 写入 Redo Log Buffer,并根据
innodb_flush_log_at_trx_commit策略执行fsync刷入磁盘。 - InnoDB 将 Redo Log 中该事务的状态标记为
PREPARE,并记录全局事务 ID(XID)。 - 此时引擎层通知 Server 层:“数据已持久化,随时待命,可提交也可回滚。”
- InnoDB 将事务的 Redo Log 写入 Redo Log Buffer,并根据
Commit 阶段:
- Server 层将 Binlog 写入 Binlog Cache,并根据
sync_binlog策略执行fsync刷入磁盘。- Binlog 的成功落盘是事务成功的唯一“逻辑分水岭”。一旦 Binlog 成功落盘,此时事务在逻辑上已经成功。
- Server 层调用 InnoDB 接口通知提交。InnoDB 将 Redo Log 状态置为
COMMIT- 这一步通常仅需内存操作,无需强制刷盘。因为只要 Binlog 在,崩溃恢复时就能推导出该事务有效。
- Server 层将 Binlog 写入 Binlog Cache,并根据
此时我们来看看 2PC 是怎么保证数据一致性的同时完成崩溃恢复的。
假设服务器在 COMMIT 刚发出或刚写完时突然断电,重启时,InnoDB 必须利用日志将数据库状态恢复到断电前的那一瞬间。
第一步:InnoDB 启动时,会去读取最近一次的 Checkpoint LSN。Checkpoint 保证了在此之前的脏页都已刷盘,因此无需恢复。
第二步:然后 InnoDB 从 Checkpoint LSN 开始,无差别地 应用所有 Redo Log。此时,Buffer Pool 被严格还原到宕机前一刻的状态(包含已提交和未提交的事务)。
第三步:此时内存中存在处于 PREPARE 状态的事务,InnoDB 需要决定是 提交 还是 回滚。 裁决的最高准则是:Binlog 的完整性。
那怎么判断一个事务的 Binlog 是否“完整”?MySQL 依靠的是特定的“结束标记”。
在 ROW 格式下,一个完整的事务在 Binlog 物理文件中长这样:
- Header:
GTID_EVENT/BEGIN - Body:
TABLE_MAP_EVENT(操作了哪张表) - Body:
WRITE_ROWS_EVENT/UPDATE_ROWS_EVENT(改了哪些行) - Footer(关键):
XID_EVENT
MySQL 在扫描 Binlog 时,只有读到了最后的这个 XID_EVENT(它里面记录了事务的 XID),才认为这个事务是 “完整” 的。
InnoDB 提取 Binlog 中所有拥有完整 XID_EVENT 的事务 ID,与 Redo Log 中的 PREPARE 事务进行核对:
- 情况 A:Binlog 中存在该 XID
- 场景推演:宕机发生在 2PC 的第二阶段(Binlog 已落盘,但 InnoDB 还没来得及改写 Commit 状态)。
- 结论:提交事务。
- 理由:Binlog 既然已经写入,说明从库可能已经同步了这条数据。为了保证主从一致,主库必须“认账”,将该事务在引擎层补置为 COMMIT 状态。
- 情况 B:Binlog 中不存在该 XID
- 场景推演:宕机发生在 2PC 的第一阶段与第二阶段之间(Redo Log 好了,但 Binlog 没写完或没刷盘)。
- 结论:回滚事务。
- 理由:Binlog 没写入,意味着这个事务在逻辑上失败了,且从库永远不会收到。为了保证一致性,主库必须利用 Undo Log 撤销该事务在内存中的修改。
从上面这个流程也能看出来,“双 1”配置(innodb_flush_log_at_trx_commit=1 和 sync_binlog=1)是最安全的,但也意味着 COMMIT 时至少有两次 fsync,性能很差。
MySQL 5.6 引入了 Binary Log Group Commit (BLGC) (组提交)机制,将提交过程细分为三个队列阶段,允许多个并发事务“搭便车”:
也就是多个事务按顺序将自己的 Binlog 写入操作系统的文件缓存。其中一个事务成为组长,“代表”大家,调用一次 fsync,将整组事务刷入磁盘。将 N 次磁盘 I/O 合并为 1 次,极大地提升了并发吞吐量。所有“搭车”成功的事务,再按顺序通知 InnoDB 进行引擎层提交。
组提交机制不仅解决了 2PC 的性能问题,还带来了两个重要的参数权衡:
binlog_group_commit_sync_delay:为了多攒几个事务,故意等待多少微秒。binlog_group_commit_sync_no_delay_count:为了多攒几个事务,故意等待凑齐多少个事务。
ok,我们的事务终于成功提交了!终于安全落盘了…吗?
第 4 步:终极保险(页断裂与双写)
还有一个更极端、更恐怖的场景:页断裂(逻辑意义上的“断裂”)。
Linux 文件系统页的大小默认是 4KB。而 MySQL InnoDB 存储引擎的页大小默认是 16KB。
MySQL 程序运行在操作系统之上,依赖操作系统的文件系统与磁盘交互。
所以当 InnoDB 需要将内存中一个 16KB 的脏页刷到磁盘时,操作系统需要执行 4 次 4KB 的 I/O 写入操作。
关键问题在于,这 4 次 I/O 操作 并非原子操作,如果在这期间服务器突然断电或操作系统崩溃,磁盘上的这个 16KB 数据页就损坏了——它既不是修改前旧数据,也不是修改后的新数据,而是一种“半成品”状态,这就是 部分写 问题。
这种“页数据损坏”是致命的,并且 无法通过 Redo Log 来修复。
因为 Redo Log 记录的是“对页做了什么修改”,它的前提是页本身结构是完整的,如果页都损坏了,重做修改毫无意义。
Redo Log在磁盘上也是按块存储的,每块 512 字节。这与磁盘扇区大小一致,因此Redo Log的写入通常能保证原子性。
为了解决这个问题,InnoDB 引入了“双写”机制。简单来说,就是在把脏页真正写入分散的数据文件之前,先找个地方把它们 完整地备份 一遍。
脏页刷盘的流程变成了三步:
- 内存拷贝:脏页刷盘时,不直接写磁盘。而是先将脏页复制到内存中的 Doublewrite Buffer 区域(大小通常为 2MB)。
- 顺序写备份:当内存 DWB 攒满(例如 1MB)后,InnoDB 发起一次系统调用,将这批页 顺序写入 到磁盘上的系统表空间(ibdata)或独立的
.dblwr文件中。 - 离散写落盘:只有在步骤 2 的备份落盘成功(fsync)后,InnoDB 才会根据脏页的表空间 ID 和页号,将其 随机写入 到真正的
.ibd数据文件中。
在 8.0.20 之前,DWB 寄居在
ibdata1共享表空间中,容易与 Undo Log、数据字典产生 I/O 争用。8.0.20 后,DWB 被分离为独立的.dblwr文件(由innodb_doublewrite_dir控制),显着降低了争用,提升了吞吐量。无论在哪,DWB 的磁盘空间大小通常都是 2MB(即 128 个 16KB 的页),且这 2MB 空间在逻辑上(或物理上)分为两个 1MB 的插槽(Slot)。
在步骤 2 写入一个插槽的同时,内存 DWB 可以使用另一个插槽接收新的脏页,实现并发。
当 InnoDB 重启时,如果发现某个数据页已损坏(页断裂),它会先去 Doublewrite 磁盘区域找到该页的 完好副本,用副本覆盖坏页,然后再 开始执行 Redo Log 进行恢复。
DWB 虽然安全,但带来了一个代价:写放大。数据需要写两次磁盘(一次顺序,一次随机),I/O 压力翻倍。
现代的高端 SSD 硬件(如 Fusion-IO)或特定文件系统(如支持原子写的 XFS)可以保证 16KB 写入的原子性。如果底层硬件支持原子写(即保证 16KB 要么全写成功,要么全不写),那么“页断裂”问题就不复存在。在这种环境下,可以将 innodb_doublewrite 设置为 OFF。这将消除 50% 的冗余 I/O,大幅提升数据库的写性能。
Undo Log 的第二使命——MVCC
我们的事务终于安全落盘了,但 Undo Log 不会立即被删除,它还有第二个使命。在多用户并发访问数据库的现实世界中,它从“后悔药”摇身一变,成为了 MVCC (多版本并发控制) 的基石。
为了解决“读-写”冲突,避免读取数据时加锁阻塞,MySQL 利用 Undo Log 保留了数据的历史版本:
- 写操作:修改数据时,旧版本并不直接覆盖,而是被移动到 Undo Log 中。
- 读操作:根据事务开启的时间点,去读 Undo Log 里的“历史快照”。
- 结果:读不阻塞写,写不阻塞读。
根据 MVCC 的需求,InnoDB 对 Undo Log 的生命周期管理如下:
- Insert Undo Log:新插入的数据没有旧版本,事务提交后即可 立即删除。
- Update Undo Log:由于 MVCC 需要利用它构建历史快照,事务提交后 不能立即删除。
- Purge 线程:后台线程会持续监控,只有当确认全系统没有任何事务再需要这些旧版本时,才会真正清理这些日志并释放空间。
本篇不涉及 MVCC 的详细讲解