Skip to content

索引原理 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+ 树的原因:

  1. 减少磁盘 I/O: B+ 树内部节点不存数据,单个节点可存更多键,树更矮(通常 3-4 层),每次查询只需 3-4 次磁盘 I/O
  2. 支持范围查询: 叶节点链表使范围扫描(BETWEEN><)非常高效
  3. 查询稳定性: 所有查询都要到叶节点,时间复杂度稳定为 O(log n)
  4. 顺序访问优化: 叶节点顺序排列,全表扫描时磁盘顺序读效率高

为什么不用红黑树或跳表?

  • 红黑树: 高度比 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 物理排序

sql
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)

当查询的所有列都包含在一个索引中时,数据库可以直接从索引返回结果,无需回表

sql
-- 假设有索引 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)

sql
-- 可以用到索引
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范围不连续,优化器可能放弃索引
违反最左前缀跳过组合索引首列见上节
sql
-- 函数失效的正确改法
-- 错误
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'

索引设计原则

  1. 选择性高的列优先建索引(唯一值多的列,如 user_id > status)
  2. 频繁出现在 WHERE、JOIN ON、ORDER BY、GROUP BY 的列
  3. 不要对频繁更新的列建过多索引(维护索引有写入开销)
  4. 尽量使用覆盖索引减少回表
  5. 组合索引把选择性高的列放前面,且考虑查询语句的最左前缀

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 whereServer 层过滤⚠️ 可能没用上 ICP
Using temporary用了临时表❌ 通常是 GROUP BY / DISTINCT 排序触发,要优化
Using filesort文件排序(内存/磁盘)❌ ORDER BY 没走索引顺序,要建组合索引
Using join bufferJOIN 没用上索引❌ 给关联字段加索引
Using index for group-byGROUP BY 用了索引✅ 好

type 进阶解读(最高频)

ALL          → 全表扫描(最差)
index        → 全索引扫描(比 ALL 略好,但仍扫整个索引)
range        → 范围查询(BETWEEN / >, < / IN)
ref          → 普通索引等值
eq_ref       → 唯一索引 + JOIN 时一对一
const/system → 主键/唯一索引常量查询(最佳)

典型调优场景

场景 1:SELECT * + ORDER BY

sql
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:分页深翻页慢

sql
-- 慢:偏移大时要扫 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(*) 慢

sql
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 删除从不使用的索引。

常见误区

易错点

  1. 主键建议用自增整数,UUID 或随机主键会导致 B+ 树频繁分裂(页分裂),降低写入性能
  2. NULL 值是可以走索引的,MySQL 5.6+ 已优化,IS NULLIS NOT NULL 在选择性合适时会走索引
  3. 索引不是越多越好,每个索引都会占用存储空间,并且在 INSERT/UPDATE/DELETE 时需要同步维护,增加写入开销
  4. EXPLAIN 的 type 列: system > const > eq_ref > ref > range > index > ALL,ref 及以上才算高效利用索引
📝 面试真题3 道高频
1. MySQL 为什么使用 B+ 树而不是 B 树或哈希索引?困难
2. 什么是覆盖索引?什么是回表?如何避免回表?中等
3. 哪些情况会导致索引失效?中等

Q1: 为什么 MySQL 使用 B+ 树?

从三个角度回答:

  1. 减少磁盘 I/O: B+ 树内部节点只存键不存数据,一个节点可以存放更多键,使树更矮(通常 3-4 层)。每次查询对应 3-4 次磁盘 I/O,而红黑树对百万数据树高 20+。

  2. 支持高效范围查询: B+ 树叶节点通过双向链表连接,范围查询只需找到起始节点,然后顺序遍历链表,非常高效。B 树的内部节点也存数据,范围查询需要中序遍历整棵树。

  3. 查询性能稳定: B+ 树所有查询都必须到达叶节点,时间复杂度恒为 O(log n)。B 树可能在内部节点命中,查询时间不稳定。

vs 哈希索引: 哈希索引等值查询 O(1),但不支持范围查询和排序,而且需要整个键参与哈希,无法支持最左前缀。

Q2: 什么是覆盖索引和回表?

  • 回表: 通过辅助索引找到主键,再用主键在聚簇索引中查找完整行数据,需要两次 B+ 树查找
  • 覆盖索引: 查询所需的所有列都在某个索引中,查索引即可,无需回表

如何避免回表: 为常用查询建立包含所有 SELECT 列的组合索引(索引覆盖 SELECT 列),用 EXPLAIN 验证出现 Using index

Q3: 索引失效场景(背 6 个)

  1. 左模糊 LIKE '%xx' — 无法利用 B+ 树前缀排序
  2. 索引列做函数/运算YEAR(date)age+1 等,应改为等值或范围
  3. 类型隐式转换 — varchar 列用整数比较,MySQL 隐式调用 CAST,相当于函数操作
  4. OR 连接非索引列 — OR 的任一分支全表扫描会影响整体
  5. 违反最左前缀 — 组合索引跳过首列
  6. 数据选择性太低 — 如 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 索引

操作HashB+ 树
等值查询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 磁盘"两个不同设计目标

详见 Redis 跳表 vs MySQL B+ 树

高频追问:自增主键为什么不连续

生产中经常发现自增 ID "跳号"——能讲清 5 个原因立刻显出 DBA 经验。

sql
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 时代的传言。"

延伸阅读

深度图解

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 种典型场景

sql
-- ❌ 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 加索引