Skip to content
Charles Shao
Go back

MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引

–views

广告系统的配置库、预算账本、计费流水,底层几乎都落在 MySQL + InnoDB 上。分库分表、读写分离讲的是怎么扩(见 数据库扩展);MySQL 内核 这条线则专门讲内核怎么干活——页怎么组织、索引怎么找行、事务怎么隔离、日志怎么 crash-safe、计划怎么选、主从怎么同步。开篇先把地基铺平:InnoDB 把数据放在哪、B+ 树索引长什么样、一次查询在磁盘和内存里到底怎么走。

本文是 MySQL 内核 系列的第 1 篇(开篇 · InnoDB 存储结构与 B+ 树索引)。 全系列 5 篇:

  1. MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引(本篇)
  2. MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁
  3. MySQL 内核深挖 · redo、undo 与 binlog 机制
  4. MySQL 内核深挖 · EXPLAIN 计划与慢查询优化
  5. MySQL 内核深挖 · 主从复制链路与 HA 高可用

一句话定位:InnoDB 用 16KB 页做磁盘交互单位,用 B+ 树把「按主键/二级键定位行」变成稳定的对数级查找;搞清页/区/段、Buffer Pool、聚簇 vs 二级与回表,是后面事务、日志、优化全部立得住的前提。

TL;DR

Table of contents

Open Table of contents

1. 为什么从存储结构讲起

改一条 campaign 的预算、按 campaign_id 拉一堆创意配置、按主键扫一段计费流水——表面是 SQL,底下全是另一回事:定位到哪个 16KB 页、页在不在 Buffer Pool、走哪棵 B+ 树、要不要回表。索引设计、慢查询、锁等待这些线上常踩的坑,最后几乎都能归约成「少碰几次磁盘页」。正因如此,不懂页和索引,后面要讲的 MVCC、日志、EXPLAIN 都像悬在半空——这也是为什么开篇必须从存储结构切入。

2. 页、区、段:表在磁盘上怎么躺着

2.1 页:一切的最小单位

InnoDB 默认页大小 16KB(编译期决定,生产环境别乱改)。一页里大致装着这些部件:

写一行、读一行,落到 IO 上都是「读写整个页」——这也是为什么「行虽小,一旦随机点查太多」依旧会让 IO 扛不住的根因。行格式(如 DYNAMIC 或 COMPACT)决定了大字段是否场外存储:创意的超长 JSON、素材 URL 列表若常被 SELECT * 拖进结果,不仅会挤占宝贵的页内空间,还会成倍放大网络开销。因此,决策热路径应当只取窄列,大字段则建议拆表或延迟加载。

InnoDB 16KB 页内部布局与 B+ 树三层结构示意:上半部分展示 File Header、Page Header、Infimum、User Records、Free Space、Supremum、Page Directory、File Trailer;下半部分展示根页、非叶页与叶子页,叶子页之间双向链表连接。

页是磁盘与 Buffer Pool 交互的最小单位;B+ 树等值查从根下钻到叶,范围扫沿叶子链表走。

2.2 区与段

页往上一级是区,再往上是段:

可以粗记成一条包含链:表空间 ⊃ 段 ⊃ 区 ⊃ 页 ⊃ 行。

2.3 Buffer Pool:热点必须常驻内存

页是磁盘上的最小单位,但真正决定配置库性能的,是这些页有多少能常驻内存。几乎所有数据页访问都先打 Buffer Pool(BP):

需要强调的是,BP 的 LRU 链表通常分为 young 和 old 两区,以此避免一次全表扫描就把热点页全冲掉。映射到广告场景:投放决策反复读取的 campaign 或创意行应当保持极高的命中率,而半夜跑批全表对账扫流水时则极易污染 BP——这正是运维上必须做实例隔离或错峰扫描的根本原因。

调参直觉(细节以官方文档为准):

磁盘侧表空间→段→区→页与内存侧 Buffer Pool(LRU / Flush List)的对应关系。

读写先打 Buffer Pool;脏页进 Flush List,按 LSN 逐渐刷回磁盘。

3. B+ 树索引:为什么是它

页讲完了,下一个问题是:上千万行散落在无数页里,怎么快速定位到目标行?OLTP 场景同时需要满足两件事:

  1. 等值查找(WHERE id=?)——要求对数次页读就能搞定;
  2. 范围扫描与排序(WHERE ts BETWEEN … ORDER BY ts)——要求沿叶子链表顺序走即可。

B+ 树正好两头都照顾:它的非叶节点只存键值和子页指针,因此扇出极大、树高极矮;同时,全部行数据(或二级索引载荷)都被推到了叶子节点,且叶子节点之间通过双向链表串联。对比几种常见结构:

结构等值范围典型代价
Hash极快弱只适合纯点查
B 树好一般非叶也存数据,扇出较小
B+ 树好强InnoDB 默认选择

正因如此,B+ 树的高度通常只有 2~4 层:即使表里有上亿行数据,定位单行也往往只需几次页读(再叠加 BP 的高命中率,耗时基本在微秒到毫秒级)。回到「页内记录怎么找」这个微观视角也值得看一眼:Page Directory 把记录分成了若干槽,查找时先二分定位到槽,再在槽内的单向短链上遍历。这也解释了为什么单页内记录过多或单行过大时,页内查找与分裂成本都会急剧上升——这正是业务上要做宽表拆分、避免大字段混入热点行的底层逻辑。

4. 聚簇索引与二级索引

B+ 树只是骨架,InnoDB 真正的巧思在于「数据本身就长在树上」。这一节拆开看聚簇与二级两条路径,以及它们之间那笔最容易低估的回表成本。

4.1 聚簇索引:数据即主键树

InnoDB 的核心设计在于把表数据按主键组织成一棵 B+ 树,叶子节点存放着完整行。这种设计带来了三层直接影响:

