MySQL / InnoDB 存储引擎系列 · 第三篇

为什么一次全表扫描,不会把缓存里的热数据挤出去?

前两篇讲的都是"页要访问几次"。这一篇往回退一步:页被访问之后放在哪——InnoDB 的 Buffer Pool 用一份内存,同时服务两种完全不同的访问模式:反复被查的热数据,和一次性扫过去就再也不碰的冷数据。它们不能用同一套淘汰规则,否则一次 SELECT * FROM 大表 就能把所有热数据全部冲出内存。

这篇同样不是背答案:下面所有默认值和现象,都在本机 MySQL 9.7.1 上用真实的 information_schema.INNODB_BUFFER_PAGE_LRU 查出来的——包括一次"预期落空"的实验,也如实写了出来。

37%
innodb_old_blocks_pct 实测默认值
1000ms
innodb_old_blocks_time 实测默认值
512 页
LRU 分段机制启用的最小长度(源码常量)

不是纯 LRU,是两段 LRU

Buffer Pool 是 InnoDB 用来缓存页的一块内存。它的淘汰链表看起来像 LRU,但被人为切成了两段。

Young 段

被反复访问的页

链表靠前的部分,约占 63%(1 − old_blocks_pct)。真正的热数据——被查过不止一次——最终都会沉淀在这里。

Old 段

刚从磁盘读进来的页

链表靠后的部分,约占 37%。任何一次磁盘读入的页,第一次都只能进这里,而不是直接冲到最前面。

淘汰

永远先淘汰 old 段末尾

内存不够时,被牺牲的永远是 old 段最老的那个——不管 young 段有多少页,都不会被一次扫描波及。

