广告系统的配置库、预算账本、计费流水,底层几乎都落在 MySQL + InnoDB 上。分库分表、读写分离讲的是怎么扩(见 数据库扩展);这个系列专门讲内核怎么干活——页怎么组织、索引怎么找行、事务怎么隔离、日志怎么 crash-safe、计划怎么选、主从怎么同步。开篇先把地基铺平:InnoDB 把数据放在哪、B+ 树索引长什么样、一次查询在磁盘和内存里怎么走。
本文是 MySQL 内核系列的第 1 篇(开篇 · InnoDB 存储结构与 B+ 树索引)。 全系列 5 篇:
- MySQL 内核(开篇)· InnoDB 存储结构与 B+ 树索引(本篇)
- MySQL 内核 · 事务隔离、MVCC 与锁
- MySQL 内核 · redo log、undo log 与 binlog
- MySQL 内核 · 执行计划与慢查询优化
- MySQL 内核 · 主从复制与高可用
一句话定位: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 上都是「读写整个页」——这也是为什么「行虽小,随机点太多」依旧很贵。
行格式(如 DYNAMIC/COMPACT)决定大字段是否场外存储:创意的超长 JSON、素材 URL 列表若常被 SELECT * 拖进结果,会放大页内负担与网络开销。决策热路径应只取窄列,大字段拆表或延迟加载。
页是磁盘与 Buffer Pool 交互的最小单位;B+ 树等值查从根下钻到叶,范围扫沿叶子链表走。
2.2 区与段
- 区(extent):连续 64 页 ≈ 1MB,用来降低物理碎片、让范围扫更顺。
- 段(segment):逻辑分配单元。每个索引通常有叶子段和非叶段;还有系统用途的回滚段等。表空间(
.ibd或系统表空间)装着若干段。
可以粗记:表空间 ⊃ 段 ⊃ 区 ⊃ 页 ⊃ 行。
2.3 Buffer Pool:热点必须常驻内存
几乎所有数据页访问都先打 Buffer Pool(BP):
- 命中:直接内存读写;
- 缺失:从磁盘读入页,可能挤走别的页(LRU);
- 修改:先成脏页,记 redo,再异步刷盘(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 默认选择 |
树高通常 2~4 层:即使上亿行,定位一行也往往只要几次页读(再叠加 BP 命中,就是微秒~毫秒级)。
「页里记录怎么找」也值得一眼:页目录把记录分成若干槽,先二分到槽,再在槽内短链上挪动。所以单页内记录过多、行过大时,页内查找与分裂成本都会上升——这也是宽表拆分、避免大字段进热点行的微观理由。
4. 聚簇索引与二级索引
4.1 聚簇索引:数据即主键树
InnoDB 把表数据按主键组织成一棵 B+ 树:叶子节点存放完整行。含义:
- 主键查找最快(直接命中行);
- 按主键范围扫是顺序友好的;
- 主键选太长(如 UUID 字符串)会让所有二级索引变胖(二级索引叶子要带主键),也会让页分裂更碎。
广告库里流水表常用雪花/号段整数主键(见 分布式 ID),配置表用自增或业务整型主键,都是为了让聚簇树「又瘦又稳」。
4.2 二级索引与回表
二级索引(辅助索引)叶子存的是:(索引列值, 主键)。流程:
- 在二级索引上定位到主键;
- 再用主键去聚簇索引取完整行 → 回表。
回表 = 额外的随机页读。SELECT * 从二级索引进来,基本都会回表;若只查索引列 + 主键,则可能覆盖、免回表。
回表是二级索引查询的隐形成本;覆盖索引把它抹掉。
4.3 覆盖索引
当 SELECT 列表与 WHERE/ORDER BY 所需列都落在某个二级索引里时,优化器可以只扫二级索引(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 / redo(见后续日志篇)。
非唯一二级索引的更新,InnoDB 可用 Change Buffer 先缓存对二级索引页的变更,稍后合并,减轻随机 IO——这对「写多、二级索引页常常不在 BP」的流水表有帮助。但:
- 唯一索引要立刻检查冲突,不能偷懒进 Change Buffer;
- 读到该页时会 merge,突刺可能出现在「写完不久的范围读」。
配置表索引不是越多越好:读加速 vs 写放大 + BP 占用要取舍。计费流水写多读少,索引要克制;配置热点读多写少,可以为头部查询多建覆盖索引。
监控上常看:
Innodb_buffer_pool_read_requestsvsInnodb_buffer_pool_reads→ 命中率;Innodb_rows_read与慢查询扫描行是否同向飙升;- 页分裂/扩展相关状态(版本不同指标名略有差异)。
参考
- MySQL 官方文档. The InnoDB Storage Engine:页、表空间、索引组织表。
- MySQL 官方文档. Clustered and Secondary Indexes:聚簇与二级索引定义。
- MySQL 官方文档. InnoDB Buffer Pool:Buffer Pool、LRU、刷脏。
- 《MySQL 技术内幕:InnoDB 存储引擎》:页结构与 B+ 树实现的经典中文材料。
- Jeremy Cole. InnoDB:页与索引结构解析系列: 二进制层面的页布局笔记。