MySQL内存与磁盘数据结构全解析:从Buffer Pool到B+树
1. 从一次线上卡顿说起:为什么要搞清楚内存与磁盘的数据结构
前阵子帮朋友排查一个线上问题,MySQL 实例的 CPU 不高、磁盘 IO 也不高,但业务侧就是感觉“慢半拍”,一条简单的按主键查订单的 SQL,偶尔要几百毫秒才返回。他一开始怀疑是网络抖动,后来又怀疑是连接池配置,折腾了一圈没结果。我让他把 innodb_buffer_pool_size、表数据量、索引情况发过来一看,问题基本就清楚了:Buffer Pool 开得太小,热数据根本装不下,每次查询都要从磁盘把页读进来,读进来又很快被挤出去,来回折腾。
这件事让我意识到,很多人用 MySQL 用了好几年,但对它“内存里存什么、磁盘上存什么、两者怎么配合”其实是没有整体画面的。你知道有 B+ 树,也知道有 Buffer Pool,但这两者是怎么咬合的、一次查询到底走了哪几步、为什么加了索引还是慢,这些问题串不起来,排查问题就只能靠猜。
这篇内容我想干的事很明确:把 MySQL(重点讲 InnoDB)从内存到磁盘的数据结构拉成一条完整的线,讲清楚每一层存的是什么、为什么这么设计、对我们日常调优和排查问题意味着什么。不管你是刚学 MySQL 的新手,还是已经能写复杂 SQL 但没深挖过存储引擎的老手,我都尽量用大白话加实际例子讲明白。核心关键词就几个:MySQL、InnoDB、Buffer Pool、B+ 树、数据结构,这几个词会贯穿全文。
我个人的习惯是,学一个东西先建立“地图”,再抠细节。所以这篇会先给你一张全局地图,再一层层往下拆。你不需要一次全记住,但看完之后,遇到慢查询、遇到内存告警、遇到索引失效,脑子里应该能浮现出数据在内存和磁盘之间是怎么流动的。
2. 全局地图:一条 SQL 在内存与磁盘之间到底经历了什么
2.1 先建立分层认知:Server 层与存储引擎层
很多人一上来就扎进 B+ 树,其实容易迷路。我建议先把 MySQL 的架构分成两大块来看:Server 层和存储引擎层。Server 层负责连接管理、SQL 解析、优化、执行,存储引擎层负责真正的数据存取。我们常说的 InnoDB,就是存储引擎层的主角。
为什么要分这两层?因为数据结构是分层的。Server 层里有各种缓存(比如查询缓存,8.0 已经移除)、解析树、执行计划,这些偏“逻辑结构”;而 InnoDB 层里的 Buffer Pool、B+ 树、redo log、undo log,才是真正跟磁盘打交道的“物理结构”。你排查问题时,先要判断是 Server 层的问题还是引擎层的问题,方向错了,越查越远。
举个实际例子:一条 SQL 慢,如果是执行计划选错了索引,那是 Server 层优化器的问题;如果是 Buffer Pool 命中率低导致频繁读盘,那是引擎层的问题。两者的解决思路完全不同。所以建立分层认知,是理解后面所有数据结构的前提。
2.2 内存侧的核心角色:Buffer Pool 不是唯一
一提到 MySQL 内存,大部分人第一反应就是 Buffer Pool。没错,它是 InnoDB 内存里最大的一块,但绝不是唯一的一块。InnoDB 的内存结构大致包括:
- Buffer Pool:缓存数据页和索引页,是绝对主力。
- Change Buffer:缓存对非唯一二级索引的写操作,减少随机读。
- Log Buffer:缓存 redo log,批量刷盘。
- Adaptive Hash Index:自适应哈希索引,加速等值查询。
- Dictionary Cache:数据字典缓存。
这几块各有分工。Buffer Pool 管“读”和“页缓存”,Change Buffer 管“写合并”,Log Buffer 管“日志批量落盘”。它们共同的目标只有一个:尽量减少直接访问磁盘的次数。因为磁盘(哪怕是 SSD)和内存的速度差距是数量级的,能少读一次就少读一次。
我见过不少调优文章只讲 Buffer Pool,结果读者以为调大它就万事大吉。实际上如果你的写入模式大量涉及非唯一二级索引,Change Buffer 的配置同样关键。所以先建立“内存是多块协作”的认知,比死记一个参数重要得多。
2.3 磁盘侧的核心角色:表空间、页与 B+ 树
磁盘这边,InnoDB 的逻辑存储单元从大到小是:表空间(tablespace)→ 段(segment)→ 区(extent)→ 页(page)→ 行(row)。其中最关键的单位是页,默认 16KB。磁盘上的数据,就是以页为单位组织在 B+ 树里的。
这里有个很多人忽略的点:B+ 树的每个节点就是一个页。叶子节点存的是行数据(或主键+数据),非叶子节点存的是索引键和指向子页的指针。一次查询从根页开始,逐层往下,最终定位到叶子页,这个过程叫“树的高度次磁盘 IO”。树高一般是 3 到 4 层,也就是说一次查询理论上最多 3 到 4 次页读取——但前提是这些页都在 Buffer Pool 里,否则每次都是真实的磁盘 IO。
所以你看,内存和磁盘不是割裂的:磁盘上的 B+ 树是“骨架”,内存里的 Buffer Pool 是“缓存骨架的副本”。理解了这层关系,你就能明白为什么“树高”和“命中率”是两个最核心的性能指标。
2.4 一次查询的完整链路(简化版)
我把一次按主键查询的链路简化成下面几步,你可以对照着理解:
- 客户端发来 SQL,Server 层解析、优化,生成执行计划。
- 执行器调用 InnoDB 接口,请求主键对应的行。
- InnoDB 先在 Buffer Pool 里找对应的页。找到就直接用,这叫“命中”。
- 没命中,就从磁盘把页读进 Buffer Pool,再使用。
- 在页内通过页目录(Page Directory)做二分查找,定位到具体行。
- 返回数据给 Server 层,再返回客户端。
如果是更新操作,还要多几步:写 undo log、修改 Buffer Pool 里的页(此时页是“脏页”)、写 redo log 到 Log Buffer、后续再刷脏页和刷日志。这一整套流程,才是“内存与磁盘数据结构”真正要讲的东西。
3. 内存侧深挖:Buffer Pool 的内部结构与运作机制
3.1 Buffer Pool 的页管理:三种链表撑起一片天
Buffer Pool 本质上是一块连续内存,被划分成一个个“缓存页”,每个缓存页对应磁盘上的一个数据页,默认 16KB。但光有页还不够,它需要一套管理机制来决定“谁进来、谁出去、谁脏了要刷”。这套机制靠三种链表实现:
- Free List(空闲链表):记录还没被使用的空闲缓存页。刚启动时所有页都在这里。
- LRU List(最近最少使用链表):记录已被使用的页,按使用频率和最近使用时间排序,用于淘汰。
- Flush List(脏页链表):记录被修改过、还没刷回磁盘的页。
这三个链表的分工非常清晰:Free List 管“分配”,LRU List 管“淘汰”,Flush List 管“刷盘”。你调优时关心的命中率,本质就是 LRU List 的管理效率;你关心的写性能,很大程度取决于 Flush List 的刷盘节奏。
我特别想强调一点:Buffer Pool 的 LRU 不是简单的 LRU。如果真按朴素 LRU,一次全表扫描就会把整个 Buffer Pool 冲干净,热数据全被挤出去。InnoDB 的做法是把 LRU List 分成两段:young 区(约 5/8)和old 区(约 3/8)。新读进来的页先放在 old 区的头部,只有在一定时间后被再次访问,才会晋升到 young 区。这个设计就是为了防止“一次性扫描污染缓存”。
3.2 命中率怎么算,多少才算健康
命中率是衡量 Buffer Pool 效果最直接的指标。计算公式很简单:
命中率 = (1 - 磁盘读取次数 / 逻辑读取总次数) × 100%
你可以用下面这条 SQL 查看:
其中 Innodb_buffer_pool_read_requests 是逻辑读次数,Innodb_buffer_pool_reads 是实际从磁盘读的次数。两者一除就能算出命中率。
那多少算健康?我的经验是:OLTP 业务场景下,命中率长期低于 99% 就要警惕了。低于 95% 基本可以确定 Buffer Pool 偏小或者有大量全表扫描。但要注意,命中率不是越高越好,因为如果业务本身就是写多读少,或者数据量远超内存,命中率天然上不去,这时候盲目调大 Buffer Pool 反而可能挤占系统内存,引发 swap,得不偿失。
3.3 脏页刷盘:为什么你的写入会突然抖动
脏页刷盘是很多人踩坑的地方。Buffer Pool 里的页被修改后不会立刻写磁盘,而是先标记为脏页,等合适的时机再刷。这个“合适时机”由几个因素触发:
- redo log 快写满:这是最紧急的情况,必须把脏页刷下去才能推进 checkpoint。
- Buffer Pool 空间不足:需要腾地方给新页,就得先刷脏页。
- 系统空闲:后台线程慢慢刷。
- 正常关闭:全部刷干净。
问题就出在第一种情况。如果 redo log 设置得太小,写入又很猛,redo log 很快逼近上限,就会触发“激进刷脏”,大量脏页集中刷盘,磁盘 IO 瞬间飙升,业务侧就表现为写入突然变慢、抖动。这就是为什么我一般建议 redo log 不要设太小,宁可稍微大一点,给刷脏留出缓冲。
我踩过的一个坑:早期给一个日志写入量很大的服务配了默认的 redo log 大小,结果每天固定时段出现写入毛刺,查了半天才发现是 redo log 周期性写满触发的集中刷脏。后来把 redo log 调大,毛刺基本消失。这个经验告诉我,内存和磁盘的配合,很多时候卡在“节奏”上,而不是“容量”上。
3.4 Change Buffer:写多读少场景的隐藏功臣
Change Buffer 是 InnoDB 内存里一个容易被忽视但很有价值的结构。它的作用是:当你要修改一个非唯一二级索引,而这个索引页又不在 Buffer Pool 里时,InnoDB 不会立刻把页读进来改,而是先把这次修改记录在 Change Buffer 里,等以后这个页被读进来时再合并。
为什么只针对非唯一二级索引?因为唯一索引需要校验唯一性,必须把页读进来才能判断,没法延迟。这个设计非常巧妙:它把“随机读”转化成了“顺序写”,大幅减少了磁盘随机 IO。
适用场景很明确:写多读少,且涉及大量非唯一二级索引更新。比如日志类、流水类业务。如果你的业务是读多写少,或者索引基本都是唯一的,Change Buffer 的价值就不大。它的内存占比由 innodb_change_buffer_max_size 控制,默认是 Buffer Pool 的 25%。这个值不是越大越好,太大反而会挤占正常页缓存。
4. 磁盘侧深挖:B+ 树为什么是 InnoDB 的最终选择
4.1 从二叉树到 B+ 树:一步步推演出来的结构
很多人问“B+ 树是红黑树吗”,答案是不是。红黑树是二叉平衡树,B+ 树是多路平衡树,两者解决的问题场景不同。要理解 B+ 树,最好从“为什么不用别的结构”推起。
假设我们用二叉搜索树存索引。数据量大时,树高会很高,比如 100 万条数据,树高约 20 层,意味着一次查询最多 20 次磁盘 IO,太慢。红黑树虽然平衡,但本质还是二叉,树高依然是 log₂N,改善有限。
那用哈希表呢?哈希表等值查询是 O(1),很快,但它不支持范围查询,也没法排序。而数据库查询里范围查询(比如 WHERE id > 100 AND id < 200)太常见了,哈希表直接出局。
于是就有了多路平衡树:每个节点不只存一个键,而是存多个键、有多个子节点。这样树高大幅降低。B 树就是多路平衡树,但 B 树每个节点既存键又存数据,导致一个页能放的键变少,树还是偏高。B+ 树做了改进:非叶子节点只存键和指针,不存数据;所有数据都在叶子节点,且叶子节点用链表串起来。这样一来,非叶子节点能放更多键,树更矮;叶子链表又天然支持范围查询。这就是 InnoDB 选 B+ 树的根本原因。
4.2 聚簇索引与二级索引:两种 B+ 树的区别
InnoDB 里其实有两类 B+ 树:聚簇索引(主键索引)和二级索引(辅助索引)。它们的结构差异直接决定了查询行为。
聚簇索引的叶子节点存的是完整的行数据。也就是说,表数据本身就是按主键组织在聚簇索引的叶子节点上的。这也是为什么 InnoDB 必须有主键——没有主键它会自动生成一个隐藏主键。
二级索引的叶子节点存的是索引列的值 + 主键值,不存完整行数据。所以通过二级索引查数据时,如果需要的列不在索引里,就得拿着主键值再回聚簇索引查一次,这就是著名的“回表”。
回表是性能杀手。假设你建了 idx_name 索引,查询 SELECT * FROM user WHERE name = 'xxx',流程是:先在 idx_name 的 B+ 树里找到主键,再拿主键回聚簇索引找完整行。两次 B+ 树查找,如果都不命中缓存,就是双倍磁盘 IO。这就是为什么“覆盖索引”那么重要——如果查询的列都在索引里,就不用回表。
4.3 页的内部结构:一条记录是怎么被找到的
一个 16KB 的页里,记录不是随便堆的,而是有严格结构。页内主要包含:
- File Header / File Trailer:页的元信息,用于校验和链表串联。
- Page Header:页的状态信息,比如记录数、空闲空间位置。
- Infimum / Supremum:虚拟的最小和最大记录,作为边界。
- User Records:真正的行记录,按主键顺序排列。
- Page Directory:页目录,存放若干“槽”,每个槽指向一条记录。
页内查找用的是页目录 + 二分查找。先在页目录的槽上二分,定位到大致范围,再在范围内顺序查找。这个设计让页内查找也是 O(log n) 级别,而不是全页扫描。
这里有个细节值得说:记录在页内是单向链表串起来的,按主键顺序排列。所以页内范围扫描其实是顺着链表走。页目录的作用就是给这个链表建“跳表”式的索引,加速定位。
4.4 树高与数据量的关系:算一笔账
很多人对“树高 3 到 4 层能存多少数据”没概念,我带你算一笔账。
假设主键是 bigint,占 8 字节;页指针占 6 字节;那么非叶子节点一个键值对约 14 字节。一个 16KB 的页,除去页头页尾开销,大约能放 1000 多个这样的键值对,也就是一个非叶子节点能指向约 1000 个子节点。
叶子节点存行数据。假设一行数据 1KB,一个 16KB 的页能放约 16 行。
那么:
- 树高 2 层:根节点指向 1000 个叶子页,每个叶子 16 行,约 1.6 万行。
- 树高 3 层:根 → 1000 个中间节点 → 每个中间节点 1000 个叶子页,约 1000 × 1000 × 16 = 1600 万行。
- 树高 4 层:约 160 亿行。
所以对于千万级、亿级数据,树高通常就是 3 到 4 层。这意味着一次查询最多 3 到 4 次页读取。如果这些页都在 Buffer Pool 里,那就是纯内存操作,微秒级;如果不在,每次都是磁盘 IO,毫秒级。差距就是这么来的。
5. 内存与磁盘的配合:redo log、undo log 与 checkpoint
5.1 redo log:让内存修改不丢的保障
Buffer Pool 里的脏页如果还没刷盘,数据库突然宕机,数据不就丢了?redo log 就是解决这个问题的。它的思路是 WAL(Write-Ahead Logging):修改数据前,先把修改操作写到 redo log,并且 redo log 要先落盘。这样即使脏页没刷,重启后也能根据 redo log 重放,恢复数据。
redo log 是物理逻辑日志,记录的是“在某个页的某个位置做了什么修改”。它也是环形结构,由 write pos 和 checkpoint 两个指针管理。write pos 是当前写到的位置,checkpoint 是已经刷盘(对应脏页已落盘)的位置。两者之间的区域就是还没落盘的部分。
这里的关键是:redo log 的大小决定了能积压多少未刷脏页。太小,checkpoint 追得紧,刷脏频繁;太大,宕机恢复时间长。一般建议设置为能容纳 1 到 2 小时的写入量,具体要看业务写入速率。
5.2 undo log:回滚与 MVCC 的基石
undo log 记录的是数据的“旧版本”,主要干两件事:事务回滚和MVCC(多版本并发控制)。当你更新一行数据,undo log 会记下更新前的值。如果事务回滚,就用 undo log 恢复;如果其他事务要读这行数据的历史版本,也通过 undo log 找到。
undo log 和 redo log 的关系很有意思:undo log 本身的写入也会产生 redo log,因为它也需要持久化。所以 redo log 是“日志的日志”的最终保障。这个层次关系理清楚,你就明白为什么 redo log 如此关键。
5.3 checkpoint:内存与磁盘的“对账”机制
checkpoint 是内存和磁盘之间的对账点。它标记了“这个位置之前的脏页都已经刷盘了”。checkpoint 推进的过程,就是脏页不断落盘的过程。如果 checkpoint 推进太慢,redo log 空间被占满,写入就会被阻塞。
所以你会看到一个现象:写入突然卡住,往往不是磁盘满了,而是 redo log 满了,checkpoint 追不上。这时候要么调大 redo log,要么提升刷脏速度(比如调大 innodb_io_capacity)。理解 checkpoint 机制,是排查写入抖动的关键。
6. 实操排查:从数据结构视角定位性能问题
6.1 常见问题速查表
| 现象 | 可能的数据结构原因 | 排查方向 |
|---|---|---|
| 查询偶尔慢 | Buffer Pool 命中率低,热数据被挤出 | 查命中率,评估 Buffer Pool 大小 |
| 写入周期性抖动 | redo log 太小,集中刷脏 | 查 redo log 配置和刷脏频率 |
| 范围查询慢 | 二级索引回表次数多 | 看执行计划,考虑覆盖索引 |
| 内存告警 | Buffer Pool 过大挤占系统内存 | 评估总内存分配比例 |
| 索引失效 | 页内查找效率低或统计信息过期 | 更新统计信息,检查索引选择性 |
6.2 几个我常用的排查命令
查看 Buffer Pool 状态:
重点看 BUFFER POOL AND MEMORY 段,里面有命中率、脏页比例、free buffers 等。
查看 redo log 相关:
关注 Innodb_log_waits,如果这个值持续增长,说明 redo log 空间不足导致等待。
查看索引使用情况:
这个视图能帮你找出没被使用的索引,减少不必要的索引维护开销。
6.3 实操心得:调参之前先看数据
我见过太多人一上来就改参数,结果越改越糟。我的习惯是:任何调参之前,先收集至少一个完整业务周期的监控数据。Buffer Pool 命中率、redo log 等待次数、脏页比例、磁盘 IO 利用率,这几个指标看一周,你基本就能判断瓶颈在哪。
还有一个经验:不要迷信“标准配置”。网上流传的 innodb_buffer_pool_size 设为物理内存 70% 到 80%,那是通用建议。如果你的机器上还跑着其他服务,或者数据量本身很小,照搬反而有害。参数是死的,业务是活的。
7. 几个高频疑问的直球回答
7.1 B+ 树是红黑树吗
不是。红黑树是二叉平衡树,B+ 树是多路平衡树。红黑树树高是 log₂N,B+ 树树高是 logₘN(m 是每个节点的子节点数,通常几百到上千)。数据库选 B+ 树就是为了降低树高、减少磁盘 IO。两者解决的问题层次不同,不能混为一谈。
7.2 Buffer Pool 越大越好吗
不是。Buffer Pool 太大会挤占系统内存,可能导致操作系统层面的内存压力,甚至触发 swap,反而拖慢整体性能。合理做法是留足系统和其他进程所需内存,剩下的再给 Buffer Pool。一般单实例独占的数据库服务器,给到物理内存的 60% 到 75% 比较稳妥。
7.3 为什么加了索引还是慢
加索引不等于用索引。可能的原因包括:索引选择性差(比如性别字段)、查询条件导致索引失效(比如对索引列做函数运算)、回表次数太多、统计信息过期导致优化器选错索引。排查时先看执行计划,确认索引是否真的被用上,再看回表情况。
7.4 内存和磁盘的数据结构,哪个更重要
都重要,但优先级不同。内存侧决定“快不快”,磁盘侧决定“稳不稳”。Buffer Pool 命中率高,查询就快;B+ 树设计合理、redo log 配置得当,写入就稳。实际排查中,我一般先看内存侧指标(命中率、脏页比例),再看磁盘侧(IO 利用率、redo log 等待),因为内存问题更容易暴露也更容易调整。
8. 我个人的一点经验收尾
搞 MySQL 这些年,我最大的体会是:不要孤立地记数据结构,要把它们放进“一次请求的完整链路”里去理解。B+ 树不是画在纸上的图,它是磁盘上真实存在的页;Buffer Pool 也不是一个抽象概念,它是内存里真实存在的缓存页。当你把一次查询从客户端到磁盘再回到客户端的过程在脑子里跑通,很多问题就自然有答案了。
最后分享一个小技巧:如果你刚开始学这块,别急着背参数。先找一台测试机,装个 MySQL,建一张几百万行的表,然后故意把 Buffer Pool 调小,观察查询耗时变化;再调大,再观察。这种“手动制造问题再解决”的过程,比看十篇文章都管用。数据结构这东西,看会了不算会,跑通了才算。