storage/innobase/buf/buf0lru.cc · mysql-server @ mysql-9.7.1, L1656 /** Adds a block to the LRU list. ... @param[in] old true if should be put to the old blocks in the LRU list, else put to the start; if the LRU list is very short, the block is added to the start, regardless of this parameter */ static inline void buf_LRU_add_block_low(buf_page_t *bpage, bool old) { ... if (!old || (UT_LIST_GET_LEN(buf_pool->LRU) < BUF_LRU_OLD_MIN_LEN)) { UT_LIST_ADD_FIRST(buf_pool->LRU, bpage); // 进 young 端 } else { UT_LIST_INSERT_AFTER(buf_pool->LRU, buf_pool->LRU_old, bpage); // 进 old 段 }

注意源码注释里那句"if the LRU list is very short"——BUF_LRU_OLD_MIN_LEN 在源码里被定义为 8*1024/16 = 512,也就是说,整个 young/old 分段机制要等链表攒够 512 页才会真正启动;链表还很短的时候(比如刚启动、数据量很小),所有页都直接进最前面,和普通 LRU 没有区别。这也是本文后面一个真实实验"没按预期表现"的根本原因。

不是所有页都要"排队"——新建的页可以插队

同样是"加入缓冲池",源码里其实走的是两条不同的路径,取决于这个页是读进来的还是刚创建的

storage/innobase/buf/buf0buf.cc // 磁盘读入路径(比如 SELECT 触发的页面缺失): buf_LRU_add_block(bpage, true /* to old blocks */); // L4969, L5052 // 新建页路径(比如 INSERT 导致的 B+树页分裂,产生一个全新的空页): buf_LRU_add_block(&block->page, false); // buf_page_create, L5172

逻辑其实很直白:一个刚从磁盘读进来的页,你不知道它是不是"用完就扔"的扫描产物,所以先当嫌疑犯,进 old 段观察;而一个刚创建、正在被写入的新页,当下就在被使用,没有"用完就扔"的嫌疑,直接放行到 young 端——这在上一篇的回表实验里其实已经间接验证过:插入数据时新分裂出来的叶子页,从来不需要"熬资历"。

演示:一次大扫描,冲不走真正的热页

真实 512 页的分段阈值没法在屏幕上演示,这里用一个容量 12、阈值缩小到"链表≥4页才分段"的玩具池子——分段比例(37%)、"插队"规则、淘汰顺序,和真实规则完全一致。

场景:4 个热页反复被查 → 一次扫 10 个新页 → 检查热页是否还在
young 段 old 段 ← 链表最前(MRU)  链表末尾(即将被淘汰)→

真实数据库里量出来的样子

回到本机真实运行的 MySQL,新建一张 20 万行的表,重启服务清空缓冲池,再做几次真实读取。

SELECT PAGE_NUMBER, PAGE_TYPE, IS_OLD FROM information_schema.INNODB_BUFFER_PAGE_LRU WHERE TABLE_NAME='`bpdemo`.`t`'; 第一次 SELECT * FROM t WHERE id = 170000; 之后: PAGE_NUMBER PAGE_TYPE IS_OLD 4 (根页) INDEX NO ← 每次查询都会经过,早就被反复访问、晋升过了 1208 (内部页) INDEX NO ← 同上 2032 (叶子页) INDEX YES ← 第一次被这个查询碰到,老老实实进 old 段

这和理论完全对得上:根页、内部页几乎参与每一次查询,所以永远处于"刚被访问过"的状态,早早晋升 young 并留在那里;只有叶子页——只有查询命中它对应的那个 key 范围时才会被碰——第一次读入时老老实实排在 old 段。

一次没按预期表现的实验,如实记录:原本想接着验证"同一个叶子页,过 1 秒后再查一次,应该会晋升到 young"——实测发现没有晋升,哪怕连查好几次都还是 IS_OLD=YES。翻源码才找到真正原因:晋升判断函数 buf_page_peek_if_too_old() 里第一行就是
if (buf_pool->freed_page_clock == 0) { /* 尚未开始淘汰页面,说明还在预热阶段或者纯内存工作负载,不必更新统计或搬动链表 */ return false; } 我的测试表加上系统开销远没有填满 128MB 的缓冲池,从没触发过真正的淘汰(freed_page_clock 一直是 0)——这套"该不该晋升"的判断,本身就只在内存吃紧、真的需要精确排队时才会启动。内存够用的时候,系统压根不操这个心。这也是为什么上一节的动画要用玩具规模演示——真实环境里想亲眼看到晋升,得先把缓冲池填满到真正开始淘汰页面为止。

脏页:内存和磁盘不一致的那一刻

页被修改后,内存里的版本领先磁盘上的版本——这个状态叫"脏"。脏页不是错误,是常态;但脏页什么时候变干净,决定了崩溃恢复要做多少事。

InnoDB 不会每改一行就立刻把整个 16KB 页刷回磁盘——那样随机 I/O 会把系统拖垮。真实策略是:先把这次修改写进日志(redo log,下一篇要展开的话题),页本身留在内存里继续脏着,由后台的 page cleaner 线程按节奏慢慢刷。这样做的前提是:只要日志写盘了,哪怕页最终没刷,崩溃后也能靠日志把它重放回来。

下面是本机真实测出来的一次脏页生命周期:

动作全局脏页数本表(t)脏页数
UPDATE 前00
UPDATE 10,001 行后239119
调低刷盘阈值、等待 3 秒后00

数据来自 SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty'information_schema.INNODB_BUFFER_PAGE_LRUOLDEST_MODIFICATION > 0 的页数——这个字段就是"这个页第一次被弄脏时的日志位点(LSN)",非零就意味着脏。

触发这次快速刷盘用的是两个真实存在的阈值——innodb_max_dirty_pages_pct(默认 90)和 innodb_max_dirty_pages_pct_lwm(默认 10):脏页比例超过前者,后台线程会**拼命**刷;超过后者(更低的水位线)就已经开始**提前**加速刷,不等真到 90% 才手忙脚乱。实验里把两个阈值都调到 0,相当于告诉 InnoDB"我一点脏页都不想留",3 秒内它就照做了。

脏页机制和上一节的 young/old 分段其实是同一种设计哲学:把"正确性"和"性能"解耦。young/old 保证"记录去哪不影响正确性,只影响谁先被挤出去";脏页保证"什么时候刷盘不影响正确性(有日志兜底),只影响 I/O 节奏"。两者都是在不牺牲正确性的前提下,把"什么时候做"这件事的决定权,尽量往后推、往批量里合并。

参考与说明

  • 演示动画的容量(12)、分段生效阈值(4)、晋升延迟(3 步)全部是缩小版玩具参数,已在正文中说明;真实默认值是容量随 innodb_buffer_pool_size 变化、分段阈值 512 页、晋升延迟 1000ms——比例(37%)和淘汰顺序规则是完全一致的,不是简化过的。
  • 所有源码引用(buf0lru.ccbuf0buf.ccbuf0buf.ic)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致。
  • "真实数据库里量出来的样子"一节的实验,是先重启本机 mysqld(并清空 innodb_buffer_pool_dump_at_shutdown 产生的转储文件)以获得一个真正干净的缓冲池,再做的读取——否则重启前残留的缓存会干扰观察结果。
  • 没有涉及:多个 Buffer Pool 实例(innodb_buffer_pool_instances > 1 时每个实例独立维护自己的 LRU,本机因为池子只有 128MB,默认只有 1 个实例,没有触发分片逻辑)、压缩页的 unzip_LRU、自适应哈希索引对页驻留的影响。
  • redo log 如何保证"页还没刷、日志已经写盘"这件事在崩溃后依然安全,是这个系列下一篇的内容。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电