MySQL为什么 采用“先写日志,再写磁盘数据文件”的机制

MySQL 采用“先写日志,再写磁盘数据文件”的机制,在数据库领域被称为WAL(Write-Ahead Logging,预写式日志)。

设计这一机制的核心原因只有一句话:用极快的磁盘“顺序写”替代极其缓慢的磁盘“随机写”,先顺序写日志解决当下的写入延迟,再异步刷回 B+ 树保障后续的读取效率,正是数据库在“读”与“写”之间达成的精妙平衡。


一、 为什么不能直接把数据写回磁盘?

如果每次执行UPDATE、INSERT或DELETE都直接去修改磁盘上的数据文件(.ibd文件),数据库会立刻面临两个严重的性能灾难:

  1. 大量随机 I/O(寻道极慢):
    MySQL(InnoDB 引擎)的数据在磁盘上是按 B+ 树结构组织的。一个事务修改的几行数据,物理位置往往分散在磁盘的不同角落。如果每次更新都直接落盘,磁盘就需要不断地在不同位置之间跳转定位(随机 I/O)。无论是机械硬盘的磁头寻道,还是 SSD 的随机写入,随机 I/O 的速度都远低于顺序 I/O。

  2. 严重的写放大(改几个字节也要写 16KB):
    InnoDB 与磁盘交互的最小单位是页(Page,默认大小为 16KB)。哪怕你只是把某一行里的一个字段从1改成了2(仅改动几个字节),如果直接落盘,也必须把这整整 16KB 的数据页完整写回磁盘,性价比极低。


二、 磁盘“顺序写”与“随机写”的速度到底差多少?

在真实生产环境中,“顺序写日志”和“随机写数据页”的耗时差距,在传统机械硬盘(HDD)上相差数百倍;在现代固态硬盘(SSD/NVMe)上,叠加 MySQL 自身的写入机制后,实际耗时依然相差 10 倍到 100 倍以上。

1. 硬件层面的单次耗时对比

不同存储介质在进行随机写和顺序写时,底层的物理耗时差距显著:

存储介质随机写单次延迟顺序写等效单次延迟持续写入吞吐量(随机 vs 顺序)速度差距倍数
机械硬盘 (HDD, 7200转)约5∼10 ms5 \sim 10\text{ ms}5∼10ms
(受限于磁头物理寻道+盘片旋转)
约0.01∼0.05 ms0.01 \sim 0.05\text{ ms}0.01∼0.05ms
(磁头不动,顺着磁道连续写)
约1∼2 MB/s1 \sim 2\text{ MB/s}1∼2MB/svs.150∼200 MB/s150 \sim 200\text{ MB/s}150∼200MB/s100∼500100 \sim 500100∼500倍
普通固态 (SATA SSD)约0.1∼0.5 ms0.1 \sim 0.5\text{ ms}0.1∼0.5ms
(即100∼500 μs100 \sim 500\ \mu\text{s}100∼500μs)
约0.01∼0.03 ms0.01 \sim 0.03\text{ ms}0.01∼0.03ms
(即10∼30 μs10 \sim 30\ \mu\text{s}10∼30μs)
约50∼100 MB/s50 \sim 100\text{ MB/s}50∼100MB/svs.500 MB/s500\text{ MB/s}500MB/s5∼155 \sim 155∼15倍
高性能固态 (NVMe SSD)约20∼100 μs20 \sim 100\ \mu\text{s}20∼100μs
(受限于闪存擦写块与垃圾回收)
约2∼10 μs2 \sim 10\ \mu\text{s}2∼10μs
(直接写入连续闪存页)
约300∼600 MB/s300 \sim 600\text{ MB/s}300∼600MB/svs.3000∼7000 MB/s3000 \sim 7000\text{ MB/s}3000∼7000MB/s5∼105 \sim 105∼10倍以上

为什么没有磁头的 SSD 随机写依然慢?
SSD 的闪存颗粒不能直接覆盖写,必须**“先擦除、再写入”,且擦除块(Block,通常几 MB)远大于写入页(Page,通常 4KB~16KB)。随机写会导致 SSD 内部产生大量碎片,迫使主控芯片频繁执行垃圾回收(GC,搬运有效数据并擦除整块)**,引发严重的写放大和延迟抖动。

