广告系统的配置库、预算账本、计费流水,底层几乎都落在 MySQL + InnoDB 上。分库分表、读写分离讲的是怎么扩(见 数据库扩展);MySQL 内核 这条线则专门讲内核怎么干活——页怎么组织、索引怎么找行、事务怎么隔离、日志怎么 crash-safe、计划怎么选、主从怎么同步。开篇先把地基铺平:InnoDB 把数据放在哪、B+ 树索引长什么样、一次查询在磁盘和内存里到底怎么走。
本文是 MySQL 内核 系列的第 1 篇(开篇 · InnoDB 存储结构与 B+ 树索引)。 全系列 5 篇:
- MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引(本篇)
- MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁
- MySQL 内核深挖 · redo、undo 与 binlog 机制
- MySQL 内核深挖 · EXPLAIN 计划与慢查询优化
- MySQL 内核深挖 · 主从复制链路与 HA 高可用
一句话定位:InnoDB 用 16KB 页做磁盘交互单位,用 B+ 树把「按主键/二级键定位行」变成稳定的对数级查找;搞清页/区/段、Buffer Pool、聚簇 vs 二级与回表,是后面事务、日志、优化全部立得住的前提。
TL;DR
- 页(Page,默认 16KB)是 InnoDB 读写与 Buffer Pool 缓存的最小单位;往上是区(Extent)= 64 连续页 ≈ 1MB,再往上是按用途划分的段(Segment)(叶子段、非叶段、回滚段等)。
- Buffer Pool 缓存数据页与索引页:读先找 BP,写先改 BP 脏页再靠 redo / 异步刷盘——命中率是配置库性能的第一指标。
- 聚簇索引:InnoDB 表数据本身就是主键 B+ 树,叶子存完整行;没有显式主键时会退而选唯一非空索引,再不行就生成隐藏
row_id。 - 二级索引:叶子只存「索引列 + 主键」,要拿完整行必须用主键再查一次聚簇索引——也就是回表。
- 覆盖索引:
SELECT的列都在二级索引里即可免回表,广告配置热点读常用这招。 - 联合索引最左前缀:
idx(a,b,c)可支撑a、a+b、a+b+c,不能支撑「跳过 a 只查 b」;中间列一旦断裂,后面的列也用不上。 - B+ 树优势:非叶只存键 + 指针、数据全在叶,叶页双向链表天然适合范围扫与排序——比 Hash(等值强、范围弱)、比普通 B 树(非叶也存数据、扇出更小)都更适合 OLTP。
Table of contents
Open Table of contents
1. 为什么从存储结构讲起
改一条 campaign 的预算、按 campaign_id 拉一堆创意配置、按主键扫一段计费流水——表面是 SQL,底下全是另一回事:定位到哪个 16KB 页、页在不在 Buffer Pool、走哪棵 B+ 树、要不要回表。索引设计、慢查询、锁等待这些线上常踩的坑,最后几乎都能归约成「少碰几次磁盘页」。正因如此,不懂页和索引,后面要讲的 MVCC、日志、EXPLAIN 都像悬在半空——这也是为什么开篇必须从存储结构切入。
2. 页、区、段:表在磁盘上怎么躺着
2.1 页:一切的最小单位
InnoDB 默认页大小 16KB(编译期决定,生产环境别乱改)。一页里大致装着这些部件:
- File Header / File Trailer:页号、LSN、校验和;
- Page Header:记录数、堆顶、槽位信息等;
- Infimum / Supremum:页内最小 / 最大哨兵记录;
- User Records:真正的行,按主键顺序用单链表串起;
- Free Space:可插入的空闲区;
- Page Directory:槽位数组,支持页内二分找记录。
写一行、读一行,落到 IO 上都是「读写整个页」——这也是为什么「行虽小,一旦随机点查太多」依旧会让 IO 扛不住的根因。行格式(如 DYNAMIC 或 COMPACT)决定了大字段是否场外存储:创意的超长 JSON、素材 URL 列表若常被 SELECT * 拖进结果,不仅会挤占宝贵的页内空间,还会成倍放大网络开销。因此,决策热路径应当只取窄列,大字段则建议拆表或延迟加载。
页是磁盘与 Buffer Pool 交互的最小单位;B+ 树等值查从根下钻到叶,范围扫沿叶子链表走。
2.2 区与段
页往上一级是区,再往上是段:
- 区(extent):连续 64 页 ≈ 1MB,用来降低物理碎片、让范围扫更顺;
- 段(segment):逻辑分配单元,每个索引通常有叶子段和非叶段,另外还有系统用途的回滚段等;表空间(
.ibd或系统表空间)装着若干段。
可以粗记成一条包含链:表空间 ⊃ 段 ⊃ 区 ⊃ 页 ⊃ 行。
2.3 Buffer Pool:热点必须常驻内存
页是磁盘上的最小单位,但真正决定配置库性能的,是这些页有多少能常驻内存。几乎所有数据页访问都先打 Buffer Pool(BP):
- 命中:直接走内存读写;
- 缺失:从磁盘读入页,可能挤走别的页(触发 LRU 淘汰);
- 修改:先在内存里改成脏页并记入 redo log,随后再由后台线程异步落盘(checkpoint)。
需要强调的是,BP 的 LRU 链表通常分为 young 和 old 两区,以此避免一次全表扫描就把热点页全冲掉。映射到广告场景:投放决策反复读取的 campaign 或创意行应当保持极高的命中率,而半夜跑批全表对账扫流水时则极易污染 BP——这正是运维上必须做实例隔离或错峰扫描的根本原因。
调参直觉(细节以官方文档为准):
innodb_buffer_pool_size:通常占据实例内存的大头(常见建议在 60%~70% 量级,视同机杂活而定);- 多个 BP instance:用于减少高并发下缓存链表的锁竞争;
- 观察命中率比盲目加内存更优先:当命中率已经达到 99.9% 时,系统的瓶颈往往卡在 SQL 效率与锁冲突上,而不再是内存容量。
读写先打 Buffer Pool;脏页进 Flush List,按 LSN 逐渐刷回磁盘。
3. B+ 树索引:为什么是它
页讲完了,下一个问题是:上千万行散落在无数页里,怎么快速定位到目标行?OLTP 场景同时需要满足两件事:
- 等值查找(
WHERE id=?)——要求对数次页读就能搞定; - 范围扫描与排序(
WHERE ts BETWEEN … ORDER BY ts)——要求沿叶子链表顺序走即可。
B+ 树正好两头都照顾:它的非叶节点只存键值和子页指针,因此扇出极大、树高极矮;同时,全部行数据(或二级索引载荷)都被推到了叶子节点,且叶子节点之间通过双向链表串联。对比几种常见结构:
| 结构 | 等值 | 范围 | 典型代价 |
|---|---|---|---|
| Hash | 极快 | 弱 | 只适合纯点查 |
| B 树 | 好 | 一般 | 非叶也存数据,扇出较小 |
| B+ 树 | 好 | 强 | InnoDB 默认选择 |
正因如此,B+ 树的高度通常只有 2~4 层:即使表里有上亿行数据,定位单行也往往只需几次页读(再叠加 BP 的高命中率,耗时基本在微秒到毫秒级)。回到「页内记录怎么找」这个微观视角也值得看一眼:Page Directory 把记录分成了若干槽,查找时先二分定位到槽,再在槽内的单向短链上遍历。这也解释了为什么单页内记录过多或单行过大时,页内查找与分裂成本都会急剧上升——这正是业务上要做宽表拆分、避免大字段混入热点行的底层逻辑。
4. 聚簇索引与二级索引
B+ 树只是骨架,InnoDB 真正的巧思在于「数据本身就长在树上」。这一节拆开看聚簇与二级两条路径,以及它们之间那笔最容易低估的回表成本。
4.1 聚簇索引:数据即主键树
InnoDB 的核心设计在于把表数据按主键组织成一棵 B+ 树,叶子节点存放着完整行。这种设计带来了三层直接影响:
- 主键查找速度最快,因为下钻到叶子即可直接命中完整行;
- 按主键进行范围扫描对磁盘顺序 IO 极为友好;
- 主键选得太长(比如 UUID 字符串)不仅会让页分裂变得更细碎,还会让所有二级索引跟着变胖(因为二级索引叶子必须携带主键)。
在广告库的实战中,流水表通常采用雪花或号段生成的整数主键(见 分布式 ID),而配置表多用自增或业务整型主键,根本目的都是为了让这棵聚簇树保持「又瘦又稳」。
4.2 二级索引与回表
聚簇树全表只有一棵,但二级索引(辅助索引)却可以建很多棵。二级索引的叶子节点只存 (索引列值, 主键),这就导致查询流程必须分两步走:
- 首先在二级索引树上定位到目标主键;
- 接着用该主键去聚簇索引树里再查一次以获取完整行——这一步就是代价高昂的回表。
回表本质上等于一次额外的随机页读。当 SELECT * 从二级索引打进来时,基本都逃不掉回表的命运;但如果查询只索要索引列和主键,则可能触发覆盖索引,从而直接免去回表。
回表是二级索引查询的隐形成本;覆盖索引把它抹掉。
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 advertiser_id = ?WHERE advertiser_id = ? AND status = ?WHERE advertiser_id = ? AND status = ? AND updated_at > ?- 以及同前缀下的
ORDER BY updated_at(当条件匹配时)
但它不能高效支撑的,是直接 WHERE status = ?(因为跳过了最左列)。此外,中间列一旦使用了范围条件,其后的列通常也就难以再用于精确过滤了(优化器往往只走到范围列为止)。
设计口诀:区分度高、等值多用的列靠左;范围列靠右;覆盖所需列量力追加。
实务上,我们还会碰到两个值得注意的优化器行为:
- Index Condition Pushdown(ICP):只要能在二级索引上过滤的条件,就尽量推到引擎层先滤掉,以此大幅减少无谓的回表次数;
- 松散索引扫描与索引合并:优化器有时会用
index_merge对 OR 两路索引取并集或做交集——但这往往不如直接建一条设计合理的联合索引来得稳当。
-- 联合索引 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 时,引擎至少要在底层做三件事:
- 维护聚簇树(可能触发昂贵的页分裂);
- 维护每一棵相关的二级索引;
- 记录 undo log 与 redo log(详见后续日志篇)。
对于非唯一二级索引的更新,InnoDB 可以利用 Change Buffer 先缓存对二级索引页的变更,稍后再做合并,以此大幅减轻随机 IO 的压力——这对于「写多且二级索引页常常不在 BP 内」的流水表极有帮助。但使用时必须牢记两个限制:
- 唯一索引必须立刻检查冲突,因此绝对不能进入 Change Buffer;
- 当查询读到该页时会强制触发 merge,所以性能突刺往往出现在「大批量写完不久后的范围读」上。
这也解释了为什么配置表的索引绝不是越多越好:我们必须在读加速 vs 写放大与 BP 占用之间做出艰难取舍。计费流水通常写多读少,建索引必须克制;而配置热点往往读多写少,则可以放心地为头部查询多建几条覆盖索引。
日常监控上,我们常盯这几组核心指标:
Innodb_buffer_pool_read_requestsvsInnodb_buffer_pool_reads→ 用于推算 BP 命中率;Innodb_rows_read→ 观察其是否与慢查询扫描行数同向飙升;- 页分裂与扩展相关状态(不同版本指标名略有差异)。
排障口诀:慢查先看命中率,回表成本在二级;主键选瘦树才稳,覆盖到位免回表;宽行大字段拆附属,索引越多写越贵。
到这里,从页、区、段到 Buffer Pool,再到聚簇与二级索引、回表与覆盖、联合索引最左前缀、主键选型以及 Change Buffer——这些就是 InnoDB 存储与索引的全部地基。下一篇 MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁 会把视角从「页怎么躺」转到「行怎么被并发读写看见」:ReadView、版本链、记录锁与间隙锁,正是建在这套 B+ 树骨架之上的并发控制层。
参考
- MySQL 官方文档. The InnoDB Storage Engine:页、表空间、索引组织表。
- MySQL 官方文档. Clustered and Secondary Indexes:聚簇与二级索引定义。
- MySQL 官方文档. InnoDB Buffer Pool:Buffer Pool、LRU、刷脏。
- 《MySQL 技术内幕:InnoDB 存储引擎》:页结构与 B+ 树实现的经典中文材料。
- Jeremy Cole. InnoDB:页与索引结构解析系列:二进制层面的页布局笔记。