MySQL 索引原理与优化
索引是数据库性能优化的核心手段。本章将深入讲解 MySQL 索引的底层原理、InnoDB 存储引擎的索引实现、索引设计策略以及优化技巧。理解索引的本质,才能写出高效的 SQL 语句。
索引的本质
什么是索引?
索引是数据库中用于加速数据检索的数据结构。打个比方:如果表是一本书,索引就是书的目录。没有目录时,你要找某个内容只能从第一页翻到最后一页;有了目录,你可以快速定位到目标页码。
为什么需要索引?
没有索引时会发生什么?
假设 users 表有 100 万行数据,执行以下查询:
SELECT * FROM users WHERE email = '[email protected]';
MySQL 必须从第一行开始,逐行扫描整张表,比较每行的 email 值。这被称为全表扫描(Full Table Scan),时间复杂度是 O(N),当数据量很大时性能极差。
有了索引后呢?
如果在 email 列上建立了索引,MySQL 可以利用 B+Tree 的有序性,通过二分查找快速定位目标数据。时间复杂度从 O(N) 降到 O(log N),性能提升几个数量级。
索引的代价
索引不是免费的午餐,它带来性能提升的同时也有代价:
| 代价类型 | 具体影响 |
|---|---|
| 存储空间 | 每个索引都需要占用磁盘空间 |
| 写入性能 | INSERT、UPDATE、DELETE 需要同步维护索引 |
| 内存占用 | 索引页会被加载到 Buffer Pool 中 |
| 维护成本 | 需要定期分析和优化索引 |
因此,索引不是越多越好,需要根据实际查询需求精心设计。
B+Tree 索引结构
MySQL 的 InnoDB 存储引擎使用 B+Tree 作为索引的底层数据结构。理解 B+Tree 是掌握 MySQL 索引的关键。
为什么选择 B+Tree?
常见的查找数据结构有数组、链表、二叉搜索树、哈希表等,为什么数据库选择了 B+Tree?
二叉搜索树的问题:
当数据有序插入时,二叉搜索树会退化为链表,查找效率从 O(log N) 退化为 O(N)。即使使用 AVL 树或红黑树保持平衡,树的高度仍然较大,导致磁盘 I/O 次数多。
B+Tree 的优势:
- 矮胖树:每个节点可以有多个子节点,树的高度很低(通常 3-4 层就能存储千万级数据)
- 磁盘友好:每个节点的大小设计为磁盘页大小(默认 16KB),一次 I/O 可以读取一个完整节点
- 范围查询高效:叶子节点通过双向链表连接,范围查询只需要找到起始点,然后遍历链表即可
B+Tree 结构详解
B+Tree 是一种多路平衡查找树,具有以下特点:
┌─────────────────────────────┐
│ 根节点(内部节点) │
│ [20 | 40 | 60 | 80] │
└───────┬───────┬───────┬──────┘
┌────────────────┼───────┼───────┼────────────────┐
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ... ┌──────────┐ ┌──────────┐
│ 中间节点 │ │ 中间节点 │ │ 中间节点 │ │ 中间节点 │
│[5|10|15] │ │[25|30|35]│ │[45|50|55]│ │[85|90|95]│
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 叶子节点 │◄───►│ 叶子节点 │◄─────►│ 叶子节点 │◄───►│ 叶子节点 │
│[1-4] │ │[5-9] │ │[20-24] │ │[25-29] │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │
▼ ▼ ▼ ▼
数据 数据 数据 数据
内部节点(非叶子节点):
- 只存储键值和指向子节点的指针
- 不存储实际数据
- 起到索引作用,指导查找方向
叶子节点:
- 存储键值和实际数据(或数据指针)
- 所有叶子节点在同一层,保证查询效率稳定
- 叶子节点通过双向链表连接,支持高效的范围查询
InnoDB 页结构
InnoDB 将数据按页(Page)组织,默认页大小为 16KB。每个 B+Tree 节点就是一个页:
-- 查看页大小
SHOW VARIABLES LIKE 'innodb_page_size';
-- 输出: 16384 (16KB)
一个页可以存储多少条记录?
假设主键是 BIGINT(8 字节),指针是 6 字节,则一个内部节点页大约可以存储:
16384 字节 / (8 + 6) 字节 ≈ 1170 个键值
如果是 3 层的 B+Tree:
- 第 1 层(根节点):1 个页,约 1170 个键值
- 第 2 层(中间节点):1170 个页,约 1170 × 1170 ≈ 137 万个键值
- 第 3 层(叶子节点):约 137 万个页,假设每页存储 100 条记录,则可存储约 1.37 亿条记录
这就是为什么 3 层 B+Tree 就能存储上亿条数据,查询只需要 3 次 I/O。
页填充因子
InnoDB 在插入记录时,会预留一定的空闲空间,以减少页分裂:
- 顺序插入:页填充约 15/16(约 94%)
- 随机插入:页填充约 1/2 到 15/16
- 可通过
innodb_fill_factor调整(默认 100,表示创建索引时尽量填满)
当页填充率低于 MERGE_THRESHOLD(默认 50%)时,InnoDB 会尝试合并页以释放空间。
聚簇索引与二级索引
InnoDB 的索引分为聚簇索引和二级索引,理解它们的区别对于性能优化至关重要。
聚簇索引(Clustered Index)
聚簇索引是 InnoDB 表的"主索引",它决定了数据的物理存储顺序。
聚簇索引的特点:
- 数据与索引合一:叶子节点直接存储完整的行数据
- 每张表只有一个聚簇索引
- 数据按主键顺序存储:这也是为什么建议使用自增主键
聚簇索引的生成规则:
| 场景 | 聚簇索引 |
|---|---|
| 定义了 PRIMARY KEY | 使用主键作为聚簇索引 |
| 没有主键,但有 NOT NULL 的 UNIQUE 键 | 使用第一个 UNIQUE 键 |
| 都没有 | InnoDB 自动生成 6 字节的隐藏列 DB_ROW_ID |
聚簇索引结构:
聚簇索引(PRIMARY KEY)
│
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ ID=1 │ │ ID=2 │ │ ID=3 │
│ name=A │ │ name=B │ │ name=C │
│ age=20 │ │ age=25 │ │ age=30 │
│ ... │ │ ... │ │ ... │
└────────┘ └────────┘ └────────┘
完整行数据 完整行数据 完整行数据
聚簇索引的优势:
- 通过主键查询非常快(直接定位到数据页)
- 范围查询效率高(数据物理连续存储)
- 覆盖索引扫描可以直接返回数据
聚簇索引的劣势:
- 二级索引需要"回表"查询
- 主键过长会导致二级索引占用更多空间
- 插入新行可能导致页分裂(如果主键不是递增的)
二级索引(Secondary Index)
二级索引是除聚簇索引之外的所有索引,包括普通索引、唯一索引、复合索引等。
二级索引的结构:
二级索引的叶子节点存储的是:索引列的值 + 主键值,而不是数据的物理地址。
二级索引(INDEX name_age ON name, age)
│
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ name=A │ │ name=B │ │ name=C │
│ age=20 │ │ age=25 │ │ age=30 │
│ PK=1 │ │ PK=2 │ │ PK=3 │
└────────┘ └────────┘ └────────┘
↓ ↓ ↓
回表查询 回表查询 回表查询
为什么二级索引存储主键值?
如果二级索引存储物理地址,当行数据移动(如页分裂、更新导致行变长)时,需要更新所有指向该行的二级索引,代价巨大。存储主键值,虽然需要回表,但避免了这个问题。
回表查询:
通过二级索引查找数据的过程:
- 在二级索引 B+Tree 中查找,得到主键值
- 用主键值在聚簇索引 B+Tree 中查找,得到完整行数据
这就是"回表"——需要两次 B+Tree 查找。
-- 假设有索引 INDEX idx_name ON users(name)
SELECT * FROM users WHERE name = '张三';
-- 执行过程:
-- 1. 在 idx_name 索引中查找 name='张三',得到主键 id=100
-- 2. 用 id=100 在聚簇索引中查找,得到完整行数据
-- 这就是回表查询
聚簇索引 vs 二级索引对比
| 特性 | 聚簇索引 | 二级索引 |
|---|---|---|
| 叶子节点存储 | 完整行数据 | 索引列值 + 主键值 |
| 每表数量 | 一个 | 可以多个 |
| 查询方式 | 直接返回数据 | 可能需要回表 |
| 插入性能 | 受主键顺序影响 | 影响较小 |
| 空间占用 | 等于表大小 | 额外空间 |
主键设计建议
根据聚簇索引的特性,主键设计应遵循以下原则:
1. 使用自增主键
-- 推荐:自增主键
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100)
);
-- 不推荐:UUID 作为主键
CREATE TABLE users (
id CHAR(36) PRIMARY KEY, -- UUID 随机无序
name VARCHAR(50)
);
为什么 UUID 作为主键不好?
- 插入顺序随机,导致频繁的页分裂
- 页分裂会产生大量碎片,降低存储效率
- 索引维护成本高
2. 主键尽量短
-- 推荐:整数类型
id INT AUTO_INCREMENT PRIMARY KEY
-- 不推荐:长字符串
id VARCHAR(100) PRIMARY KEY
主键越长,二级索引占用的空间越大,因为每个二级索引都存储主键值。
3. 主键不要更新
-- 不推荐:更新主键
UPDATE users SET id = 1000 WHERE id = 1;
更新主键会导致:
- 聚簇索引中数据的物理移动
- 所有二级索引的更新
- 可能触发页分裂
索引类型
按数据结构分类
B+Tree 索引
MySQL 最常用的索引类型,适用于大多数场景:
-- 创建 B+Tree 索引(默认类型)
CREATE INDEX idx_name ON users(name);
-- 显式指定 B+Tree
CREATE INDEX idx_name ON users(name) USING BTREE;
适用场景:
- 全值匹配:
WHERE name = '张三' - 范围查询:
WHERE age > 20 - 前缀匹配:
WHERE name LIKE '张%' - 排序:
ORDER BY name - 分组:
GROUP BY name
Hash 索引
基于哈希表实现,只支持 Memory 存储引擎:
-- Memory 表默认使用 Hash 索引
CREATE TABLE sessions (
session_id VARCHAR(32) PRIMARY KEY,
data TEXT,
INDEX USING HASH (session_id)
) ENGINE = Memory;
特点:
- 只支持等值查询(
=,IN) - 不支持范围查询、排序
- 查询效率 O(1),但不稳定(哈希冲突)
全文索引(FULLTEXT)
用于全文搜索,支持中文分词:
-- 创建全文索引(需要 ngram 解析器支持中文)
CREATE TABLE articles (
id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
FULLTEXT INDEX ft_content (content) WITH PARSER ngram
);
-- 全文搜索
SELECT * FROM articles
WHERE MATCH(content) AGAINST('数据库优化' IN NATURAL LANGUAGE MODE);
空间索引(SPATIAL)
用于地理空间数据(GIS):
CREATE TABLE locations (
id INT PRIMARY KEY,
name VARCHAR(100),
position POINT SRID 4326,
SPATIAL INDEX idx_position (position)
);
-- 空间查询:查找距离某点 10km 内的位置
SELECT name, ST_Distance_Sphere(
position,
ST_GeomFromText('POINT(116.4074 39.9042)', 4326)
) AS distance
FROM locations
WHERE ST_Distance_Sphere(
position,
ST_GeomFromText('POINT(116.4074 39.9042)', 4326)
) < 10000;
按功能分类
主键索引(PRIMARY KEY)
特殊的唯一索引,不允许 NULL,每张表只能有一个:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50)
);
-- 或在表创建后添加
ALTER TABLE users ADD PRIMARY KEY (id);
唯一索引(UNIQUE)
索引列的值必须唯一,允许 NULL:
-- 创建唯一索引
CREATE UNIQUE INDEX uk_email ON users(email);
-- 或在建表时定义
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR(100) UNIQUE,
phone VARCHAR(20),
UNIQUE INDEX uk_phone (phone)
);
唯一索引 vs 主键:
| 特性 | 主键 | 唯一索引 |
|---|---|---|
| NULL 值 | 不允许 | 允许 |
| 每表数量 | 一个 | 多个 |
| 是否为聚簇索引 | 是(InnoDB) | 否 |
普通索引(INDEX)
最基本的索引类型,无特殊约束:
CREATE INDEX idx_status ON users(status);
复合索引
多列组合的索引,也叫组合索引或多列索引:
-- 创建复合索引
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
复合索引的设计至关重要,详见后续"最左前缀原则"。
复合索引与最左前缀原则
复合索引是多个列组合而成的索引,理解其工作原理是设计高效索引的关键。
最左前缀原则
复合索引按照定义的列顺序构建,MySQL 会从索引的最左列开始匹配。
假设有索引 idx_abc (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 c = 3 -- 部分使用(只用 a)
-- ❌ 无法使用索引
WHERE b = 2 -- 跳过了 a
WHERE c = 3 -- 跳过了 a 和 b
WHERE b = 2 AND c = 3 -- 跳过了 a
为什么是这样?
索引 B+Tree 首先按第一列排序,第一列相同时按第二列排序,以此类推。就像电话簿:先按姓氏排序,同姓的再按名字排序。如果你只知道名字,无法利用电话簿的排序快速查找。
范围查询的影响
当索引列中出现范围查询(>, <, >=, <=, BETWEEN, LIKE 前缀)时,该列之后的索引列无法使用:
-- 假设索引 idx_abc (a, b, c)
-- ✅ 三列都能使用
WHERE a = 1 AND b = 2 AND c = 3
-- ⚠️ 只有 a 和 b 能使用(a 是等值,b 是范围,c 无法使用)
WHERE a = 1 AND b > 2 AND c = 3
-- ⚠️ 只有 a 能使用(a 是范围,后面的都无法使用)
WHERE a > 1 AND b = 2 AND c = 3
复合索引设计策略
1. 等值查询列放前面,范围查询列放后面
-- 查询:WHERE status = 'active' AND created_at > '2024-01-01'
-- 推荐
CREATE INDEX idx_status_created ON orders(status, created_at);
-- 不推荐
CREATE INDEX idx_created_status ON orders(created_at, status);
2. 高选择性列放前面
选择性 = 不同值的数量 / 总行数,选择性越高,索引效果越好。
-- 查看列的选择性
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
COUNT(DISTINCT user_id) / COUNT(*) AS user_selectivity
FROM orders;
-- 假设 user_id 选择性为 0.9,status 选择性为 0.1
-- 推荐:高选择性列放前面
CREATE INDEX idx_user_status ON orders(user_id, status);
3. 考虑排序和分组
如果经常按某列排序或分组,可以将其加入索引:
-- 查询:WHERE user_id = ? ORDER BY created_at DESC
CREATE INDEX idx_user_created ON orders(user_id, created_at);
这样索引本身有序,可以避免 filesort。
索引下推(Index Condition Pushdown)
MySQL 5.6 引入的优化特性,可以在索引遍历过程中执行 WHERE 条件过滤,减少回表次数。
-- 假设有索引 idx_name_age (name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;
没有索引下推时:
- 在索引中找到所有
name LIKE '张%'的记录 - 回表查询完整行数据
- 过滤
age = 25的记录
有索引下推时:
- 在索引遍历时直接过滤
age = 25 - 只对满足条件的记录回表
索引下推减少了回表次数,提高查询效率。可以在 EXPLAIN 的 Extra 字段看到 Using index condition。
覆盖索引
当查询的所有列都包含在索引中时,MySQL 可以直接从索引返回数据,无需回表,这就是覆盖索引。
覆盖索引的优势
-- 假设有索引 idx_name_email (name, email)
-- ✅ 覆盖索引查询
SELECT name, email FROM users WHERE name = '张三';
-- EXPLAIN Extra: Using index
-- ❌ 需要回表(查询了 id 列)
SELECT id, name, email FROM users WHERE name = '张三';
-- EXPLAIN Extra: NULL
覆盖索引的好处:
- 避免回表,减少 I/O 操作
- 索引通常比数据小,更容易被缓存
- 查询效率显著提升
如何判断是否使用了覆盖索引?
使用 EXPLAIN 查看 Extra 字段:
EXPLAIN SELECT name, email FROM users WHERE name = '张三';
-- Extra: Using index ← 表示使用了覆盖索引
设计覆盖索引
-- 原始查询
SELECT user_id, SUM(amount)
FROM orders
WHERE status = 'completed'
GROUP BY user_id;
-- 设计覆盖索引
CREATE INDEX idx_status_user_amount ON orders(status, user_id, amount);
这个索引可以实现:
- 快速过滤
status = 'completed' - 按
user_id分组(索引有序) - 直接从索引读取
amount求和 - 完全避免回表
索引失效场景
即使创建了索引,某些情况下 MySQL 也无法使用索引。了解这些场景可以避免写出低效的 SQL。
1. 在索引列上使用函数或计算
-- ❌ 索引失效:在索引列上使用函数
WHERE YEAR(created_at) = 2024
WHERE DATE(created_at) = '2024-01-01'
WHERE LOWER(name) = 'zhangsan'
-- ✅ 索引有效:改写条件
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
WHERE created_at >= '2024-01-01 00:00:00'
AND created_at <= '2024-01-01 23:59:59'
WHERE name = 'ZhangSan' OR name = 'zhangsan'
函数索引(MySQL 8.0+):
如果确实需要在函数结果上建立索引,可以使用函数索引:
-- 创建函数索引
CREATE INDEX idx_year ON orders((YEAR(created_at)));
-- 或在建表时
CREATE TABLE orders (
id INT PRIMARY KEY,
created_at DATETIME,
INDEX idx_year ((YEAR(created_at)))
);
2. 隐式类型转换
-- ❌ 索引失效:phone 是 VARCHAR,传入数字触发隐式转换
WHERE phone = 13800138000
-- ✅ 索引有效:使用字符串
WHERE phone = '13800138000'
为什么会失效?
MySQL 会将字符串列转换为数字进行比较,相当于执行了 CAST(phone AS SIGNED),这是一个函数操作。
3. LIKE 以通配符开头
-- ❌ 索引失效:以 % 开头
WHERE name LIKE '%张'
WHERE name LIKE '%张%'
-- ✅ 索引有效:前缀匹配
WHERE name LIKE '张%'
前缀匹配为什么有效?
索引是按字典序排列的,前缀匹配可以利用 B+Tree 的有序性。而以 % 开头意味着要从任意位置开始匹配,无法利用索引顺序。
4. 使用 NOT、!=、<> 操作
-- ⚠️ 可能索引失效
WHERE status != 'deleted'
WHERE status NOT IN ('deleted', 'cancelled')
-- ✅ 改写为 IN 或范围
WHERE status IN ('active', 'pending')
WHERE status = 'active' OR status = 'pending'
5. 使用 OR 连接非索引列
-- 假设 name 有索引,age 没有索引
-- ❌ 索引失效
WHERE name = '张三' OR age = 25
-- ✅ 使用 UNION
SELECT * FROM users WHERE name = '张三'
UNION
SELECT * FROM users WHERE age = 25;
6. 索引列参与表达式计算
-- ❌ 索引失效
WHERE age + 1 = 26
WHERE age * 2 > 50
-- ✅ 索引有效
WHERE age = 25
WHERE age > 25
7. 联合索引不满足最左前缀
-- 假设索引 idx_abc (a, b, c)
-- ❌ 索引失效
WHERE b = 2 AND c = 3
WHERE c = 3
8. 全表扫描更快时
当 MySQL 优化器判断全表扫描比使用索引更快时,会放弃使用索引:
- 表数据量很小
- 查询需要返回大部分数据(如超过 20%)
- 索引选择性太低(如性别列只有两个值)
-- 强制使用索引(谨慎使用)
SELECT * FROM users FORCE INDEX (idx_name) WHERE name = '张三';
-- 忽略索引
SELECT * FROM users IGNORE INDEX (idx_name) WHERE name = '张三';
EXPLAIN 执行计划分析
EXPLAIN 是分析 SQL 查询性能的关键工具,可以帮助理解 MySQL 如何使用索引。
基本使用
EXPLAIN SELECT * FROM users WHERE name = '张三';
-- MySQL 8.0+ 可以使用更详细的格式
EXPLAIN ANALYZE SELECT * FROM users WHERE name = '张三';
输出字段详解
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
type:访问类型
type 表示 MySQL 如何查找数据,从好到差依次为:
| 类型 | 说明 | 性能 |
|---|---|---|
| system | 单行系统表 | 最优 |
| const | 主键或唯一索引常量查询 | 极优 |
| eq_ref | 主键或唯一索引关联查询 | 优秀 |
| ref | 非唯一索引等值查询 | 良好 |
| fulltext | 全文索引 | 良好 |
| ref_or_null | 类似 ref,额外搜索 NULL | 良好 |
| index_merge | 索引合并 | 一般 |
| range | 索引范围扫描 | 一般 |
| index | 全索引扫描 | 较差 |
| ALL | 全表扫描 | 最差 |
重点关注:避免出现 ALL,尽量达到 ref 或更好。
-- const:主键常量查询
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const
-- ref:非唯一索引等值查询
EXPLAIN SELECT * FROM users WHERE name = '张三';
-- type: ref
-- range:范围查询
EXPLAIN SELECT * FROM users WHERE age > 25;
-- type: range
-- ALL:全表扫描(需要优化)
EXPLAIN SELECT * FROM users WHERE age + 1 = 26;
-- type: ALL
key 和 possible_keys
possible_keys:MySQL 可能使用的索引列表key:实际使用的索引key_len:使用的索引长度(字节)
-- 假设有索引 idx_name_age (name, age)
EXPLAIN SELECT * FROM users WHERE name = '张三' AND age = 25;
-- possible_keys: idx_name_age
-- key: idx_name_age
-- key_len: 204 (假设 name VARCHAR(50) utf8mb4,age INT)
key_len 计算方法:
- VARCHAR(n):n × 4 + 2(utf8mb4 编码,最大 4 字节/字符,+2 是变长字段长度标记)
- INT:4 字节
- NULL 值需要额外 1 字节
rows:预估扫描行数
MySQL 预估需要扫描的行数,这是一个估计值,基于索引统计信息。值越小越好。
-- 更新索引统计信息
ANALYZE TABLE users;
Extra:额外信息
Extra 字段包含重要的执行细节:
| 值 | 含义 | 建议 |
|---|---|---|
| Using index | 使用覆盖索引 | 好 |
| Using index condition | 使用索引下推 | 好 |
| Using where | 服务器层过滤 | 一般 |
| Using filesort | 额外排序操作 | 需优化 |
| Using temporary | 使用临时表 | 需优化 |
| Using join buffer | 使用连接缓冲 | 可能需优化 |
| Impossible WHERE | WHERE 条件永远为假 | 检查逻辑 |
-- Using filesort:需要优化
EXPLAIN SELECT * FROM users ORDER BY age;
-- Extra: Using filesort
-- 优化:为排序列创建索引
CREATE INDEX idx_age ON users(age);
-- Using temporary:需要优化
EXPLAIN SELECT DISTINCT status FROM users;
-- Extra: Using temporary
-- Using index:覆盖索引,好
EXPLAIN SELECT name FROM users WHERE name = '张三';
-- Extra: Using index
EXPLAIN ANALYZE(MySQL 8.0.18+)
MySQL 8.0.18 引入了 EXPLAIN ANALYZE,它会实际执行查询并显示每个步骤的耗时:
EXPLAIN ANALYZE SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.name = '张三';
-- 输出示例:
-> Nested loop inner join (cost=1.15 rows=2) (actual time=0.032..0.042 rows=3 loops=1)
-> Index lookup on u using idx_name (name='张三') (cost=0.35 rows=1) (actual time=0.019..0.020 rows=1 loops=1)
-> Index lookup on o using idx_user_id (user_id=u.id) (cost=0.78 rows=2) (actual time=0.010..0.019 rows=3 loops=1)
这提供了实际的执行时间和行数,比普通 EXPLAIN 更直观。
索引创建与管理
创建索引
-- 方式一:CREATE INDEX
CREATE INDEX idx_name ON users(name);
CREATE UNIQUE INDEX uk_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- 方式二:ALTER TABLE
ALTER TABLE users ADD INDEX idx_age (age);
ALTER TABLE users ADD UNIQUE INDEX uk_phone (phone);
ALTER TABLE users ADD PRIMARY KEY (id);
-- 方式三:建表时创建
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100),
age INT,
INDEX idx_name (name),
UNIQUE INDEX uk_email (email),
INDEX idx_name_age (name, age)
);
前缀索引
对长字符串列,可以只索引前面的字符,节省空间:
-- 创建前缀索引
CREATE INDEX idx_email_prefix ON users(email(10));
如何选择前缀长度?
-- 分析不同前缀长度的选择性
SELECT
COUNT(DISTINCT email) AS total,
COUNT(DISTINCT LEFT(email, 5)) AS prefix_5,
COUNT(DISTINCT LEFT(email, 10)) AS prefix_10,
COUNT(DISTINCT LEFT(email, 15)) AS prefix_15,
COUNT(DISTINCT LEFT(email, 20)) AS prefix_20
FROM users;
-- 选择选择性接近完整列的最短前缀
前缀索引的限制:
- 不能用于 ORDER BY 和 GROUP BY
- 不能作为覆盖索引
隐藏索引(MySQL 8.0+)
隐藏索引不会被优化器使用,适合测试删除索引的影响:
-- 创建隐藏索引
CREATE INDEX idx_test ON users(test_column) INVISIBLE;
-- 切换可见性
ALTER TABLE users ALTER INDEX idx_test INVISIBLE; -- 隐藏
ALTER TABLE users ALTER INDEX idx_test VISIBLE; -- 显示
-- 查看索引是否隐藏
SHOW INDEX FROM users;
-- Visible 字段: YES/NO
使用场景:
- 准备删除索引前,先隐藏测试影响
- 调试查询性能,临时禁用某个索引
降序索引(MySQL 8.0+)
MySQL 8.0 支持真正的降序索引:
-- 创建降序索引
CREATE INDEX idx_created_desc ON orders(created_at DESC);
-- 混合排序索引
CREATE INDEX idx_status_asc_created_desc ON orders(status ASC, created_at DESC);
-- 可以优化这类查询
SELECT * FROM orders
WHERE status = 'active'
ORDER BY created_at DESC;
函数索引(MySQL 8.0+)
基于函数或表达式创建索引:
-- 基于函数创建索引
CREATE INDEX idx_year ON orders((YEAR(created_at)));
-- 基于表达式创建索引
CREATE INDEX idx_full_name ON users((CONCAT(first_name, ' ', last_name)));
-- JSON 路径索引
CREATE INDEX idx_data_name ON users((CAST(data->>'$.name' AS CHAR(50))));
查看和删除索引
-- 查看表的索引
SHOW INDEX FROM users;
-- 从 information_schema 查看
SELECT
INDEX_NAME,
COLUMN_NAME,
SEQ_IN_INDEX,
NON_UNIQUE,
CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'users'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
-- 删除索引
DROP INDEX idx_name ON users;
ALTER TABLE users DROP INDEX idx_name;
ALTER TABLE users DROP PRIMARY KEY; -- 删除主键
索引维护
-- 分析表,更新索引统计信息
ANALYZE TABLE users;
-- 优化表,重建表和索引
OPTIMIZE TABLE users;
-- 检查表
CHECK TABLE users;
-- 强制重建表(修复索引碎片)
ALTER TABLE users ENGINE = InnoDB;
索引优化策略
1. 为高频查询创建索引
根据实际查询模式创建索引,而不是盲目创建:
-- 分析慢查询
SELECT * FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 10;
-- 为慢查询中的条件列创建索引
CREATE INDEX idx_status_user ON orders(status, user_id);
2. 选择合适的索引类型
| 场景 | 推荐索引类型 |
|---|---|
| 主键查询 | 主键索引 |
| 唯一约束 | 唯一索引 |
| 等值查询 | 普通索引 |
| 多条件查询 | 复合索引 |
| 范围查询 | B+Tree 索引 |
| 文本搜索 | 全文索引 |
| 地理数据 | 空间索引 |
3. 避免冗余索引
冗余索引浪费空间并增加维护成本:
-- 冗余索引示例
CREATE INDEX idx_a ON users(a);
CREATE INDEX idx_a_b ON users(a, b); -- idx_a 是冗余的
-- 删除冗余索引
DROP INDEX idx_a ON users;
4. 删除未使用的索引
-- 查看索引使用情况(MySQL 5.7+)
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
COUNT_READ,
COUNT_FETCH
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'mydb'
AND INDEX_NAME IS NOT NULL
ORDER BY COUNT_READ DESC;
-- 查找未使用的索引
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE INDEX_NAME IS NOT NULL
AND COUNT_READ = 0
AND OBJECT_SCHEMA = 'mydb';
5. 控制索引数量
索引不是越多越好,一般建议:
- 单表索引数量不超过 5 个
- 单个索引的列数不超过 5 列
- 根据实际查询需求创建
6. 定期分析表
-- 定期执行,更新统计信息
ANALYZE TABLE users;
-- 或使用事件定时执行
CREATE EVENT analyze_tables
ON SCHEDULE EVERY 1 WEEK
DO
CALL analyze_all_tables();
实战案例
案例 1:电商订单查询优化
问题 SQL:
SELECT * FROM orders
WHERE user_id = 1001
AND status = 'completed'
ORDER BY created_at DESC
LIMIT 10;
执行计划分析:
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND status = 'completed'
ORDER BY created_at DESC LIMIT 10;
-- type: ALL, Extra: Using where; Using filesort
-- 全表扫描 + 文件排序,性能很差
优化方案:
-- 创建复合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at DESC);
-- 执行计划
-- type: ref, Extra: Using index condition
-- 使用索引,避免全表扫描和文件排序
索引设计解释:
user_id放第一位:等值查询,快速过滤status放第二位:等值查询,进一步过滤created_at DESC放第三位:支持降序排序,避免 filesort
案例 2:覆盖索引优化
问题 SQL:
-- 统计每个用户的订单数量
SELECT user_id, COUNT(*)
FROM orders
WHERE status = 'completed'
GROUP BY user_id;
优化前:
-- 假设只有 idx_status (status)
EXPLAIN SELECT user_id, COUNT(*)
FROM orders WHERE status = 'completed' GROUP BY user_id;
-- type: ref, Extra: Using where; Using temporary; Using filesort
-- 使用临时表和文件排序
优化后:
-- 创建覆盖索引
CREATE INDEX idx_status_user ON orders(status, user_id);
EXPLAIN SELECT user_id, COUNT(*)
FROM orders WHERE status = 'completed' GROUP BY user_id;
-- type: ref, Extra: Using index
-- 使用覆盖索引,无需回表
案例 3:大表分页优化
问题 SQL:
-- 深分页查询,越往后越慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
问题分析:
MySQL 需要扫描前 1000010 行,然后丢弃前 1000000 行,效率极低。
优化方案 1:延迟关联
-- 先查 ID,再关联查完整数据
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 1000000, 10
) t ON o.id = t.id;
优化方案 2:记录上次位置
-- 记住上次查询的最大 ID
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10;
案例 4:索引合并优化
问题 SQL:
SELECT * FROM orders
WHERE user_id = 1001 OR product_id = 2001;
执行计划:
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 OR product_id = 2001;
-- type: index_merge
-- possible_keys: idx_user_id, idx_product_id
-- key: idx_user_id, idx_product_id
-- Extra: Using union(idx_user_id, idx_product_id); Using where
问题:索引合并不是最高效的方式,需要合并两个索引的结果。
优化方案:
-- 改写为 UNION
SELECT * FROM orders WHERE user_id = 1001
UNION
SELECT * FROM orders WHERE product_id = 2001;
-- 或者根据业务需求,创建复合索引
小结
MySQL 索引是性能优化的核心技术,本章我们学习了:
核心概念:
- 索引是加速数据检索的数据结构,本质是空间换时间
- InnoDB 使用 B+Tree 作为索引结构,适合磁盘存储
索引类型:
- 聚簇索引:数据与索引合一,每表一个,通常是主键
- 二级索引:存储索引列 + 主键,查询可能需要回表
- 复合索引:多列组合,遵循最左前缀原则
优化要点:
- 为高频查询创建合适的索引
- 复合索引设计:等值列在前,范围列在后
- 利用覆盖索引减少回表
- 避免索引失效场景
诊断工具:
- EXPLAIN 分析执行计划
- EXPLAIN ANALYZE 查看实际执行时间
- performance_schema 监控索引使用
练习
- 分析一张表的查询模式,设计合理的索引方案
- 使用 EXPLAIN 分析慢查询,找出索引失效原因
- 设计一个覆盖索引优化聚合查询
- 对比有索引和无索引的查询性能差异
- 实践索引下推和覆盖索引的使用