MySQL双写缓冲(Double Write Buffer):为什么要写两次?

摘要:从一次"断电后数据页损坏"的诡异故障出发,深度剖析InnoDB的双写缓冲机制。通过数据页结构图解、部分写入问题的原理分析、以及双写缓冲的完整流程,揭秘为什么redo log不能直接恢复损坏的数据页、为什么需要先写双写缓冲再写数据文件、以及如何通过双写缓冲保证数据页的完整性。配合时序图展示写入流程,给出性能优化的权衡建议。


💥 翻车现场

凌晨3点,机房突然断电。

5分钟后电力恢复,哈吉米紧急重启MySQL,发现报错:

[ERROR] InnoDB: Database page corruption on disk or a failed file read.
[ERROR] InnoDB: Page [page id: space=1, page number=1234] is corrupted.

哈吉米:"数据页损坏了?不是有redo log吗?为什么没恢复?"

查看错误日志:

InnoDB: Recovered from a crash using doublewrite buffer.
InnoDB: Restored page [1234] from doublewrite buffer.

哈吉米:"doublewrite buffer是啥?"

早上8点,南北绿豆和阿西噶阿西来了。

南北绿豆:"幸好有双写缓冲,否则这个页的数据就永久损坏了!"
哈吉米:"双写缓冲是什么?为什么要写两次?"
阿西噶阿西:"来,我给你讲讲MySQL的部分写入问题。"


🤔 什么是部分写入问题?

数据页的结构

南北绿豆:"InnoDB的最小存储单元是数据页,大小16KB。"

一个数据页(16KB)的结构:

┌─────────────────────────────┐
│ 页头(38字节)               │
├─────────────────────────────┤
│ 行记录(约15KB)             │  ← 存储多行数据
│ [row1][row2][row3]...       │
├─────────────────────────────┤
│ 页尾(8字节)                │
│ 校验和(checksum)           │
└─────────────────────────────┘

总大小:16KB = 16384字节

操作系统的写入单位

阿西噶阿西:"但操作系统的写入单位是4KB。"

操作系统的IO单位:4KB(一个扇区)

MySQL写入16KB数据页:
需要写4次(16KB / 4KB = 4次)

写入过程:
第1次写:写入前4KB  ✅
第2次写:写入第二个4KB  ✅
第3次写:写入第三个4KB  ✅
第4次写:写入第四个4KB  ← 如果这时候断电...

部分写入问题

场景:写数据页的过程中断电

sequenceDiagram
    participant MySQL
    participant Memory as 内存(Buffer Pool)
    participant Disk as 磁盘

    MySQL->>Memory: 1. 修改数据页(16KB)
    MySQL->>Disk: 2. 开始写入磁盘
    Disk->>Disk: 写入前4KB ✅
    Disk->>Disk: 写入第二个4KB ✅
    
    rect rgb(255, 182, 193)
        Note over Disk: 断电!
    end
    
    Note over Memory,Disk: 数据页损坏<br/>只写了一半(8KB)
    
    Note over MySQL: 重启后,数据页不完整<br/>checksum校验失败

数据页状态

完整的数据页:
[页头][row1][row2][row3][row4][页尾checksum]

