Skip to content
Charles Shao
Go back

MySQL 内核(开篇)· InnoDB 存储结构与 B+ 树索引

views

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

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

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

一句话定位: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 上都是「读写整个页」——这也是为什么「行虽小,随机点太多」依旧很贵。

行格式(如 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 默认选择

树高通常 2~4 层:即使上亿行,定位一行也往往只要几次页读(再叠加 BP 命中,就是微秒~毫秒级)。

「页里记录怎么找」也值得一眼:页目录把记录分成若干槽,先二分到槽,再在槽内短链上挪动。所以单页内记录过多、行过大时,页内查找与分裂成本都会上升——这也是宽表拆分、避免大字段进热点行的微观理由。

4. 聚簇索引与二级索引

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

InnoDB 把表数据按主键组织成一棵 B+ 树:叶子节点存放完整行。含义:

广告库里流水表常用雪花/号段整数主键(见 分布式 ID),配置表用自增或业务整型主键,都是为了让聚簇树「又瘦又稳」。

4.2 二级索引与回表

二级索引(辅助索引)叶子存的是:(索引列值, 主键)。流程:

  1. 在二级索引上定位到主键;
  2. 再用主键去聚簇索引取完整行 → 回表

回表 = 额外的随机页读。SELECT * 从二级索引进来,基本都会回表;若只查索引列 + 主键,则可能覆盖、免回表。

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

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

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 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 占用要取舍。计费流水写多读少,索引要克制;配置热点读多写少,可以为头部查询多建覆盖索引。

监控上常看:

参考


views
Share this post on:

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