在广告库的实战中,流水表通常采用雪花或号段生成的整数主键(见 分布式 ID),而配置表多用自增或业务整型主键,根本目的都是为了让这棵聚簇树保持「又瘦又稳」。

4.2 二级索引与回表

聚簇树全表只有一棵,但二级索引(辅助索引)却可以建很多棵。二级索引的叶子节点只存 (索引列值, 主键),这就导致查询流程必须分两步走:

  1. 首先在二级索引树上定位到目标主键;
  2. 接着用该主键去聚簇索引树里再查一次以获取完整行——这一步就是代价高昂的回表。

回表本质上等于一次额外的随机页读。当 SELECT * 从二级索引打进来时,基本都逃不掉回表的命运;但如果查询只索要索引列和主键,则可能触发覆盖索引,从而直接免去回表。

二级索引叶子存 campaign_id→主键,再回表到聚簇索引取完整行;覆盖索引可免回表。

回表是二级索引查询的隐形成本;覆盖索引把它抹掉。

4.3 覆盖索引

当 SELECT 列表与 WHERE 或 ORDER BY 所需的列全部落在某个二级索引里时,优化器就可以只扫二级索引直接返回结果(EXPLAIN 里体现为 Extra: Using index)。在 AdTech 场景中有一个典型例子:

-- 决策引擎只需要 campaign 状态与出价上限
SELECT campaign_id, status, bid_cap
FROM campaign
WHERE advertiser_id = ? AND status = 1;
-- 适合:idx(advertiser_id, status, campaign_id, bid_cap) 一类覆盖索引

需要警惕的是,千万别为了凑覆盖索引而把它做成「整行拷贝」——严重的写入放大和 BP 内存占用迟早会反噬系统。覆盖索引也绝非万能药:如果业务明天突然要求多返回 20 个列,你要么硬着头皮改索引,要么只能乖乖接受回表。因此,设计覆盖列时必须以稳定热路径的窄投影为准,切莫幻想用一个超级索引吃尽所有的后台查询。

5. 联合索引与最左前缀

如果说覆盖索引解决的是「查什么」的问题,那么联合索引解决的就是「按什么顺序查」。例如 idx(advertiser_id, status, updated_at) 这条索引能高效支撑以下查询:

但它不能高效支撑的,是直接 WHERE status = ?(因为跳过了最左列)。此外,中间列一旦使用了范围条件,其后的列通常也就难以再用于精确过滤了(优化器往往只走到范围列为止)。

设计口诀:区分度高、等值多用的列靠左;范围列靠右;覆盖所需列量力追加。

实务上,我们还会碰到两个值得注意的优化器行为:

-- 联合索引 idx(advertiser_id, status, updated_at)
-- ✅ 走最左:等值 + 等值 + 范围
SELECT id, name FROM campaign
WHERE advertiser_id = 10086 AND status = 1 AND updated_at > '2026-07-01';

-- ❌ 跳过最左:基本用不上该联合索引
SELECT id FROM campaign WHERE status = 1;

-- ❌ 中间断:a 等值后对 b 做范围,c 往往帮不上精确过滤
SELECT id FROM campaign
WHERE advertiser_id = 10086 AND updated_at > '2026-07-01';  -- 若没有单独合适的索引

6. 主键怎么选(广告库视角)

主键直接决定了聚簇树的「骨架」,一旦选错,整棵树的插入与分裂节奏都会被打乱:

选型优点风险
自增 BIGINT顺序插入、页分裂少、二级索引带主键更瘦单点热点页、分库要配合号段
雪花 / 号段整型全局大致有序、适合分布式发号时钟回拨等发号问题(见分布式 ID 文)
UUID 字符串应用层好生成随机插入碎页、二级索引膨胀——配置 / 流水库尽量避免当主键

对于计费流水、曝光明细这类典型的追加写表,主键应尽量选择时间或趋势有序的整数;而广告主配置表由于行数通常不大,直接用自增即可。再次提醒,宽行(如包含大 JSON 的创意素材)千万不要全塞进热点行里——最好拆分到附属表,以避免一次回表就把一大坨冷数据拖进 BP。

7. 索引维护成本与 Change Buffer

索引建得多,读确实快了,但写的时候却要加倍还债。每次执行 INSERT、UPDATE 或 DELETE 时,引擎至少要在底层做三件事:

对于非唯一二级索引的更新,InnoDB 可以利用 Change Buffer 先缓存对二级索引页的变更,稍后再做合并,以此大幅减轻随机 IO 的压力——这对于「写多且二级索引页常常不在 BP 内」的流水表极有帮助。但使用时必须牢记两个限制:

这也解释了为什么配置表的索引绝不是越多越好:我们必须在读加速 vs 写放大与 BP 占用之间做出艰难取舍。计费流水通常写多读少,建索引必须克制;而配置热点往往读多写少,则可以放心地为头部查询多建几条覆盖索引。

日常监控上,我们常盯这几组核心指标:

排障口诀:慢查先看命中率,回表成本在二级;主键选瘦树才稳,覆盖到位免回表;宽行大字段拆附属,索引越多写越贵。

到这里,从页、区、段到 Buffer Pool,再到聚簇与二级索引、回表与覆盖、联合索引最左前缀、主键选型以及 Change Buffer——这些就是 InnoDB 存储与索引的全部地基。下一篇 MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁 会把视角从「页怎么躺」转到「行怎么被并发读写看见」:ReadView、版本链、记录锁与间隙锁,正是建在这套 B+ 树骨架之上的并发控制层。

参考


–views
Share this post on:

Previous Post
特征工程总览:算法是大厨,但菜的上限由「备料」决定
Next Post
Flink 深挖 · 部署模式与 Flink on K8s