核心答案
B+Tree 分支多、树高低,单次查询需要的磁盘 IO 较少;叶子节点按顺序连接,既适合等值查询,也适合范围扫描。
B+Tree 的结构优势
数据库页面一次可以保存大量索引键和子节点指针。相比二叉树,B+Tree 在相同数据量下树高更低。非叶子节点主要用于导航,数据集中在叶子节点,因此每层能容纳更多分支。
聚簇索引与二级索引
InnoDB 主键索引的叶子节点保存整行数据,也叫聚簇索引。二级索引叶子节点保存索引列和主键值,通过二级索引查询其他列时,可能还要根据主键回到聚簇索引,这个过程称为回表。
联合索引与最左匹配
联合索引按定义顺序排序。索引 (a, b, c) 可以高效支持以 a 开始的查询条件;跳过最左列后,通常无法直接利用完整索引顺序完成定位。
CREATE INDEX idx_user_status_time
ON orders(user_id, status, created_at);
常见索引失效场景
- 对索引列进行函数或计算。
- 隐式类型转换。
- 联合索引未满足最左匹配。
- 前导模糊查询,如
LIKE '%java'。 - 优化器判断全表扫描成本更低。
参考资料
核心考点清单
- 聚簇索引叶子保存整行,二级索引叶子保存主键值。
- 二级索引查询非覆盖列需要回表,覆盖索引可减少一次树查找。
- 联合索引遵循最左前缀,范围条件会影响后续列的利用方式。
- 索引存在不代表一定使用,优化器会根据统计信息估算成本。
- 随机主键、过多索引和页分裂都会增加写入成本。
为什么不用普通二叉树或 Hash
B+Tree 分支多、树高低,一次页读取能获得大量键;叶子有序连接,兼顾等值、范围、排序和前缀查询。Hash 擅长等值定位,却不天然支持范围和排序。
高频追问与参考回答
追问 1:索引下推是什么?
存储引擎遍历二级索引时直接使用索引中的字段过滤,减少不满足条件记录的回表次数,但不一定减少索引扫描量。
追问 2:为什么索引会“失效”?
可能是不满足最左前缀、函数计算、隐式转换或返回比例过大。更准确地说,优化器认为其他路径更便宜,应结合 EXPLAIN ANALYZE 判断。
追问 3:为什么不能为每列都建索引?
每个索引都占空间并增加插入、更新、日志和缓存压力,应围绕高价值查询设计,定期清理冗余索引。
机制全景图
下面把「MySQL 为什么使用 B+Tree 索引?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["根据条件构造搜索键"]
A --> B["从 B+Tree 根节点下降"]
B --> C["定位叶子页区间"]
C --> D["回表或直接取覆盖列"]
D --> E["按顺序返回结果"]
完整链路:从输入到结果
沿着「根据条件构造搜索键 → 从 B+Tree 根节点下降 → 定位叶子页区间 → 回表或直接取覆盖列 → 按顺序返回结果」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 根据条件构造搜索键
优化器先依据谓词和索引定义构造可用搜索边界,联合索引遵守最左前缀与范围截断规律。
2. 从 B+Tree 根节点下降
非叶子节点只保存键和子页指针,扇出高使数千万行通常只需少数层随机访问。
3. 定位叶子页区间
叶子页有序并通过双向链连接,范围扫描可顺序读取相邻记录。
4. 回表或直接取覆盖列
二级索引叶子保存主键,缺少目标列时要回聚簇索引;覆盖索引可消除这一步。
5. 按顺序返回结果
顺序与索引一致时可直接输出,反向或不匹配排序可能产生 filesort 与额外临时空间。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| storage/innobase/btr | B+Tree 页搜索与分裂实现 |
| EXPLAIN ANALYZE | 实际扫描、回表与节点耗时 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
CREATE INDEX idx_order_tenant_status_time
ON orders(tenant_id, status, created_at, id);
构造均匀与状态偏斜两组百万行数据,对比联合索引列顺序、覆盖与回表;必须记录 rows examined。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「扫描/返回」为主基线,记录值应满足「理想接近 1」;同时保存 EXPLAIN 实际扫描行数、回表次数,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「storage/innobase/btr」确认请求确实进入「B+Tree 页搜索与分裂实现」对应的实现,再沿「EXPLAIN ANALYZE」观察「实际扫描、回表与节点耗时」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「函数或隐式转换让索引列失去可搜索性」,并把单一变量逐级放大,直到「扫描/返回」越过「>100」。随后再分别验证「低选择性索引回表成本高于全表扫」和「过多冗余索引拖慢写入」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「按真实谓词重排联合索引」,确认它能控制影响范围;第二轮应用「消除隐式类型转换」,验证核心链路恢复;最后落实「删除前用 invisible index 灰度」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「扫描/返回」回到「理想接近 1」、「回表次数」回到「覆盖查询为 0」、「索引/数据体积」回到「按写预算」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 扫描/返回 | 理想接近 1 | >100 | 索引边界差 |
| 回表次数 | 覆盖查询为 0 | 高选择性仍大量回表 | 补覆盖列 |
| 索引/数据体积 | 按写预算 | 索引总量>数据 | 删冗余 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:联合索引存在但查询仍扫描大量行
索引为 (tenant_id,status,created_at),查询只给 status 和时间,缺少第一列,无法建立连续搜索区间。补齐租户条件或按真实访问模式重建索引后,扫描行数从百万降到数百。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 函数或隐式转换让索引列失去可搜索性 | EXPLAIN 实际扫描行数 | 按真实谓词重排联合索引 |
| 低选择性索引回表成本高于全表扫 | 回表次数 | 消除隐式类型转换 |
| 过多冗余索引拖慢写入 | Buffer Pool 读页 | 删除前用 invisible index 灰度 |
发布与回滚检查点
- 发布前:确认「storage/innobase/btr」对应实现和上述配置在目标版本仍然有效,并保存「扫描/返回」基线。
- 灰度中:同时观察 EXPLAIN 实际扫描行数、回表次数、Buffer Pool 读页;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「按真实谓词重排联合索引」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「函数或隐式转换让索引列失去可搜索性」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| 单列索引 | 独立高选择性条件 | 定义简单、复用直接 | 难覆盖组合过滤与排序 |
| 联合索引 | 固定组合过滤和排序 | 一次定位并可覆盖查询 | 列顺序依赖访问模式 |
| 覆盖索引 | 热点查询返回列稳定且少 | 减少回表 I/O | 索引更宽,写放大与缓存占用增加 |
选型至少带上 数据规模、选择性、读写比、事务长度和峰值并发,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
索引是否有效由数据分布、过滤组合、返回列和排序共同决定;“建了索引”不是结论,必须用真实执行计划和 rows examined 验证。
工程落地遵循:正确性由约束和事务兜底,性能优化必须用执行计划与测量验证。回答时直接引用「storage/innobase/btr」、配置实验和事故数据,比复述固定模板更有说服力。