2. MySQL 架构层面的三重耗时放大

除了硬件本身的差距,在 MySQL 中直接写数据页还会触发三重开销放大:

  • 写入体积放大(差 64 倍):写 Redo Log 仅记录改动内容,按扇区对齐最小仅需写512 字节;而直接刷数据页是16KB,加上防止半页损坏的 Doublewrite Buffer(双写缓冲区),实际落盘高达32KB。
  • 多索引连带惩罚(1 次 vs. N 次):一张表若有 1 个主键索引 + 2 个二级索引,更新一行数据直接落盘需要随机写3 个不同的 B+ 树数据页(3 次随机 I/O);而写 Redo Log 只需把这 3 处改动打包成1 次顺序 I/O。
  • 高并发组提交(Group Commit):100 个并发事务同时提交时,直接落盘需要执行上百次随机 I/O;而 Redo Log 可以将这 100 个事务的日志在内存中合并,通过1 次顺序写一次性刷入磁盘。
3. 直观算账:执行 10,000 次更新耗时对比

假设单线程下连续执行10,00010,00010,000条带 2 个二级索引的UPDATE语句(每次更新涉及 3 个数据页):

  • 机械硬盘 (HDD):直接随机写需30,000×8 ms≈240 秒30,000 \times 8\text{ ms} \approx \mathbf{240\text{ 秒}}30,000×8ms≈240秒(4 分钟);而顺序写 Redo Log 仅需10,000×0.05 ms≈0.5 秒10,000 \times 0.05\text{ ms} \approx \mathbf{0.5\text{ 秒}}10,000×0.05ms≈0.5秒(相差 480 倍)。
  • 企业级 SSD:直接随机写需30,000×0.2 ms≈6 秒30,000 \times 0.2\text{ ms} \approx \mathbf{6\text{ 秒}}30,000×0.2ms≈6秒;而顺序写 Redo Log 仅需10,000×0.02 ms≈0.2 秒10,000 \times 0.02\text{ ms} \approx \mathbf{0.2\text{ 秒}}10,000×0.02ms≈0.2秒(相差 30 倍)。

三、 “先写日志,再落盘”是如何工作的?

为了绕开上述瓶颈,InnoDB 引入了内存缓冲池(Buffer Pool)和重做日志(Redo Log),把一次数据更新拆成了三个阶段:

  1. 先改内存(极快):
    当需要更新一条数据时,InnoDB 先在内存的Buffer Pool中找到对应的数据页并直接修改。此时内存里的数据是最新的,而磁盘上的数据还是旧的,这个内存页被称为脏页(Dirty Page)。

  2. 再写日志(顺序写磁盘,快):
    接着,InnoDB 把“在哪个数据页上做了什么修改”生成一条紧凑的Redo Log记录,追加写入到磁盘上的 Redo Log 文件末尾。因为永远是在文件末尾追加(顺序 I/O),不需要寻道,且只记录改动内容(通常只有几十到几百字节),写入速度极快。只要 Redo Log 落盘成功,事务就可以立刻向客户端返回“提交成功”。

  3. 后台异步落盘(批量合并,省力):
    至于内存里的“脏页”,InnoDB 不会立刻刷入磁盘,而是交由后台线程在系统空闲、脏页比例达到阈值、或者 Redo Log 快写满时,分批、合并地刷回磁盘数据文件。

合并写入的优势:如果某个热点数据页在一秒内被修改了 1000 次,采用 WAL 机制时,只需要改 1000 次内存 + 顺序追加 1000 条极小的日志,最后后台只需要把这 16KB 的数据页刷盘 1 次;而如果直接落盘,则需要执行 1000 次 16KB 的随机磁盘写入。


四、 两种方式的直观对比

