索引原理 Indexing
数据库⭐⭐⭐ 高级🔥🔥🔥 高频
💡 核心要点
MySQL InnoDB 使用 B+ 树作为索引数据结构,磁盘 I/O 友好且支持范围查询。索引能极大加速查询,但也有维护成本和失效场景。覆盖索引(无需回表)是优化查询性能的重要手段。
什么是索引?
索引是数据库中一种单独的、物理的数据结构,用于加速对表中数据的检索。没有索引的查询需要全表扫描(O(n)),有了索引可以优化到 O(log n)。
类比: 索引就像书的目录,能快速定位到指定页码,而不必从头翻到尾。
索引类型
按数据结构分类
| 类型 | 引擎支持 | 特点 |
|---|---|---|
| B+ 树索引 | InnoDB、MyISAM | 最常用,支持范围查询、排序 |
| 哈希索引 | Memory(InnoDB 自适应哈希) | 等值查询极快,不支持范围查询 |
| 全文索引 | InnoDB(5.6+)、MyISAM | 用于文本模糊搜索 |
| 空间索引 | MyISAM | 用于地理空间数据(R 树) |
按逻辑角色分类
| 类型 | 说明 |
|---|---|
| 主键索引 | 唯一标识每行数据,InnoDB 中也是聚簇索引 |
| 唯一索引 | 确保列值唯一,允许 NULL |
| 普通索引 | 最基本的索引,无唯一性限制 |
| 组合索引 | 多列组合,遵循最左前缀原则 |
| 覆盖索引 | 查询所需列都在索引中,无需回表 |
B+ 树结构与特点
为什么 MySQL 选择 B+ 树?
B 树 vs B+ 树的关键区别:
- B 树的内部节点也存储数据;B+ 树只在叶节点存储数据
- B+ 树叶节点之间通过双向链表连接,支持顺序遍历和范围查询
B+ 树示意(3 阶):
[30 | 60] ← 内部节点(只存键)
/ | \
[10|20] [40|50] [70|80] ← 内部节点
/ |\ ...
[1|5|9]─►[10|15|20]─►[25|30|35]─► ... ← 叶节点(存键+数据,双向链表)选择 B+ 树的原因:
- 减少磁盘 I/O: B+ 树内部节点不存数据,单个节点可存更多键,树更矮(通常 3-4 层),每次查询只需 3-4 次磁盘 I/O
- 支持范围查询: 叶节点链表使范围扫描(
BETWEEN、>、<)非常高效 - 查询稳定性: 所有查询都要到叶节点,时间复杂度稳定为 O(log n)
- 顺序访问优化: 叶节点顺序排列,全表扫描时磁盘顺序读效率高
为什么不用红黑树或跳表?
- 红黑树: 高度比 B+ 树高,百万数据时树高可能 20+,磁盘 I/O 次数多
- 跳表: 适合内存结构(Redis 用跳表实现 Sorted Set),但磁盘随机访问效率低
聚簇索引与非聚簇索引
聚簇索引 (Clustered Index)
InnoDB 中,数据本身按主键顺序存储在 B+ 树的叶节点,每张表只有一个聚簇索引。
主键索引 B+ 树叶节点:
┌──────────────────────────────┐
│ id=1 | name="Alice" | age=25 │ ← 完整行数据
│ id=2 | name="Bob" | age=30 │
│ id=3 | name="Carol" | age=28 │
└──────────────────────────────┘非聚簇索引 (Non-Clustered / Secondary Index)
辅助索引的叶节点只存储索引列的值和主键值,通过主键再查完整行,这个过程叫回表。
name 列辅助索引:
┌─────────────────────┐
│ "Alice" → id=1 │ ← 只存 name + 主键
│ "Bob" → id=2 │
│ "Carol" → id=3 │
└─────────────────────┘
↓ 回表
聚簇索引查完整行MyISAM 不区分聚簇/非聚簇: MyISAM 索引和数据分开存储,所有索引都是非聚簇的,叶节点存的是数据文件的物理地址。
为什么 InnoDB 二级索引存 PK,而 MyISAM 存物理地址?(资深面试 Top 10)
💡 同样是数据库引擎,InnoDB / MyISAM 在二级索引设计上做了完全相反的选择——理解这个差异立刻显出深度
两种设计的实现差异
MyISAM 二级索引: InnoDB 二级索引:
┌──────────────┐ ┌──────────────┐
│ "Alice" │ │ "Alice" │
│ → 0x7F3A │ (物理地址) │ → id=1 │ (主键)
└──────────────┘ └──────────────┘
↓ 直接读 ↓ 回表(查聚簇索引)
数据文件 聚簇索引 B+ 树MyISAM:1 次 IO 直接到数据。 InnoDB:2 次 IO(二级索引 → 聚簇索引)。
但 InnoDB 故意选择更慢的方案,为什么?
原因 1:数据更新代价 —— InnoDB 不重建二级索引
InnoDB 的数据存在聚簇索引的叶节点——而B+ 树叶节点会因为页分裂/合并发生物理位置变化。
假设 InnoDB 二级索引也存物理地址:
- 用户 id=1 的数据原本在 page 5
- 插入大量数据后页分裂,id=1 被搬到 page 7
- ★ 所有二级索引都要更新指向 page 7 → 写放大严重InnoDB 存 PK 后:物理地址变了不影响二级索引,只有 PK 变了才需要更新——而 PK 在表设计后几乎不会变。
原因 2:MyISAM 数据不动 —— 可以放心存物理地址
MyISAM 用单独的 .MYD 数据文件,按"自然插入顺序"存储(不排序):
.MYI(索引文件) .MYD(数据文件,固定追加,行号即位置)
┌──────────────┐
"Alice" → 0x100 │ 0x100: Alice │
"Bob" → 0x140 │ 0x140: Bob │
│ 0x180: Carol │ ← 新插入,追加到末尾
└──────────────┘- 数据不会因索引变动而搬家 → 物理地址永远稳定
- 但代价是没有聚簇索引 → 范围扫描全表慢(数据物理顺序与索引顺序无关)
原因 3:InnoDB 设计的核心目的 —— 范围查询效率
InnoDB 的聚簇索引让数据按 PK 物理排序:
SELECT * FROM users WHERE id BETWEEN 100 AND 200;
-- InnoDB:聚簇索引一次顺序读 100 行(顺序 IO 极快)
-- MyISAM:二级索引 100 次随机 IO 跳到数据文件(慢 100×)这就是为什么 OLTP(大量范围 + JOIN)选 InnoDB——回表的代价远低于 MyISAM 的随机 IO 代价。
设计对比总结
| 维度 | MyISAM(存物理地址) | InnoDB(存 PK) |
|---|---|---|
| 二级索引→数据 | 1 次 IO(直接) | 2 次 IO(回表) |
| 页分裂代价 | 高(所有二级索引要改) | 低(只改聚簇索引) |
| 范围扫描 | 慢(随机 IO) | 快(顺序 IO) |
| 数据存储 | .MYD 单独文件、追加 | 聚簇索引叶节点 |
| 支持事务 | ❌ | ✅ |
| 行锁 | ❌ 只有表锁 | ✅ 行锁 |
| 典型场景 | 只读 OLAP(已过时) | OLTP(事实标准) |
标准答题模板
"InnoDB 二级索引存主键、MyISAM 存物理地址——看似 MyISAM 更快(少一次回表),但 InnoDB 这个设计是为了写性能**:InnoDB 数据存在聚簇索引叶节点会因页分裂搬家,若二级索引存物理地址,每次页分裂都要重写所有二级索引——写放大灾难。**
存主键的代价是查询多一次回表,但 InnoDB 用聚簇索引把数据按 PK 物理排序作为补偿——范围查询是顺序 IO,远快于 MyISAM 的随机 IO。
MyISAM 已被淘汰(不支持事务 + 表锁),现代 InnoDB 是唯一选择。"**
覆盖索引 (Covering Index)
当查询的所有列都包含在一个索引中时,数据库可以直接从索引返回结果,无需回表。
-- 假设有索引 idx_name_age (name, age)
-- 覆盖索引查询(无需回表)
SELECT name, age FROM users WHERE name = 'Alice';
-- 非覆盖索引查询(需要回表取 email)
SELECT name, age, email FROM users WHERE name = 'Alice';EXPLAIN 验证: Extra 列出现 Using index 说明用了覆盖索引。
组合索引的最左前缀原则
组合索引 (a, b, c) 等效于建了三个前缀索引:(a)、(a, b)、(a, b, c)。
-- 可以用到索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND b > 5 -- a 用索引,b 范围后的列不能用
-- 用不到索引
WHERE b = 2 -- 没有 a
WHERE b = 2 AND c = 3 -- 没有 a
WHERE c = 3 -- 没有 a, b索引失效场景
面试必考!以下情况会导致索引无法被使用,退化成全表扫描:
| 场景 | 示例 | 原因 |
|---|---|---|
| 左模糊查询 | LIKE '%keyword' | B+ 树按前缀排序,无法匹配后缀 |
| 对索引列运算 | WHERE age + 1 = 26 | 索引存储的是原值,运算后无法定位 |
| 对索引列使用函数 | WHERE YEAR(create_time) = 2024 | 同上,应改为范围查询 |
| 类型隐式转换 | WHERE phone = 13812345678(phone 是 varchar) | 触发类型转换,相当于函数操作 |
| OR 连接非索引列 | WHERE id = 1 OR name = 'Alice'(name 无索引) | 一个条件全表扫描,OR 合并后整体也全扫 |
| NOT IN / != | WHERE status != 1 | 范围不连续,优化器可能放弃索引 |
| 违反最左前缀 | 跳过组合索引首列 | 见上节 |
-- 函数失效的正确改法
-- 错误
WHERE YEAR(create_time) = 2024
-- 正确
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'
-- 类型转换的正确改法
-- 错误(phone 是 VARCHAR)
WHERE phone = 13812345678
-- 正确
WHERE phone = '13812345678'索引设计原则
- 选择性高的列优先建索引(唯一值多的列,如 user_id > status)
- 频繁出现在 WHERE、JOIN ON、ORDER BY、GROUP BY 的列
- 不要对频繁更新的列建过多索引(维护索引有写入开销)
- 尽量使用覆盖索引减少回表
- 组合索引把选择性高的列放前面,且考虑查询语句的最左前缀
EXPLAIN 实战:怎么用 EXPLAIN 调优
EXPLAIN 是 MySQL 调优的第一工具,2025-2026 年面试必问"看 EXPLAIN 怎么定位慢查询"。下面是必背字段速查:
关键列含义
| 列 | 含义 | 优化关注点 |
|---|---|---|
| type | 访问类型,性能从好到坏:system > const > eq_ref > ref > range > index > ALL | 见到 ALL 必须优化,至少要到 range |
| possible_keys | 可能用到的索引 | 为空说明该 WHERE 没合适索引 |
| key | 实际选中的索引 | 与 possible_keys 不符时考虑 FORCE INDEX |
| key_len | 索引使用长度(字节) | 越短越好;判断联合索引用了几列 |
| rows | 估算扫描行数 | 与实际差距大时 → ANALYZE TABLE 刷统计 |
| filtered | 过滤后剩余比例 | rows × filtered/100 = 实际处理行 |
| Extra | 额外信息 | 黄金信号(见下) |
Extra 字段必背速查
💡 看到这些 Extra 立刻判断性能
| Extra 值 | 含义 | 判断 |
|---|---|---|
| Using index | 覆盖索引,无需回表 | ✅ 最佳 |
| Using index condition | 索引下推 ICP,引擎层过滤 | ✅ 好 |
| Using where | Server 层过滤 | ⚠️ 可能没用上 ICP |
| Using temporary | 用了临时表 | ❌ 通常是 GROUP BY / DISTINCT 排序触发,要优化 |
| Using filesort | 文件排序(内存/磁盘) | ❌ ORDER BY 没走索引顺序,要建组合索引 |
| Using join buffer | JOIN 没用上索引 | ❌ 给关联字段加索引 |
| Using index for group-by | GROUP BY 用了索引 | ✅ 好 |
type 进阶解读(最高频)
ALL → 全表扫描(最差)
index → 全索引扫描(比 ALL 略好,但仍扫整个索引)
range → 范围查询(BETWEEN / >, < / IN)
ref → 普通索引等值
eq_ref → 唯一索引 + JOIN 时一对一
const/system → 主键/唯一索引常量查询(最佳)典型调优场景
场景 1:SELECT * + ORDER BY 慢
EXPLAIN SELECT * FROM orders WHERE user_id = 1 ORDER BY created_at DESC;
-- Extra: Using filesort ← 文件排序
-- 优化:建组合索引 (user_id, created_at)
-- 二级索引的 (user_id, created_at) 自带顺序,无需 filesort场景 2:分页深翻页慢
-- 慢:偏移大时要扫 100020 行
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 快:用"延迟关联"
SELECT * FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) t ON o.id = t.id;
-- 子查询只走索引(覆盖索引)→ 不回表,再 JOIN 取数据场景 3:COUNT(*) 慢
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 如果 status 有索引:走索引扫描
-- 如果没索引:全表扫描
-- MySQL 8.0+ 的 COUNT 优化:COUNT(*) 和 COUNT(1) 现在等价
-- COUNT(列名) 仍然要逐行判断 NULL,性能略差索引选型决策树
⚠️ 索引不是越多越好
生产经验:单表索引超过 5-7 个就要警觉,超过 10 个几乎必有问题。每多一个索引:
- 写入性能下降:INSERT/UPDATE/DELETE 都要维护所有索引
- 占用磁盘空间:单索引可能比表数据本身还大
- 优化器选错索引概率上升:候选索引多了选错更频繁
定期用 sys.schema_unused_indexes 删除从不使用的索引。
常见误区
易错点
- 主键建议用自增整数,UUID 或随机主键会导致 B+ 树频繁分裂(页分裂),降低写入性能
- NULL 值是可以走索引的,MySQL 5.6+ 已优化,
IS NULL和IS NOT NULL在选择性合适时会走索引 - 索引不是越多越好,每个索引都会占用存储空间,并且在 INSERT/UPDATE/DELETE 时需要同步维护,增加写入开销
- EXPLAIN 的 type 列: system > const > eq_ref > ref > range > index > ALL,
ref及以上才算高效利用索引
Q1: 为什么 MySQL 使用 B+ 树?
从三个角度回答:
减少磁盘 I/O: B+ 树内部节点只存键不存数据,一个节点可以存放更多键,使树更矮(通常 3-4 层)。每次查询对应 3-4 次磁盘 I/O,而红黑树对百万数据树高 20+。
支持高效范围查询: B+ 树叶节点通过双向链表连接,范围查询只需找到起始节点,然后顺序遍历链表,非常高效。B 树的内部节点也存数据,范围查询需要中序遍历整棵树。
查询性能稳定: B+ 树所有查询都必须到达叶节点,时间复杂度恒为 O(log n)。B 树可能在内部节点命中,查询时间不稳定。
vs 哈希索引: 哈希索引等值查询 O(1),但不支持范围查询和排序,而且需要整个键参与哈希,无法支持最左前缀。
Q2: 什么是覆盖索引和回表?
- 回表: 通过辅助索引找到主键,再用主键在聚簇索引中查找完整行数据,需要两次 B+ 树查找
- 覆盖索引: 查询所需的所有列都在某个索引中,查索引即可,无需回表
如何避免回表: 为常用查询建立包含所有 SELECT 列的组合索引(索引覆盖 SELECT 列),用 EXPLAIN 验证出现 Using index。
Q3: 索引失效场景(背 6 个)
- 左模糊
LIKE '%xx'— 无法利用 B+ 树前缀排序 - 索引列做函数/运算 —
YEAR(date)、age+1等,应改为等值或范围 - 类型隐式转换 — varchar 列用整数比较,MySQL 隐式调用 CAST,相当于函数操作
- OR 连接非索引列 — OR 的任一分支全表扫描会影响整体
- 违反最左前缀 — 组合索引跳过首列
- 数据选择性太低 — 如 status 只有 0/1 两个值,优化器认为全表扫描更快
高频追问:为什么 B+ 树(不用 B 树 / 红黑树 / Hash)
面试 Top 1 数据库追问——能讲清"为什么不用 B 树"是数据库底子的硬指标。
B+ 树 vs B 树
B 树(Balanced Tree):
每个节点 (key + 数据)
叶子节点和非叶子都有数据
B+ 树(B-Plus Tree):
非叶子节点只存 key("索引")
数据只在叶子节点
叶子节点之间双向链表连接为什么 B+ 树更适合数据库索引:
| 维度 | B 树 | B+ 树 |
|---|---|---|
| 非叶子节点存数据 | 是 | 否,只存 key |
| 单节点扇出 | 中(节点要存数据,size 大) | 大(只存 key,能塞更多) |
| 树高 | 高 | 矮(百万级数据 3-4 层) |
| 范围查询 | 中序遍历,跳跃访问 | 叶子节点链表顺序扫描 |
| 单点查询稳定性 | 不同 key 深度可能不同 | 所有 key 都到叶子,O(log n) 稳定 |
💡 一句话回答面试官
"B+ 树非叶子节点不存数据 → 单节点能塞更多 key → 扇出大 → 树更矮 → 磁盘 IO 更少。叶子节点链表 → 范围查询超快。这两点恰好命中数据库的两大需求:减少磁盘 IO + 高效范围扫描。"
为什么不用 Hash 索引
| 操作 | Hash | B+ 树 |
|---|---|---|
| 等值查询 | O(1) 最快 | O(log n) |
| 范围查询 | ❌ 不支持 | ✅ |
| ORDER BY | ❌ 不支持 | ✅ 天然有序 |
| 最左前缀(联合索引) | ❌ | ✅ |
| 磁盘友好 | 中(哈希冲突 + 随机 IO) | ✅✅ |
MySQL 内部对策:InnoDB 自适应哈希索引(AHI) 自动给热点等值查询建 Hash 索引,但用户不可控。
为什么不用红黑树
红黑树:
树高 = log₂(N)
100 万数据: 树高 = 20 → 20 次磁盘 IO
B+ 树(扇出 1000):
树高 = log₁₀₀₀(N)
100 万数据: 树高 = 2-3 → 2-3 次磁盘 IO核心差异:红黑树是二叉,B+ 树是多叉。磁盘 IO 是数据库瓶颈,IO 次数差几倍就是性能差几倍。
为什么不用跳表
Redis 用跳表因为纯内存——内存访问无 IO 区别;MySQL 用 B+ 树因为磁盘——B+ 树的高扇出大幅减少 IO。两个数据结构选择反映了"内存 vs 磁盘"两个不同设计目标。
高频追问:自增主键为什么不连续
生产中经常发现自增 ID "跳号"——能讲清 5 个原因立刻显出 DBA 经验。
SHOW VARIABLES LIKE 'innodb_autoinc_lock_mode';
-- 0: 传统模式(语句级锁,连续但慢)
-- 1: 连续模式(默认)
-- 2: 交错模式(最快,但批量插入可能跳号,MySQL 8 默认)5 个常见跳号原因
| 原因 | 现象 | 是否可避免 |
|---|---|---|
| 事务回滚 | 申请 ID 后回滚不归还 → 永久跳号 | ❌ 不可避免 |
| REPLACE INTO | 实际是 DELETE+INSERT,可能消耗多个 ID | ❌ |
| INSERT IGNORE / ON DUPLICATE KEY UPDATE 冲突 | 即使冲突也已申请 ID | ❌ |
批量插入(innodb_autoinc_lock_mode=2) | 一次预申请一批,未用完的浪费 | 改回 mode=1 但慢 |
| MySQL 重启(8.0 之前) | 内存中的 auto_increment 重启后从 max+1 开始,可能不等于历史最大 | 8.0 已通过 redo log 持久化 |
💡 面试黄金回答
"自增主键不连续是正常现象,根本原因是:① ID 申请发生在事务前,回滚不归还;② 批量插入预申请,未用完的浪费。生产不要依赖 ID 连续做业务判断(如订单连号),改用业务流水号 + Snowflake。"
高频追问:COUNT(*) 怎么优化
InnoDB 不维护行数变量(因为 MVCC,不同事务看到的行数不同)。常见优化方案:
| 场景 | 方案 |
|---|---|
| 整表精确计数 | SELECT COUNT(*) FROM t → 走最小二级索引扫描(MySQL 8.0+ 已优化) |
| 带条件计数 | 给 WHERE 列加索引 + 覆盖索引 |
| 海量数据近似计数 | EXPLAIN SELECT * FROM t 看 rows 列(统计信息估算) |
| 业务可缓存 | Redis INCR 计数 + binlog/CDC 同步 → 微秒级返回 |
| 大表准实时 | 专门计数表:插入时 + 1,删除时 - 1 |
COUNT(*) vs COUNT(1) vs COUNT(column)
COUNT(*) ← 不取行数据,扫最小索引计数(最快)
COUNT(1) ← 等价于 COUNT(*),无差异
COUNT(列名) ← 跳过 NULL 行,需要取列值(最慢)面试经典:"COUNT(*) 和 COUNT(1) 一样吗?" → "在 MySQL 中完全等价,老说法'COUNT(1) 更快'是 Oracle 时代的传言。"
延伸阅读
- MySQL 官方文档 - Optimization and Indexes
- Use The Index, Luke — 数据库索引专项学习网站
- 《高性能 MySQL》第 5 章:索引优化
深度图解
B+ 树索引查询路径
查询 WHERE id = 22 的路径: 根节点 → 内部节点(20≤id<40 分支)→ 叶子节点 → 找到数据指针,共 3 次 I/O(树高决定)。
为什么 B+ 树叶子节点用双向链表连接? 支持范围查询(WHERE id BETWEEN 10 AND 30):找到起始叶子后,直接沿链表向右扫描,无需回到根节点。
回表 vs 覆盖索引
优化技巧: 将 SELECT 的列加入联合索引(如
INDEX(age, name)),即可避免回表,减少约 50% I/O。
索引失效 6 种典型场景
-- ❌ 1. 对索引列使用函数
SELECT * FROM t WHERE YEAR(create_time) = 2024;
-- ✅ 改写:WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'
-- ❌ 2. 隐式类型转换(phone 是 VARCHAR,传入整数)
SELECT * FROM t WHERE phone = 13812345678;
-- ✅ 改写:WHERE phone = '13812345678'
-- ❌ 3. LIKE 前缀通配(索引无法定位起始点)
SELECT * FROM t WHERE name LIKE '%张';
-- ✅ 改写:WHERE name LIKE '张%'(前缀匹配可走索引)
-- ❌ 4. 违反最左前缀(联合索引 INDEX(a, b, c),跳过 a)
SELECT * FROM t WHERE b = 1 AND c = 2;
-- ✅ 改写:WHERE a = ? AND b = 1 AND c = 2
-- ❌ 5. 范围查询右侧列失效(INDEX(a, b, c))
SELECT * FROM t WHERE a = 1 AND b > 5 AND c = 2;
-- c 上的索引失效;b 范围查询之后的列无法走索引
-- ❌ 6. OR 连接非索引列
SELECT * FROM t WHERE id = 1 OR name = '张三';
-- name 无索引时,整个查询走全表扫描
-- ✅ 改写:拆为两个查询 UNION ALL,或给 name 加索引