前两篇讲的都是"页要访问几次"。这一篇往回退一步:页被访问之后放在哪——InnoDB 的 Buffer Pool 用一份内存,同时服务两种完全不同的访问模式:反复被查的热数据,和一次性扫过去就再也不碰的冷数据。它们不能用同一套淘汰规则,否则一次 SELECT * FROM 大表 就能把所有热数据全部冲出内存。
这篇同样不是背答案:下面所有默认值和现象,都在本机 MySQL 9.7.1 上用真实的 information_schema.INNODB_BUFFER_PAGE_LRU 查出来的——包括一次"预期落空"的实验,也如实写了出来。
Buffer Pool 是 InnoDB 用来缓存页的一块内存。它的淘汰链表看起来像 LRU,但被人为切成了两段。
链表靠前的部分,约占 63%(1 − old_blocks_pct)。真正的热数据——被查过不止一次——最终都会沉淀在这里。
链表靠后的部分,约占 37%。任何一次磁盘读入的页,第一次都只能进这里,而不是直接冲到最前面。
内存不够时,被牺牲的永远是 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%)、"插队"规则、淘汰顺序,和真实规则完全一致。
回到本机真实运行的 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 段。
页被修改后,内存里的版本领先磁盘上的版本——这个状态叫"脏"。脏页不是错误,是常态;但脏页什么时候变干净,决定了崩溃恢复要做多少事。
InnoDB 不会每改一行就立刻把整个 16KB 页刷回磁盘——那样随机 I/O 会把系统拖垮。真实策略是:先把这次修改写进日志(redo log,下一篇要展开的话题),页本身留在内存里继续脏着,由后台的 page cleaner 线程按节奏慢慢刷。这样做的前提是:只要日志写盘了,哪怕页最终没刷,崩溃后也能靠日志把它重放回来。
下面是本机真实测出来的一次脏页生命周期:
| 动作 | 全局脏页数 | 本表(t)脏页数 |
|---|---|---|
| UPDATE 前 | 0 | 0 |
| UPDATE 10,001 行后 | 239 | 119 |
| 调低刷盘阈值、等待 3 秒后 | 0 | 0 |
数据来自 SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty' 与 information_schema.INNODB_BUFFER_PAGE_LRU 里 OLDEST_MODIFICATION > 0 的页数——这个字段就是"这个页第一次被弄脏时的日志位点(LSN)",非零就意味着脏。
触发这次快速刷盘用的是两个真实存在的阈值——innodb_max_dirty_pages_pct(默认 90)和 innodb_max_dirty_pages_pct_lwm(默认 10):脏页比例超过前者,后台线程会**拼命**刷;超过后者(更低的水位线)就已经开始**提前**加速刷,不等真到 90% 才手忙脚乱。实验里把两个阈值都调到 0,相当于告诉 InnoDB"我一点脏页都不想留",3 秒内它就照做了。