部分写入后(断电):
[页头][row1][ro...  ← 只写了一半                  ↑                断电点问题:1. 数据页不完整2. checksum校验失败3. 数据损坏

哈吉米:"卧槽,那redo log不能恢复吗?"

南北绿豆:"这就是关键!redo log只能恢复完整的数据页,不能恢复损坏的数据页!"


🤔 为什么redo log不能恢复损坏的数据页?

redo log的内容

redo log记录的是:
页号:1234
偏移量:456
旧值:balance = 1000
新值:balance = 900

意思是:在页1234的偏移量456处,把1000改成900

恢复流程

正常情况(数据页完整):
1. 读取页1234
2. 在偏移量456处,把1000改成900 ✅
3. 写回磁盘

部分写入情况(数据页损坏):
1. 读取页1234 → 校验失败,页已损坏 ❌
2. 无法确定偏移量456在哪里
3. 无法应用redo log

阿西噶阿西:"看到了吗?redo log依赖数据页的完整性,如果数据页本身损坏了,redo log无法应用!"

哈吉米:"那怎么办?"

南北绿豆:"这就需要双写缓冲!"


🎯 双写缓冲的原理

双写缓冲的结构

双写缓冲(Doublewrite Buffer):
一块连续的磁盘空间(2MB)

位置:
在系统表空间(ibdata1)中
固定大小:128个页(128 × 16KB = 2MB)

作用:
在写数据文件前,先写双写缓冲(完整性保证)

双写缓冲的写入流程

sequenceDiagram
    participant MySQL
    participant Memory as 内存(Buffer Pool)
    participant DoubleWrite as 双写缓冲(ibdata1)
    participant DataFile as 数据文件(.ibd)

    MySQL->>Memory: 1. 修改数据页
    MySQL->>DoubleWrite: 2. 写双写缓冲(顺序IO,快)
    Note over DoubleWrite: 完整写入16KB ✅
    
    DoubleWrite->>MySQL: 3. 写入成功
    MySQL->>DataFile: 4. 写数据文件(可能断电)
    
    alt 正常情况
        DataFile->>MySQL: 写入成功 ✅
    else 断电(部分写入)
        rect rgb(255, 182, 193)
            Note over DataFile: 断电!数据页损坏
        end
        Note over MySQL: 重启后,从双写缓冲恢复 ✅
    end

关键步骤

步骤1:修改内存中的数据页
步骤2:写双写缓冲(顺序写,快,且一次性写完16KB)
步骤3:写数据文件(可能部分写入)
步骤4:如果数据文件损坏,从双写缓冲恢复

为什么双写缓冲能保证完整性?

阿西噶阿西:"因为双写缓冲是顺序写入,而且连续的磁盘空间。"

双写缓冲的特点:
1. 连续的磁盘空间(2MB)
2. 顺序写入(不是随机写)
3. 一次性写入16KB(原子写入)

操作系统保证:
- 顺序写入的原子性更好
- 要么写完整16KB,要么一个字节都不写
- 不会出现"写了一半"的情况

南北绿豆:"所以双写缓冲能保证数据页的完整性!"


崩溃恢复的流程

重启后:
   ↓
1. 检查数据文件(.ibd)的数据页
   ↓
2. 校验checksum
   ↓
┌──────────────────┐
│ checksum正确?   │
└────┬─────────┬───┘
    YES       NO
     ↓         ↓
  正常     数据页损坏
           ↓
        从双写缓冲恢复
           ↓
        读取双写缓冲中的页
           ↓
        写回数据文件
           ↓
        恢复完成 ✅

时序图

graph TD
    A[MySQL重启] --> B[扫描数据文件]
    B --> C{数据页完整?}
    C -->|是| D[正常启动]
    C -->|否| E[页损坏,检查双写缓冲]
    E --> F{双写缓冲有备份?}
    F -->|是| G[从双写缓冲恢复]
    F -->|否| H[数据永久损坏 ❌]
    G --> I[写回数据文件]
    I --> J[恢复成功 ✅]
    
    style D fill:#90EE90
    style J fill:#90EE90
    style H fill:#FFB6C1

哈吉米:"所以双写缓冲是数据页完整性的最后一道防线!"


🎯 性能影响:写两次会慢吗?

性能测试

测试环境:
- 关闭双写缓冲 vs 开启双写缓冲
- 插入100万条数据

结果:
关闭双写缓冲:12.3秒
开启双写缓冲:13.1秒

性能损失:6.5%

为什么影响不大?

南北绿豆:"因为双写缓冲的写入是顺序IO,很快。"

双写缓冲的写入:
- 顺序写入(连续空间)
- 批量写入(128个页一起写)
- 顺序IO速度:100-200MB/s

数据文件的写入:
- 随机写入(数据页分散)
- 单页写入
- 随机IO速度:10-20MB/s

结论:双写缓冲的写入反而更快

是否可以关闭双写缓冲?

-- 查看双写缓冲状态
SHOW VARIABLES LIKE 'innodb_doublewrite';

+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| innodb_doublewrite | ON    |  ← 默认开启
+--------------------+-------+

-- 关闭双写缓冲(不推荐)
SET GLOBAL innodb_doublewrite = OFF;

什么时候可以关闭?

场景是否关闭原因
生产环境❌ 不建议数据安全第一
SSD + 文件系统支持原子写⚠️ 可考虑部分文件系统保证原子写
测试环境✅ 可以追求性能
临时表✅ 可以数据不重要

阿西噶阿西:"生产环境强烈建议开启,损失6%性能换数据安全,很值!"


🤔 有了undo log为啥还需要redo log?

哈吉米:"既然undo log能回滚,为什么还需要redo log?"

南北绿豆:"因为undo log和redo log的作用完全不同!"

日志作用对比

日志作用使用时机
undo log事务回滚、MVCC事务回滚时、查询历史版本时
redo log崩溃恢复、保证持久性崩溃后重启恢复数据

为什么需要redo log?

场景1:事务提交后,数据还在内存

T1: 事务提交(COMMIT)
    - 数据已修改(内存中的buffer pool)
    - redo log已写磁盘 ✅
    - 数据文件还没刷盘(异步刷盘)
    
T2: 断电

重启后:
- 内存数据丢失
- 数据文件是旧数据
- 怎么恢复?靠redo log!

恢复流程

重启后:
1. 读取redo log
2. 重放日志(把内存中的修改重新应用到数据文件)
3. 数据恢复 ✅

为什么需要undo log?

场景2:事务执行中,还没提交

T1: 事务开始
T2: UPDATE account SET balance = 900 WHERE id = 1;
    - 数据已修改(内存)
    - undo log已写入 ✅
    - 还没COMMIT
    
T3: 断电

重启后:
- 事务没提交,需要回滚
- 怎么回滚?靠undo log!

回滚流程

重启后:
1. 读取undo log
2. 执行反向操作(UPDATE account SET balance = 1000 WHERE id = 1)
3. 恢复到事务开始前的状态 ✅

对比总结

时序图

graph TD
    A[事务执行] --> B{事务是否提交?}
    
    B -->|已提交| C[需要恢复数据]
    C --> D[使用redo log]
    D --> E[重放日志]
    E --> F[数据恢复 ✅]
    
    B -->|未提交| G[需要回滚事务]
    G --> H[使用undo log]
    H --> I[执行反向操作]
    I --> J[数据回滚 ✅]
    
    style F fill:#90EE90
    style J fill:#90EE90

南北绿豆:"所以redo log保证持久性(已提交的数据不丢),undo log保证原子性(未提交的数据回滚)。"


🎯 redo log怎么保证持久性?

WAL机制(Write-Ahead Logging)

原理:先写日志,再刷盘。

传统方式(直接写磁盘):
1. 修改数据
2. 写磁盘(随机IO,10ms)
3. 返回"成功"

性能:100次UPDATE = 1秒


WAL方式(先写redo log):
1. 修改数据(内存)
2. 写redo log(顺序IO,0.1ms)
3. 返回"成功"
4. 后台异步刷盘(批量、顺序IO)

性能:100次UPDATE = 0.01秒

性能提升:100倍

时序图

sequenceDiagram
    participant App as 应用
    participant Memory as 内存
    participant RedoLog as redo log
    participant DataFile as 数据文件

    App->>Memory: 1. UPDATE balance=900
    App->>RedoLog: 2. 写redo log(顺序IO,快)
    RedoLog->>App: 3. 返回"提交成功"
    
    Note over Memory,DataFile: 后台异步刷盘
    par 后台线程
        Memory->>DataFile: 4. 批量刷盘(顺序IO)
    end
    
    Note over App: 用户感知:快<br/>实际持久化:慢(异步)<br/>但有redo log保证

阿西噶阿西:"WAL机制用顺序IO代替随机IO,性能提升100倍!"


🎯 能不能只用binlog,不用redo log?

面试标准答案

不能!因为层级和作用不同。

对比

特性redo logbinlog
层级InnoDB引擎层MySQL Server层
作用崩溃恢复主从复制、数据恢复
记录内容物理日志(数据页变化)逻辑日志(SQL或行变化)
写入时机事务执行中(边执行边写)事务提交时
恢复速度快(物理日志,直接应用)慢(需要重新执行SQL)

为什么需要两份日志?

原因1:历史遗留

MySQL最初只有MyISAM:
- 不支持事务
- 只有binlog(用于主从复制)

后来引入InnoDB:
- 支持事务
- 需要redo log保证崩溃恢复
- 但binlog不能删(主从复制还要用)

结果:两份日志并存

原因2:职责分离

redo log:
- InnoDB引擎层
- 专注崩溃恢复
- 循环写入(固定大小)

binlog:
- Server层
- 主从复制 + 数据恢复
- 顺序追加(无限增长)

南北绿豆:"两份日志各司其职,通过两阶段提交保证一致性。"


🎓 面试标准答案

题目:什么是双写缓冲?为什么要写两次?

答案

双写缓冲(Doublewrite Buffer):InnoDB的一种机制,在写数据文件前,先写双写缓冲。

为什么需要

  • 操作系统写入单位是4KB
  • InnoDB数据页是16KB
  • 写16KB需要4次IO,可能部分写入(断电)
  • redo log无法恢复损坏的数据页

双写缓冲的作用

  1. 先写双写缓冲(顺序IO,保证完整性)
  2. 再写数据文件(可能部分写入)
  3. 崩溃后,从双写缓冲恢复损坏的数据页

性能影响

  • 损失约6%性能
  • 换来数据页完整性保证

是否关闭

  • 生产环境:不建议关闭
  • 测试环境:可以关闭

题目:有了undo log为啥还需要redo log?

答案

作用不同

  • undo log:事务回滚、MVCC
  • redo log:崩溃恢复、保证持久性

使用场景

  • 事务未提交,崩溃 → 用undo log回滚
  • 事务已提交,崩溃 → 用redo log恢复

不能互相替代

  • undo log记录的是旧值(回滚用)
  • redo log记录的是新值(恢复用)

🎉 结束语

晚上9点,哈吉米终于搞懂了双写缓冲的原理。

哈吉米:"原来双写缓冲是防止数据页部分写入的!"

南北绿豆:"对,redo log只能恢复完整的数据页,不能恢复损坏的数据页。"

阿西噶阿西:"双写缓冲是数据安全的最后一道防线,虽然损失6%性能,但很值!"

哈吉米:"还有undo log和redo log作用完全不同,一个回滚,一个恢复。"

南北绿豆:"对,理解了这些日志的原理,就理解了MySQL的可靠性保证!"


记忆口诀

数据页十六KB,操作系统四KB写
部分写入页损坏,redo log无法救
双写缓冲顺序写,保证页面完整性
undo保回滚,redo保持久
两份日志各司职,崩溃恢复有保障


希望这篇文章能帮你理解双写缓冲的原理!把这个讲清楚,面试官一定觉得你基础扎实!💪