对比维度直接修改磁盘数据文件先写 Redo Log 再异步落盘(WAL)
磁盘 I/O 类型随机 I/O(位置分散,寻道开销极大)顺序 I/O(日志末尾追加写,速度极快)
单次写入数据量整个数据页(固定16KB,含双写则为32KB)仅记录修改内容(通常几十至几百字节)
数据刷盘频率每次事务提交都必须同步刷盘后台异步刷盘,多次修改可合并为一次刷盘
用户体验(响应耗时)极慢,高并发下磁盘瞬间打满极快,写完内存和顺序日志即返回成功

五、 脏页还没落盘,服务器突然断电怎么办?

你可能会担心:如果事务提交后,只写了Redo Log,内存里的脏页还没来得及刷入磁盘数据文件,此时服务器突然断电重启,内存里的数据全部消失,数据会丢失吗?

不会丢失。这正是Redo Log存在的第二个核心价值——Crash-Safe(崩溃恢复能力):

  • 当 MySQL 重启时,InnoDB 会自动扫描磁盘上的Redo Log文件。
  • 它会找出那些“已经写入日志、但还没来得及刷入数据文件”的记录,把这些修改在内存中重新执行一遍(重放,Replay),然后再刷回数据文件。

此外,在 MySQL 架构中,InnoDB 引擎层的Redo Log还会与 Server 层的归档日志Binlog配合,通过**两阶段提交(Two-Phase Commit)**机制,确保主从复制与崩溃恢复的数据严丝合缝。


六、 延伸思考:既然顺序写日志这么快,为什么不全部只写日志来保存数据?

看到这里,一个很自然的问题是:既然顺序追加写日志(Redo Log)这么快,为什么还要费劲把数据刷回.ibd数据文件(B+ 树)?干脆废除数据文件,把所有数据都用顺序写日志的方式保存不行吗?

答案是绝对不行。只写日志虽然能让“写入”达到极限速度,但会彻底摧毁数据库的查询性能、存储空间和恢复效率:

  1. 查询(读)性能会变成灾难(O(N)O(N)O(N)vs.O(log⁡N)O(\log N)O(logN)):

    • 日志是按“时间顺序”记录的流水账:里面存的是“第 1 秒把id=5的余额改为 100”、“第 800 秒把id=5的余额改为 200”。如果你要执行SELECT balance FROM account WHERE id = 5,在没有内存缓存的情况下,数据库必须从头到尾全盘扫描整个日志文件(时间复杂度O(N)O(N)O(N)),才能拼凑出id=5的最新状态。
    • 数据文件(.ibd)是按“索引结构(B+ 树)”组织的:尽管刷盘慢,但一旦落盘整理成 B+ 树,哪怕表里有 2000 万行数据,查询任意一行也只需要3~4 次磁盘定位(时间复杂度O(log⁡N)O(\log N)O(logN)),并且能极快地支持范围查询(BETWEEN、>)和排序(ORDER BY)。
  2. 磁盘存储空间会无限膨胀:

    • 如果一行数据被更新了 10 万次,在.ibd数据文件的 B+ 树里,它永远只占1 行的空间(原地覆盖更新)。
    • 但如果只用追加日志来存,磁盘上就会永久保留这10 万条历史变更记录,几 GB 的真实数据很快就会膨胀成几 TB 的日志文件。这也是为什么 InnoDB 的Redo Log被设计成固定大小、循环覆盖写的环形文件——一旦历史日志对应的脏页已经刷入.ibd数据文件,这部分旧日志就会被擦除重用。
  3. 崩溃重启的时间会完全失控:

    • 如果没有后台把脏页刷入数据文件(即推进 Checkpoint 检查点),一旦数据库重启,想要在内存中恢复出最新数据,MySQL 就必须把从建库第一天起积累的几百 GB 甚至上 TB 的日志全部重放(Replay)一遍,重启一次可能需要几个小时甚至几天。
    • 有了定期落盘机制后,MySQL 重启时只需要重放最后一次刷盘点(Checkpoint)之后的那一小段日志,通常几秒钟就能完成恢复。

总结来说:
日志(Redo Log)是为“极速写入与恢复”而生的,而数据文件(B+ 树)是为“极速查询与紧凑存储”而生的。先顺序写日志解决当下的写入延迟,再异步刷回 B+ 树保障后续的读取效率,正是数据库在“读”与“写”之间达成的精妙平衡。