InnoDB BufferPool 与 Double Write:保证数据页不损坏
一句话结论(30s)
BufferPool 是 InnoDB 的内存心脏(脏页异步刷盘),而 Double Write 是解决「页断裂」的兜底机制——因为 16KB 的页面对 4KB 的 OS 原子写单位,写到一半宕机会让 checksum 校验失败、页本身损坏,redo 也无从回放。关键设计是「先顺序写 dblwr 备份、再离散写数据文件」,崩溃恢复时从备份取完整页副本;核心权衡是多花约 5%~10% 的写开销换取数据页不被撕裂,金融等强一致场景必选。
核心原理(2min)
修改先发生在 BufferPool 内存页(标记脏页),由后台线程异步刷盘。为防全表扫描污染热点数据,LRU 链表被分成 young 区(前 63%)与 old 区(后 37%):新页先插到 old 区头部,停留超过 innodb_old_blocks_time(默认 1000ms)再被访问才晋升 young 区,冷页一闪而过不碰热点。刷盘前脏页先复制到 dblwr buffer 再顺序写入 dblwr 磁盘区(128 页 × 16KB,一次 fsync,顺序 IO),随后离散写回各 .ibd(随机 IO)。崩溃恢复时校验 .ibd 页 checksum,失败就从 dblwr 区找同一 (space_id, page_no) 的完整副本覆盖后再回放 redo,正常则直接回放。Flush 链表按 oldest_modification 排序,保证 checkpoint 稳定推进、redo 循环可用。
底层深入(5-10min)
BufferPool:InnoDB 的内存心脏
BufferPool 是一块内存区域,缓存数据页和索引页。修改操作不直接写磁盘——先修改 BufferPool 中的页(标记为脏页),由后台线程异步刷盘。
在源码里,一个 BufferPool 实例对应一个 buf_pool_t(include/buf0buf.h),内部挂着三条核心链表,各自有独立的互斥锁:
// include/buf0buf.h —— LRU 链表 / free 链表的锁与 old 区比例
/** LRU list mutex */
BufListMutex LRU_list_mutex;
/** free and withdraw list mutex */
BufListMutex free_list_mutex;
// ……(省略 zip/buddy 等互斥锁与实例元数据)……
/** Reserve this much of the buffer pool for "old" blocks */
ulint LRU_old_ratio;
脏页除了在 LRU 链表里,还同时挂在 flush 链表上,节点按 oldest_modification(最早修改 LSN)排序,刷盘线程据此按序推进 checkpoint:
// include/buf0buf.h —— flush 链表(脏页链表)
/** Mutex protecting the flush list access. This mutex protects flush_list and
bpage::list pointers when the bpage is on flush_list. It also protects writes
to bpage::oldest_modification and flush_list_hp */
BufListMutex flush_list_mutex;
/** "Hazard pointer" used during scan of flush_list while doing flush list
batch. Protected by flush_list_mutex */
FlushHp flush_hp;
/** Entry pointer to scan the oldest page except for system temporary */
FlushHp oldest_hp;
/** Base node of the modified block list */
UT_LIST_BASE_NODE_T(buf_page_t, list) flush_list;
普通页挂在 LRU 链表上,而 LRU_old 不是另一条链表,只是 LRU 链表内部指向 old 区头部的分界指针:
// include/buf0buf.h —— free 链表 + LRU 链表 + old 区指针
/** Base node of the free block list */
UT_LIST_BASE_NODE_T(buf_page_t, list) free;
// ……(省略 withdraw 列表与 LRU 扫描迭代器等字段)……
/** Base node of the LRU list */
UT_LIST_BASE_NODE_T(buf_page_t, LRU) LRU;
/** Pointer to the about LRU_old_ratio/BUF_LRU_OLD_RATIO_DIV oldest blocks in
the LRU list; NULL if LRU length less than BUF_LRU_OLD_MIN_LEN; NOTE: when
LRU_old != NULL, its length should always equal LRU_old_len */
buf_page_t *LRU_old;
/** Length of the LRU list from the block to which LRU_old points onward,
including that block; see buf0lru.cc for the restrictions on this value; 0
if LRU_old == NULL; NOTE: LRU_old_len must be adjusted whenever LRU_old
shrinks or grows! */
ulint LRU_old_len;
分析:三条链表都用 UT_LIST_BASE_NODE_T 内嵌式双向链表而非 std::list——因为同一个 buf_page_t 需要同时出现在 page_hash、LRU、flush_list 等多个集合里,内嵌节点省去堆分配且能 O(1) 摘除/重挂。LRU_old 分界指针的设计让 young/old 迁移只是”改指针 + 改 old 标志”,不搬动任何页数据。
分区 LRU:防全表扫描”冲走”热数据
思考:BufferPool 里已经有 LRU 链表了,为什么还要”分代”成 young / old 两段,而不只是把链表加长?——因为单段 LRU 只有”最近访问优先”这一个维度,它分不清”偶发的批量扫描”和”真正的高频热点”:一次全表扫描在极短时间内也是”最近访问”,会把真正的热数据挤出去。分代的意义在于给冷页一个”观察区”,让它们在 old 区待满时间阈值、证明自己是真的热,才许晋升 young 区。
传统 LRU 的一个致命弱点:一次全表扫描会把大量冷数据页拉到链头,将真正的热数据页挤出缓存——BufferPool 污染。
InnoDB 的解决:将 LRU 链表分成 young 区(前 63%)和 old 区(后 37%):
LRU 链表:
[young区 (63%)] [old区 (37%)]
↑ 热页 ↑ 冷页入口
新页面插入 old 区的头部(不进 young 区)
在 old 区停留 > innodb_old_blocks_time(默认1000ms) → 再次被访问 → 才晋升 young 区
停留 < 1000ms 就被淘汰 → 全表扫描的冷页一闪而过,不影响热页
innodb_old_blocks_time = 1000 这个默认值有明确意图:一次全表扫描读取一个数据页的间隔通常远小于 1 秒,冷页在 old 区待不到 1 秒就会被淘汰(old 区 37% × BufferPool 大小 ≈ 几 GB,一次全表扫描的数据量迅速填满并淘汰,全程不碰 young 区)。分区 LRU 让全表扫描只污染 old 区,young 区的热点数据毫发无伤。
这个 37% 比例在启动时写死为 3/8,对应 young 区 62.5%(约 63%):
// buf0buf.cc —— 初始化 old 区比例:100 * 3 / 8 = 37.5%
buf_LRU_old_ratio_update(100 * 3 / 8, false);
// include/buf0lru.h —— old 区相关常量
/** Minimum LRU list length for which the LRU_old pointer is defined
8 megabytes of 16k pages */
constexpr uint32_t BUF_LRU_OLD_MIN_LEN = 8 * 1024 / 16;
新页默认以 old=true 入链,buf_LRU_add_block_low() 把它插到 LRU_old 指针之后(old 区头部),而不是 LRU 链头:
// buf0lru.cc —— 新页插入 old 区头部
static inline void buf_LRU_add_block_low(buf_page_t *bpage, bool old) {
buf_pool_t *buf_pool = buf_pool_from_bpage(bpage);
ut_ad(mutex_own(&buf_pool->LRU_list_mutex));
ut_a(buf_page_in_file(bpage));
ut_ad(!bpage->in_LRU_list);
if (!old || (UT_LIST_GET_LEN(buf_pool->LRU) < BUF_LRU_OLD_MIN_LEN)) {
UT_LIST_ADD_FIRST(buf_pool->LRU, bpage);
bpage->freed_page_clock = buf_pool->freed_page_clock;
} else {
UT_LIST_INSERT_AFTER(buf_pool->LRU, buf_pool->LRU_old, bpage);
buf_pool->LRU_old_len++;
}
// ……(更新 in_LRU_list 标记、LRU 字节统计,必要时调整 LRU_old 长度)……
}
只有当 old 区的页首次被访问后、且距首次访问超过 innodb_old_blocks_time(对应 buf_LRU_old_threshold)再被访问时,buf_page_peek_if_too_old() 才返回 true,触发晋升 young 区:
// include/buf0buf.ic —— 判断 old 页是否该晋升 young 区
static inline bool buf_page_peek_if_too_old(const buf_page_t *bpage) {
buf_pool_t *buf_pool = buf_pool_from_bpage(bpage);
if (buf_pool->freed_page_clock == 0) {
/* If eviction has not started yet, do not update the
statistics or move blocks in the LRU list. This is
either the warm-up phase or an in-memory workload. */
return false;
} else if (get_buf_LRU_old_threshold() != std::chrono::seconds::zero() &&
bpage->old) {
const auto access_time = buf_page_is_accessed(bpage);
if (access_time != std::chrono::steady_clock::time_point{} &&
(std::chrono::steady_clock::now() - access_time) >=
get_buf_LRU_old_threshold()) {
return true;
}
buf_pool->stat.n_pages_not_made_young++;
return false;
} else {
return (!buf_page_peek_if_young(bpage));
}
}
分析:UT_LIST_INSERT_AFTER(buf_pool->LRU, buf_pool->LRU_old, bpage) 是分区 LRU 的核心一行——全表扫描读到的新页只会堆在 old 区头部,紧接着被淘汰,永远碰不到 LRU_old 之前的 young 区。buf_page_peek_if_too_old 则体现了时间阈值语义:now() - access_time >= old_threshold 才放行晋升,冷页一闪而过(间隔 < 1s)不会被提升,这就是 innodb_old_blocks_time 在代码层的落点。
Double Write:解决”页断裂”
思考:页写坏了,靠 redo log 重放不就行了吗?为什么还要 Double Write 这道”备份”?——因为 redo 的幂等重放本身依赖”目标页完好”这一前提:页一旦被撕裂,redo 连”在哪个位置写”都失去了物理基准(详见下一节)。所以需要 Double Write 先保证”任意时刻总有一份完整页”,redo 才有可回放的底。
什么是页断裂(Torn Page)
InnoDB 的页大小是 16KB,但操作系统和磁盘的原子写入单位是 4KB(或 512B)。当 InnoDB 将脏页刷到磁盘时,如果写到 4KB 时宕机,磁盘上这个页的前 4KB 是新数据,后 12KB 是旧数据——checksum 校验失败,页本身损坏。
redo log 为什么不能修复?
Redo log 是物理逻辑日志——“在 tablespace=5, page_no=100, offset=64 处写入 4 字节”。这个操作依赖于目标页是完整有效的,它才能确定”在哪个位置”去重放。如果页本身 checksum 已经损坏,redo 的幂等重放就失去了可靠的物理基础。
Double Write 流程
脏页刷盘前:
1. 复制脏页到 dblwr buffer(内存中 2MB)
2. 顺序写入 ibdata 的 dblwr 磁盘区(128 页 × 16KB,一次 fsync)
→ 这是顺序 IO,很快
3. 离散写回各 .ibd 表空间(异步)
→ 这是随机 IO
崩溃恢复:
检查 .ibd 页的 checksum
→ 如果 checksum 失败 → 从 dblwr 区找到同一 (space_id, page_no) 的完整副本
→ 覆盖 .ibd 中损坏的页 → 回放 redo log
→ 如果 checksum 正常 → 直接回放 redo
思考:为什么”先顺序写 dblwr 备份、再离散写数据文件”这个顺序本身,就能保证安全?——因为两步是严格串行的:只要数据文件还没开始写,dblwr 里就必然已有完整备份;一旦开始写数据文件,备份一定已经落盘。任意时刻崩溃,恢复端要么拿到”未损坏的数据页”,要么拿到”dblwr 里的完整副本”,二者必居其一,绝不会出现”页已撕裂却无备份可用”的窗口。
Double Write 用一小块顺序写区域为全部随机写的脏页提供了”备份”,在崩溃恢复时提供可靠的页副本让 redo log 能够正确回放。
代价是额外写开销约 5%~10%。对于 SSD(尤其 NVMe),有些场景下这个开销并不明显;对于必须保证数据完整性的金融场景,这是必选项。
注:以下源码取自 MySQL 8.0(doublewrite 已从 ibdata 迁到独立的
#ib_16384_*.dblwr文件,并按 buffer pool 实例拆分成小批次),但「先顺序写备份区、再离散写数据文件」的核心机制与经典 128 页 ibdata 方案一致。
内存里的 dblwr::Buffer 就是那个备份缓冲——按页对齐分配,把待刷脏页逐页 append 进去:
// include/buf0dblwr.h —— dblwr::Buffer:页对齐的备份缓冲区
struct Buffer {
// ……(构造函数按 n_pages * phy_size 对齐分配 m_ptr)……
/** Add the contents of ptr up to n_bytes to the buffer.
@return false if it won't fit. Nothing is copied if it won't fit. */
bool append(const void *ptr, size_t n_bytes) noexcept {
ut_a(m_next >= m_ptr && m_next <= m_ptr + m_n_bytes);
if (m_next + m_phy_size > m_ptr + m_n_bytes) {
return false;
}
memcpy(m_next, ptr, n_bytes);
m_next += m_phy_size;
return true;
}
// ……(begin()/size()/clear() 等省略)……
/** Write buffer used in writing to the doublewrite buffer,
aligned to an address divisible by UNIV_PAGE_SIZE (which is
required by Windows AIO) */
byte *m_ptr{};
/** Start of next write to the buffer. */
byte *m_next{};
/** Size of the unaligned (raw) buffer. */
const size_t m_n_bytes{};
};
刷盘的核心顺序就一行:先 write_dblwr_pages() 把整批脏页顺序写入 dblwr 文件并 fsync,成功后才 write_data_pages() 逐个离散写回各 .ibd:
// buf0dblwr.cc —— 先写 dblwr 备份,再写数据文件
void Double_write::write_pages(buf_flush_t flush_type) noexcept {
ut_ad(mutex_own(&m_mutex));
ut_a(!m_buffer.empty());
const uint16_t batch_id = write_dblwr_pages(flush_type);
write_data_pages(flush_type, batch_id);
}
write_dblwr_pages() 里 batch_segment->write() 把整个 buffer 一次写进 dblwr 文件,随后 flush() 落盘(fsync)——这就是「顺序 IO + 一次 fsync」的来源:
// buf0dblwr.cc —— 写 dblwr 文件并 fsync
uint16_t Double_write::write_dblwr_pages(buf_flush_t flush_type) noexcept {
ut_ad(mutex_own(&m_mutex));
ut_a(!m_buffer.empty());
// ……(从分段队列 dequeue 一个 batch_segment)……
batch_segment->write(m_buffer);
m_bytes_written += m_buffer.size();
m_buffer.clear();
#ifndef _WIN32
if (is_fsync_required()) {
batch_segment->flush();
}
#endif /* !_WIN32 */
// ……(记录 batch size、返回 batch id)……
}
而 Segment::write 用 os_file_write_retry 整段顺序落盘(一次 IO 写整个 batch):
// buf0dblwr.cc —— Segment::write:整段顺序写 dblwr 文件
void write(const byte *ptr, uint32_t len) noexcept {
ut_a(len <= m_end - m_start);
IORequest req(IORequest::Type::WRITE | IORequest::Type::DO_NOT_WAKE);
auto err = os_file_write_retry(req, m_file.m_name.c_str(), m_file.m_pfs,
ptr, m_start, len);
ut_a(err == DB_SUCCESS);
}
崩溃恢复时,load() 把 dblwr 文件里的页副本整体读回内存,再由 is_actual_page_corrupted() 校验 .ibd 里的真页是否损坏,损坏才用副本覆盖:
// buf0dblwr.cc —— 恢复阶段:校验数据文件页是否损坏(checksum 失败或全零页)
bool Pages::is_actual_page_corrupted(const fil_space_t &space,
const page_id_t &page_id) {
// ……(越界检查、分配 1 页的 Buffer)……
/* Read in the page from the data file to compare. */
auto err =
fil_io(IORequest::Type::READ | IORequest::Type::DBLWR, true, page_id,
page_size, page_size.physical(), buffer.begin(), nullptr, false);
// ……(读失败则直接报 fatal,无法恢复)……
/* Is the page read from the data file corrupt? */
BlockReporter data_file_page(true, buffer.begin(), page_size,
fsp_is_checksum_disabled(space.id));
return data_file_page.is_corrupted() ||
buf_page_is_zeroes(buffer.begin(), page_size);
}
分析:write_pages 把「写备份」和「写数据」拆成严格串行的两步——write_dblwr_pages 返回后才调用 write_data_pages,保证任意时刻崩溃,dblwr 区里要么已有整页备份、要么数据文件还没被写坏。is_actual_page_corrupted 判定损坏的标准正是「checksum 校验失败」或「全零页」,与文章开头说的页断裂场景一一对应,命中才用 load() 读出的完整副本覆盖。
脏页刷盘策略
BufferPool 中的脏页 → Flush 链表(按 oldest_modification 排序)
→ 后台线程 innodb_io_capacity 限制刷盘速率
→ Double Write Buffer → 数据文件
Flush 链表让刷盘线程能按”最老未刷脏页优先”的顺序工作,保证 BufferPool 的 checkpoint 稳定推进,redo log 可以循环使用。
总结
| 机制 | 解决的问题 | 实现方式 |
|---|---|---|
| 分区 LRU | 全表扫描污染 BufferPool | young/old 分区 + 时间阈值 |
| Double Write | 页断裂导致页损坏 | 先顺序写 dblwr 备份,再离散写数据文件 |
| Flush 链表 | 有序刷脏页 | 按 oldest_modification 排序 |
章末提问
1. 为什么 16KB 页会”断裂”?4KB 原子写到底破坏了什么? 结论先行:因为 16KB 页跨了多个 4KB 物理写单元,写到一半宕机会让页变成”新旧数据拼接”,checksum 校验失败。因为操作系统和磁盘只保证单个扇区(4KB/512B)的写原子性,InnoDB 一次刷 16KB 要拆成多次底层写,中间任意一次宕机都会留下”半新半旧”的页,checksum 一算就错。
2. 页 checksum 坏了,redo log 为什么救不了? 结论先行:因为 redo 是”物理逻辑日志”,重放依赖目标页本身完好。因为 redo 记录的语义是”在 page_no=X、offset=Y 处写入 N 字节”,它默认”X 页是有效页”这个前提成立;页一旦被撕裂,这个前提就破了,redo 的幂等重放失去了可靠的物理坐标,无从回放。
3. 为什么必须是”先写 dblwr 备份、再写数据文件”?反过来行不行? 结论先行:反过来不行,因为那等于没有备份。因为 Double Write 的价值在于”任意时刻崩溃,总能找到完整页”:先写备份再写数据,崩溃时要么数据文件还没坏、要么备份已落盘;如果先写数据文件,一旦写坏时备份区还是空的,就彻底无副本可用,机制失效。
4. Double Write 的开销有多大?SSD 上还有必要吗? 结论先行:约 5%~10% 写放大;SSD 上不能一概而论,金融等强一致场景仍是必选。因为备份区是一次顺序写 + 一次 fsync,成本相对可控;NVMe 上顺序带宽高、开销不明显,但页断裂的风险不因介质变快而消失(掉电窗口仍在),强一致场景宁可多付这点写放大也要保证数据页完整。
5. 分区 LRU 里,old 区的时间阈值 innodb_old_blocks_time 为什么默认是 1 秒? 结论先行:因为全表扫描读取相邻页的间隔通常远小于 1 秒,1 秒足以把”扫过就扔”的冷页和”反复访问”的热页区分开。因为全表扫描是顺序读,同一批冷页在 old 区停留极短就被后到的冷页挤出;而被真实业务反复访问的热页,两次访问间隔大概率超过 1 秒,能被允许晋升 young 区。