MySQL 完全指南 / 第 7 章:索引详解
第 7 章:索引详解
索引是数据库性能优化的核心武器。理解 B+Tree 的原理,才能用好索引。
7.1 索引的本质
索引是帮助 MySQL 高效获取数据的有序数据结构。可以类比为书的目录。
没有索引:全表扫描(翻遍整本书找内容)
O(n) → 100 万行就需要扫描 100 万次
有索引:通过索引定位(先查目录,再翻到对应页)
O(log n) → 100 万行只需要约 20 次比较
7.2 B+Tree 数据结构
7.2.1 B+Tree 原理
InnoDB 使用 B+Tree 作为索引的数据结构:
[30 | 60] ← 根节点(非叶子)
/ | \
[10|20] [40|50] [70|80|90] ← 内部节点(非叶子)
/ | \ / | \ / | | \
[数据页] [数据页] ... [数据页] [数据页] ← 叶子节点(存数据)
↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ← 叶子节点双向链表
B+Tree 的特点:
| 特性 | 说明 |
|---|---|
| 多路平衡 | 每个节点可以有多个子节点(通常 1000+) |
| 叶子存储数据 | 非叶子节点只存键值,叶子节点存完整数据 |
| 叶子双向链表 | 叶子节点通过指针相连,支持范围查询 |
| 树高很低 | 通常 2-4 层,亿级数据也只需 3-4 次磁盘 I/O |
7.2.2 为什么 B+Tree 而不是 B-Tree 或 Hash?
| 数据结构 | 等值查询 | 范围查询 | 排序 | 适用场景 |
|---|---|---|---|---|
| B+Tree | O(log n) | ✅ 高效 | ✅ 天然有序 | InnoDB 默认 |
| B-Tree | O(log n) | 较差 | 支持但效率不如 B+Tree | — |
| Hash | O(1) | ❌ 不支持 | ❌ 不支持 | Memory 引擎、自适应哈希索引 |
| 二叉搜索树 | O(log n) | 支持 | 支持 | 树高太高,磁盘 I/O 多 |
| 红黑树 | O(log n) | 支持 | 支持 | 树高太高,不适合磁盘存储 |
7.3 InnoDB 索引类型
7.3.1 聚簇索引(Clustered Index)
聚簇索引的叶子节点存储完整的行数据。InnoDB 表有且仅有一个聚簇索引。
选择规则:
1. 如果有 PRIMARY KEY → 主键作为聚簇索引
2. 如果没有主键,有 UNIQUE NOT NULL → 第一个 UNIQUE NOT NULL 作为聚簇索引
3. 如果都没有 → InnoDB 自动生成一个隐藏的 6 字节 ROW_ID
-- 聚簇索引结构示意:
-- 主键索引叶子节点存储:
-- | PK(id) | col1 | col2 | col3 | ... | 完整行数据 |
-- 查询走主键:直接从聚簇索引叶子节点获取数据(最快)
SELECT * FROM users WHERE id = 100;
7.3.2 二级索引(Secondary Index)
二级索引的叶子节点存储的是主键值(而不是行数据的物理地址)。
-- 二级索引 idx_email 的结构:
-- 叶子节点存储:| email | id(主键) |
-- 查询过程:
SELECT * FROM users WHERE email = '[email protected]';
-- 1. 在 idx_email B+Tree 中找到 email='[email protected]' → 获取 id=5
-- 2. 用 id=5 去聚簇索引(主键索引)中查找完整行 → 这个过程叫"回表"
二级索引 idx_email 聚簇索引(主键)
┌──────────────────┐ ┌──────────────────┐
│ [email protected] → id=1 │ │ id=1 | 完整行数据 │
│ [email protected] → id=2 │ ────→ │ id=2 | 完整行数据 │
│ [email protected] → id=3 │ 回表 │ id=3 | 完整行数据 │
└──────────────────┘ └──────────────────┘
7.3.3 覆盖索引(Covering Index)
如果查询的列全部包含在索引中,就不需要回表,这就是覆盖索引。
-- 假设有索引:INDEX idx_email_status (email, status)
-- 覆盖索引(不回表)
SELECT email, status FROM users WHERE email = '[email protected]';
-- 索引叶子节点已包含 email 和 status,直接返回
-- 非覆盖索引(需要回表)
SELECT email, status, username FROM users WHERE email = '[email protected]';
-- 索引中没有 username,需要回表到聚簇索引获取
-- EXPLAIN 中的 Using index 表示覆盖索引
EXPLAIN SELECT email, status FROM users WHERE email = '[email protected]';
-- Extra: Using index ← 使用了覆盖索引
💡 覆盖索引优化:在设计联合索引时,将 SELECT 中常用的列包含进去,避免回表。
7.3.4 联合索引(Composite Index)
联合索引是多个列组成的索引,遵循最左前缀原则。
-- 联合索引:INDEX idx_abc (a, b, c)
CREATE TABLE test_index (
id INT PRIMARY KEY,
a INT,
b INT,
c INT,
d INT,
INDEX idx_abc (a, b, c)
);
最左前缀匹配规则:
| 查询条件 | 是否使用索引 idx_abc | 说明 |
|---|---|---|
WHERE a = 1 | ✅ 使用 (a) | 匹配最左列 |
WHERE a = 1 AND b = 2 | ✅ 使用 (a, b) | 匹配前两列 |
WHERE a = 1 AND b = 2 AND c = 3 | ✅ 使用 (a, b, c) | 完整匹配 |
WHERE b = 2 | ❌ 不使用 | 缺少最左列 a |
WHERE b = 2 AND c = 3 | ❌ 不使用 | 缺少最左列 a |
WHERE c = 3 | ❌ 不使用 | 缺少最左列 a |
WHERE a = 1 AND c = 3 | ⚠️ 部分使用 (a) | c 无法使用索引(b 缺失) |
WHERE a = 1 ORDER BY b | ✅ 使用 (a, b) | a 等值 + b 排序 |
WHERE a > 1 AND b = 2 | ⚠️ 部分使用 (a) | a 范围后 b 无法使用索引 |
⚠️ 关键规则:
- 索引列的顺序非常重要
- 范围查询(>, <, BETWEEN, LIKE ‘xx%’) 之后的列无法使用索引
- 将等值查询的列放在前面,范围查询的列放在后面
7.3.5 前缀索引(Prefix Index)
对长字符串列,只索引前 N 个字符,节省空间。
-- 为 email 列创建前缀索引(只索引前 10 个字符)
ALTER TABLE users ADD INDEX idx_email_prefix (email(10));
-- 如何选择合适的前缀长度?
-- 目标:选择性(distinct 比例)接近全列
SELECT
COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel_5,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel_10,
COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS sel_15,
COUNT(DISTINCT email) / COUNT(*) AS sel_full
FROM users;
-- 选择最接近 sel_full 且值稳定的前缀长度
| 前缀长度 | 选择性 | 推荐 |
|---|---|---|
| 5 | 0.85 | 太短 |
| 10 | 0.95 | 可以考虑 |
| 15 | 0.99 | 推荐 |
| 全列 | 1.00 | 最好但占空间 |
⚠️ 前缀索引的限制:
- 不能用于 ORDER BY 和 GROUP BY
- 不能做覆盖索引
- 只能用于等值查询和前缀匹配
7.4 索引分类总结
| 分类 | 说明 | 示例 |
|---|---|---|
| 主键索引 | 聚簇索引,叶子节点存完整行 | PRIMARY KEY (id) |
| 唯一索引 | 值唯一,允许 NULL | UNIQUE INDEX uk_email (email) |
| 普通索引 | 最基本的索引 | INDEX idx_name (name) |
| 前缀索引 | 索引列的前 N 个字符 | INDEX idx_email (email(10)) |
| 联合索引 | 多列组合 | INDEX idx_abc (a, b, c) |
| 全文索引 | 全文搜索 | FULLTEXT INDEX ft_content (content) |
| 空间索引 | 地理空间数据 | SPATIAL INDEX idx_geo (position) |
| 降序索引 | 8.0+ 支持 | INDEX idx_time (created_at DESC) |
| 不可见索引 | 8.0+ 支持,优化器不使用 | ALTER TABLE t ALTER INDEX idx INVISIBLE |
| 函数索引 | 8.0+ 支持 | INDEX idx_upper ((UPPER(name))) |
7.5 索引设计原则
7.5.1 何时创建索引
-- ✅ 适合创建索引的场景
-- 1. WHERE 条件频繁使用的列
SELECT * FROM orders WHERE user_id = 100; -- INDEX idx_user_id
-- 2. JOIN 关联列
SELECT * FROM orders o JOIN users u ON o.user_id = u.id; -- orders.user_id 需要索引
-- 3. ORDER BY / GROUP BY 列
SELECT * FROM orders ORDER BY created_at DESC; -- INDEX idx_created_at
-- 4. 高选择性的列(distinct 值多)
-- email、手机号 适合建索引
-- status、gender 不适合单独建索引(选择性低)
-- 5. 覆盖索引(查询列都在索引中)
7.5.2 索引设计口诀
等值查询放前面,范围查询放后面
选择性高的放前面,选择性低的放后面
查询需要的列放索引里(覆盖索引)
不要过度索引(每个索引都有写入代价)
7.5.3 联合索引设计实战
-- 场景:电商订单查询
-- 查询模式:
-- 1. WHERE user_id = ? AND status = ? ORDER BY created_at DESC
-- 2. WHERE status = ? AND created_at BETWEEN ? AND ?
-- 3. WHERE user_id = ? ORDER BY created_at DESC
-- 推荐索引设计
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);
ALTER TABLE orders ADD INDEX idx_status_time (status, created_at);
7.6 索引管理
7.6.1 查看索引
-- 查看表的所有索引
SHOW INDEX FROM orders;
-- 更详细的索引信息
SELECT
INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns,
NON_UNIQUE,
INDEX_TYPE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'myapp' AND TABLE_NAME = 'orders'
GROUP BY INDEX_NAME, NON_UNIQUE, INDEX_TYPE;
7.6.2 创建/删除索引
-- 创建普通索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
-- 创建唯一索引
ALTER TABLE users ADD UNIQUE INDEX uk_email (email);
-- 创建联合索引
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);
-- 创建降序索引
ALTER TABLE orders ADD INDEX idx_created_desc (created_at DESC);
-- 创建不可见索引(测试索引是否有用)
ALTER TABLE orders ADD INDEX idx_test (column_name) INVISIBLE;
-- 将索引设为不可见(优化器不使用,但保留索引结构)
ALTER TABLE orders ALTER INDEX idx_test INVISIBLE;
-- 将索引设为可见
ALTER TABLE orders ALTER INDEX idx_test VISIBLE;
-- 删除索引
ALTER TABLE orders DROP INDEX idx_user_id;
-- 使用 DROP INDEX 语法
DROP INDEX idx_user_id ON orders;
7.6.3 索引监控
-- 查看索引使用情况(需要开启 Performance Schema)
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
COUNT_READ,
COUNT_WRITE,
COUNT_FETCH
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'myapp'
ORDER BY COUNT_READ DESC;
-- 查找从未使用过的索引(可以考虑删除)
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'myapp'
AND INDEX_NAME IS NOT NULL
AND COUNT_READ = 0
AND INDEX_NAME != 'PRIMARY';
7.7 索引失效场景
以下是导致索引失效(无法使用索引)的常见情况:
| 场景 | 示例 | 说明 |
|---|---|---|
| 对索引列使用函数 | WHERE YEAR(created_at) = 2026 | 改为范围查询 |
| 对索引列做运算 | WHERE id + 1 = 10 | 改为 WHERE id = 9 |
| 隐式类型转换 | WHERE phone = 13800000000(phone 是 VARCHAR) | 加引号 |
| LIKE 左模糊 | WHERE name LIKE '%san' | 前缀匹配才能用索引 |
| OR 条件 | WHERE a = 1 OR b = 2 | 需要两个列都有索引 |
| NOT IN / NOT EXISTS | WHERE id NOT IN (...) | 视情况可能不走索引 |
| IS NULL / IS NOT NULL | 视数据分布 | NULL 值比例高可能不走索引 |
| 范围查询后的列 | WHERE a > 1 AND b = 2(联合索引 a,b) | b 无法使用索引 |
-- ❌ 索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- ✅ 索引有效
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- ❌ 索引失效
SELECT * FROM users WHERE phone = 13800000000; -- phone 是 VARCHAR
-- ✅ 索引有效
SELECT * FROM users WHERE phone = '13800000000';
-- ❌ 索引失效
SELECT * FROM users WHERE username LIKE '%san%';
-- ✅ 索引有效
SELECT * FROM users WHERE username LIKE 'zhang%';
-- ❌ 索引失效
SELECT * FROM users WHERE status != 1; -- 不等于可能全表扫描
-- ✅ 改写为
SELECT * FROM users WHERE status IN (0, 2, 3);
7.8 索引优化技巧
7.8.1 使用索引提示(Index Hint)
-- 强制使用某个索引
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 100;
-- 忽略某个索引
SELECT * FROM orders IGNORE INDEX (idx_user_id) WHERE user_id = 100;
-- 建议使用某个索引
SELECT * FROM orders USE INDEX (idx_user_id) WHERE user_id = 100;
7.8.2 降序索引的应用
-- MySQL 8.0+ 支持降序索引
ALTER TABLE orders ADD INDEX idx_created_desc (created_at DESC);
-- 查询最新订单(天然使用降序索引,无需 filesort)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;
7.8.3 不可见索引测试
-- 想删除某个索引,但不确定是否有用
-- 先设为不可见,观察一段时间
ALTER TABLE orders ALTER INDEX idx_suspicious INVISIBLE;
-- 监控是否有查询受影响
-- 如果没问题,再真正删除
ALTER TABLE orders DROP INDEX idx_suspicious;
-- 如果有问题,恢复可见
ALTER TABLE orders ALTER INDEX idx_suspicious VISIBLE;
业务场景
场景 1:电商订单表索引设计
-- 订单表常用查询模式及索引设计
-- 查询 1:用户的所有订单
-- SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);
-- 查询 2:按状态查询待处理订单
-- SELECT * FROM orders WHERE status = ? AND created_at > ?
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
-- 查询 3:订单号查询
-- SELECT * FROM orders WHERE order_no = ?
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);
-- 查询 4:查询用户的待支付订单(覆盖索引)
-- SELECT id, order_no, total_amount FROM orders WHERE user_id = ? AND status = 0
ALTER TABLE orders ADD INDEX idx_user_status_amount (user_id, status, total_amount);
场景 2:索引空间评估
-- 评估索引占用空间
SELECT
DATABASE_NAME,
TABLE_NAME,
INDEX_NAME,
STAT_VALUE * @@innodb_page_size / 1024 / 1024 AS index_size_mb
FROM mysql.innodb_index_stats
WHERE stat_name = 'size'
AND DATABASE_NAME = 'myapp'
ORDER BY STAT_VALUE DESC;