MySQL教程 / 第 190 节

第19章:索引优化进阶

索引优化是SQL优化的核心,90%的性能问题都与索引有关

19.1 索引优化原则回顾

19.1.1 索引的优缺点

优点:

  • ✅ 加快查询速度(WHERE、ORDER BY、GROUP BY)
  • ✅ 加快表连接速度
  • ✅ 减少排序和分组的时间
  • ✅ 唯一索引保证数据唯一性

缺点:

  • ❌ 占用磁盘空间
  • ❌ 降低INSERT、UPDATE、DELETE速度
  • ❌ 维护索引需要时间

索引使用原则:

  • ✅ WHERE、ORDER BY、GROUP BY的列
  • ✅ 高选择性的列(区分度高)
  • ✅ 小表不需要索引
  • ❌ 频繁更新的列不建索引
  • ❌ 区分度低的列不建索引(如性别)

19.2 索引失效场景 ⭐⭐⭐⭐⭐

19.2.1 使用函数或表达式

-- ❌ 索引失效:对索引列使用函数
CREATE INDEX idx_create_time ON orders(create_time);

-- 失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
SELECT * FROM orders WHERE DATE(create_time) = '2024-11-10';

-- ✅ 优化:不使用函数
SELECT * FROM orders 
WHERE create_time >= '2024-01-01' 
  AND create_time < '2025-01-01';

SELECT * FROM orders 
WHERE create_time >= '2024-11-10 00:00:00' 
  AND create_time < '2024-11-10 23:59:59';

-- ❌ 索引失效:对索引列进行运算
CREATE INDEX idx_age ON users(age);

-- 失效
SELECT * FROM users WHERE age + 1 = 26;

-- ✅ 优化
SELECT * FROM users WHERE age = 25;

-- ❌ 索引失效:字符串拼接
CREATE INDEX idx_name ON users(name);

-- 失效
SELECT * FROM users WHERE CONCAT(name, '先生') = '张三先生';

-- ✅ 优化
SELECT * FROM users WHERE name = '张三';

19.2.2 隐式类型转换

-- ❌ 索引失效:类型不匹配
CREATE INDEX idx_phone ON users(phone);  -- phone是VARCHAR

-- 失效(字符串列与数字比较)
SELECT * FROM users WHERE phone = 13800138000;

-- ✅ 优化:使用字符串
SELECT * FROM users WHERE phone = '13800138000';

-- ❌ 索引失效:数字列与字符串比较
CREATE INDEX idx_user_id ON orders(user_id);  -- user_id是INT

-- 失效
SELECT * FROM orders WHERE user_id = '100';

-- ✅ 优化
SELECT * FROM orders WHERE user_id = 100;

-- 规则:
-- 字符串列 = 数字 → 索引失效
-- 数字列 = 字符串 → 索引有效(MySQL会转换字符串)

19.2.3 LIKE以通配符开头

CREATE INDEX idx_name ON users(name);

-- ❌ 索引失效:以%开头
SELECT * FROM users WHERE name LIKE '%张三%';
SELECT * FROM users WHERE name LIKE '%张三';

-- ✅ 索引有效:%在结尾
SELECT * FROM users WHERE name LIKE '张三%';

-- 解决方案:使用全文索引
ALTER TABLE users ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('张三');

19.2.4 OR条件

CREATE INDEX idx_age ON users(age);
CREATE INDEX idx_city ON users(city);

-- ❌ 索引失效:OR连接的列没有都建索引
SELECT * FROM users WHERE age = 25 OR email = 'test@example.com';
-- email没有索引,导致全表扫描

-- ✅ 优化1:都建索引
CREATE INDEX idx_email ON users(email);
SELECT * FROM users WHERE age = 25 OR email = 'test@example.com';

-- ✅ 优化2:改用UNION
SELECT * FROM users WHERE age = 25
UNION
SELECT * FROM users WHERE email = 'test@example.com';

-- ✅ 优化3:改用IN(如果是同一列)
SELECT * FROM users WHERE age IN (25, 30, 35);

19.2.5 NOT、!=、<>

CREATE INDEX idx_status ON orders(status);

-- ❌ 索引失效:NOT、!=、<>
SELECT * FROM orders WHERE status != 1;
SELECT * FROM orders WHERE status <> 1;
SELECT * FROM orders WHERE NOT status = 1;

-- ✅ 优化:改用IN或范围查询
SELECT * FROM orders WHERE status IN (0, 2, 3);
SELECT * FROM orders WHERE status > 1 OR status < 1;

19.2.6 IS NULL、IS NOT NULL

CREATE INDEX idx_deleted_at ON users(deleted_at);

-- ⚠️ 可能失效:IS NULL、IS NOT NULL
SELECT * FROM users WHERE deleted_at IS NULL;
SELECT * FROM users WHERE deleted_at IS NOT NULL;

-- 是否使用索引取决于NULL值的比例
-- 如果NULL值很多,IS NOT NULL可能不走索引
-- 如果NULL值很少,IS NULL可能不走索引

-- ✅ 优化:避免NULL值
-- 使用默认值代替NULL
ALTER TABLE users MODIFY deleted_at DATETIME DEFAULT '1970-01-01 00:00:00';

19.2.7 联合索引不满足最左前缀

CREATE INDEX idx_abc ON users(a, b, c);

-- ✅ 使用索引
SELECT * FROM users WHERE a = 1;
SELECT * FROM users WHERE a = 1 AND b = 2;
SELECT * FROM users WHERE a = 1 AND b = 2 AND c = 3;
SELECT * FROM users WHERE a = 1 AND c = 3;  -- 只用到a

-- ❌ 不使用索引
SELECT * FROM users WHERE b = 2;
SELECT * FROM users WHERE c = 3;
SELECT * FROM users WHERE b = 2 AND c = 3;

-- 最左前缀原则:
-- 必须从最左边的列开始,不能跳过中间的列

19.3 联合索引优化 ⭐⭐⭐⭐⭐

19.3.1 联合索引的顺序

-- 场景:经常查询 WHERE city = ? AND age = ?

-- 方案1:(city, age)
CREATE INDEX idx_city_age ON users(city, age);

-- 方案2:(age, city)
CREATE INDEX idx_age_city ON users(age, city);

-- 如何选择?
-- 原则1:区分度高的列放前面
-- 原则2:查询频率高的列放前面
-- 原则3:范围查询的列放后面

-- 分析区分度
SELECT 
  COUNT(DISTINCT city) / COUNT(*) AS city_selectivity,
  COUNT(DISTINCT age) / COUNT(*) AS age_selectivity
FROM users;

-- 假设结果:
-- city_selectivity: 0.05 (100个城市/2000条记录)
-- age_selectivity: 0.02 (40个年龄/2000条记录)

-- 结论:city区分度更高,应该放前面
CREATE INDEX idx_city_age ON users(city, age);

19.3.2 覆盖索引

-- 覆盖索引:索引包含了查询需要的所有列,不需要回表

-- ❌ 需要回表
CREATE INDEX idx_age ON users(age);
SELECT id, name, age FROM users WHERE age = 25;
-- 执行过程:
-- 1. 通过idx_age找到age=25的记录的主键id
-- 2. 回表查询name列

-- ✅ 覆盖索引(不需要回表)
CREATE INDEX idx_age_name ON users(age, name);
SELECT id, name, age FROM users WHERE age = 25;
-- 执行过程:
-- 1. 通过idx_age_name直接获取id、age、name
-- 2. 不需要回表

-- EXPLAIN中的Extra显示"Using index"表示使用了覆盖索引
EXPLAIN SELECT id, name, age FROM users WHERE age = 25;

-- 覆盖索引的优势:
-- ✅ 减少I/O(不需要回表)
-- ✅ 提高查询速度
-- ✅ 减少随机I/O

-- 覆盖索引的应用场景:
-- 1. 分页查询
-- 2. 统计查询
-- 3. 只查询索引列

19.3.3 索引下推(ICP)

-- 索引下推(Index Condition Pushdown):
-- MySQL 5.6引入,将WHERE条件下推到存储引擎层

CREATE INDEX idx_city_age ON users(city, age);

-- 查询
SELECT * FROM users WHERE city = '北京' AND age > 25 AND name LIKE '张%';

-- 没有ICP:
-- 1. 存储引擎返回city='北京'的所有记录
-- 2. Server层过滤age>25和name LIKE '张%'

-- 有ICP:
-- 1. 存储引擎过滤city='北京' AND age>25
-- 2. Server层只过滤name LIKE '张%'
-- 3. 减少回表次数

-- EXPLAIN中的Extra显示"Using index condition"表示使用了ICP
EXPLAIN SELECT * FROM users WHERE city = '北京' AND age > 25 AND name LIKE '张%';

-- 开启/关闭ICP
SET optimizer_switch='index_condition_pushdown=on';
SET optimizer_switch='index_condition_pushdown=off';

19.4 索引设计实战

19.4.1 分页查询优化

-- 场景:分页查询,LIMIT offset很大时性能差

-- ❌ 性能差:offset很大
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 需要扫描1000020行,然后丢弃前1000000行

-- ✅ 优化1:使用覆盖索引 + 延迟关联
SELECT * FROM orders o
INNER JOIN (
  SELECT id FROM orders ORDER BY id LIMIT 1000000, 20
) t ON o.id = t.id;
-- 子查询使用覆盖索引,只返回id,减少回表

-- ✅ 优化2:使用WHERE id > ?(推荐)
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
-- 前提:记录上一页的最大id

-- ✅ 优化3:使用游标(适合导出数据)
-- 第一次查询
SELECT * FROM orders WHERE id > 0 ORDER BY id LIMIT 1000;
-- 记录最后一条的id,下次查询
SELECT * FROM orders WHERE id > last_id ORDER BY id LIMIT 1000;

-- 性能对比:
-- LIMIT 1000000, 20:扫描1000020行
-- WHERE id > 1000000 LIMIT 20:扫描20行

19.4.2 排序优化

-- 场景:ORDER BY优化

-- ❌ 不使用索引:filesort
SELECT * FROM users ORDER BY age;
-- Extra: Using filesort(性能差)

-- ✅ 使用索引
CREATE INDEX idx_age ON users(age);
SELECT * FROM users ORDER BY age;
-- Extra: Using index(性能好)

-- 联合索引的排序
CREATE INDEX idx_city_age ON users(city, age);

-- ✅ 使用索引
SELECT * FROM users WHERE city = '北京' ORDER BY age;

-- ❌ 不使用索引:排序列不连续
SELECT * FROM users WHERE city = '北京' ORDER BY name;

-- ❌ 不使用索引:排序方向不一致
SELECT * FROM users ORDER BY city ASC, age DESC;

-- ✅ MySQL 8.0支持降序索引
CREATE INDEX idx_city_age_desc ON users(city ASC, age DESC);
SELECT * FROM users ORDER BY city ASC, age DESC;

-- 避免filesort的方法:
-- 1. 为ORDER BY的列建索引
-- 2. 联合索引的顺序要匹配ORDER BY
-- 3. WHERE和ORDER BY使用同一个索引

19.4.3 分组优化

-- 场景:GROUP BY优化

-- ❌ 不使用索引:Using temporary
SELECT city, COUNT(*) FROM users GROUP BY city;
-- Extra: Using temporary; Using filesort

-- ✅ 使用索引
CREATE INDEX idx_city ON users(city);
SELECT city, COUNT(*) FROM users GROUP BY city;
-- Extra: Using index

-- 联合索引的分组
CREATE INDEX idx_city_age ON users(city, age);

-- ✅ 使用索引
SELECT city, age, COUNT(*) FROM users GROUP BY city, age;

-- ❌ 不使用索引:分组列不连续
SELECT city, age, COUNT(*) FROM users GROUP BY age, city;

-- 优化技巧:
-- 1. 为GROUP BY的列建索引
-- 2. GROUP BY的顺序要匹配索引顺序
-- 3. 使用覆盖索引

19.4.4 JOIN优化

-- 场景:表连接优化

-- ❌ 没有索引
SELECT * FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.city = '北京';
-- 全表扫描,性能差

-- ✅ 建立索引
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_city ON users(city);

SELECT * FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.city = '北京';

-- JOIN优化原则:
-- 1. 为JOIN的列建索引
-- 2. 小表驱动大表
-- 3. 使用STRAIGHT_JOIN强制连接顺序

-- 小表驱动大表
-- ✅ 正确:小表在前
SELECT * FROM small_table s
INNER JOIN large_table l ON s.id = l.small_id;

-- ❌ 错误:大表在前
SELECT * FROM large_table l
INNER JOIN small_table s ON l.small_id = s.id;

-- 强制连接顺序
SELECT * FROM small_table s
STRAIGHT_JOIN large_table l ON s.id = l.small_id;

19.4.5 IN和EXISTS优化

-- 场景:子查询优化

-- IN vs EXISTS
-- 规则:小表驱动大表

-- 场景1:外表小,内表大 → 使用IN
SELECT * FROM small_table
WHERE id IN (SELECT small_id FROM large_table);

-- 场景2:外表大,内表小 → 使用EXISTS
SELECT * FROM large_table l
WHERE EXISTS (SELECT 1 FROM small_table s WHERE s.id = l.small_id);

-- 原理:
-- IN:先执行子查询,再遍历外表
-- EXISTS:遍历外表,每行执行子查询

-- 优化:改用JOIN
SELECT s.* FROM small_table s
INNER JOIN large_table l ON s.id = l.small_id;

19.5 索引监控与维护

19.5.1 查看索引使用情况

-- 查看表的索引
SHOW INDEX FROM users;

-- 查看索引统计信息
SELECT
  TABLE_NAME,
  INDEX_NAME,
  SEQ_IN_INDEX,
  COLUMN_NAME,
  CARDINALITY,
  INDEX_TYPE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'users';

-- 查看未使用的索引(MySQL 5.7+)
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_star = 0
  AND object_schema = 'mydb'
ORDER BY object_schema, object_name;

-- 查看索引大小
SELECT
  TABLE_NAME,
  INDEX_NAME,
  ROUND(STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2) AS size_mb
FROM mysql.innodb_index_stats
WHERE DATABASE_NAME = 'mydb' AND STAT_NAME = 'size';

19.5.2 索引碎片整理

-- 查看表碎片
SELECT
  TABLE_NAME,
  ENGINE,
  ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
  ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb,
  ROUND(DATA_FREE / 1024 / 1024, 2) AS free_mb,
  ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH) * 100, 2) AS frag_pct
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mydb'
  AND DATA_FREE > 0
ORDER BY frag_pct DESC;

-- 整理碎片
-- 方法1:OPTIMIZE TABLE(会锁表)
OPTIMIZE TABLE users;

-- 方法2:ALTER TABLE(重建表)
ALTER TABLE users ENGINE=InnoDB;

-- 方法3:pt-online-schema-change(不锁表,推荐)
pt-online-schema-change --alter "ENGINE=InnoDB" D=mydb,t=users --execute

-- 定期整理碎片(建议每月一次)

19.5.3 更新索引统计信息

-- 索引统计信息影响优化器的选择

-- 查看统计信息
SHOW INDEX FROM users;
-- 关注Cardinality列(索引基数)

-- 更新统计信息
ANALYZE TABLE users;

-- 查看统计信息更新时间
SELECT
  TABLE_NAME,
  UPDATE_TIME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mydb';

-- 配置自动更新统计信息
[mysqld]
innodb_stats_auto_recalc = 1  -- 自动更新
innodb_stats_persistent = 1   -- 持久化统计信息

19.6 索引设计最佳实践

19.6.1 索引设计原则

-- 1. 选择性高的列建索引
-- 选择性 = COUNT(DISTINCT column) / COUNT(*)
-- 选择性越高,索引效果越好

SELECT
  COUNT(DISTINCT city) / COUNT(*) AS city_selectivity,
  COUNT(DISTINCT gender) / COUNT(*) AS gender_selectivity
FROM users;
-- city_selectivity: 0.05(建议建索引)
-- gender_selectivity: 0.5(不建议建索引)

-- 2. 频繁查询的列建索引
-- WHERE、ORDER BY、GROUP BY、JOIN的列

-- 3. 小表不需要索引
-- 表数据少于1000行,全表扫描更快

-- 4. 更新频繁的列不建索引
-- 每次UPDATE都要更新索引

-- 5. 不在索引列上使用函数
-- 会导致索引失效

-- 6. 字符串列使用前缀索引
CREATE INDEX idx_email ON users(email(20));  -- 只索引前20个字符

-- 7. 联合索引优于多个单列索引
-- ✅ 推荐
CREATE INDEX idx_city_age ON users(city, age);

-- ❌ 不推荐
CREATE INDEX idx_city ON users(city);
CREATE INDEX idx_age ON users(age);

-- 8. 不要创建重复索引
-- ❌ 重复
CREATE INDEX idx_a ON users(a);
CREATE INDEX idx_ab ON users(a, b);  -- 包含了idx_a

-- 9. 删除未使用的索引
-- 定期检查并删除

-- 10. 控制索引数量
-- 每个表不超过5个索引(建议)

19.6.2 前缀索引

-- 场景:字符串列很长,索引占用空间大

-- ❌ 索引整个列
CREATE INDEX idx_email ON users(email);  -- email最长100字符

-- ✅ 前缀索引
CREATE INDEX idx_email ON users(email(20));  -- 只索引前20个字符

-- 如何选择前缀长度?
-- 目标:前缀的选择性接近完整列的选择性

-- 1. 查看完整列的选择性
SELECT COUNT(DISTINCT email) / COUNT(*) AS full_selectivity FROM users;
-- 结果:0.95

-- 2. 测试不同前缀长度的选择性
SELECT
  COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS prefix_5,
  COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_10,
  COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS prefix_15,
  COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS prefix_20
FROM users;
-- 结果:
-- prefix_5: 0.60
-- prefix_10: 0.85
-- prefix_15: 0.92
-- prefix_20: 0.95

-- 3. 选择接近完整列选择性的最短前缀
CREATE INDEX idx_email ON users(email(15));

-- 前缀索引的缺点:
-- ❌ 不能用于ORDER BY
-- ❌ 不能用于GROUP BY
-- ❌ 不能用于覆盖索引

19.6.3 索引合并

-- 索引合并:MySQL使用多个索引,然后合并结果

CREATE INDEX idx_age ON users(age);
CREATE INDEX idx_city ON users(city);

-- 查询
SELECT * FROM users WHERE age = 25 AND city = '北京';

-- MySQL可能使用:
-- 1. idx_age
-- 2. idx_city
-- 3. 索引合并(同时使用idx_age和idx_city)

-- 查看执行计划
EXPLAIN SELECT * FROM users WHERE age = 25 AND city = '北京';
-- type: index_merge
-- Extra: Using intersect(idx_age,idx_city)

-- 索引合并的类型:
-- 1. intersect:AND条件,取交集
-- 2. union:OR条件,取并集
-- 3. sort_union:复杂OR条件

-- 优化建议:
-- 索引合并性能不如联合索引
-- ✅ 推荐:创建联合索引
CREATE INDEX idx_age_city ON users(age, city);

19.7 实战案例

19.7.1 案例1:慢查询优化

-- 慢查询
SELECT * FROM orders
WHERE user_id = 100
  AND status = 1
  AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 10;

-- 执行时间:2.5秒

-- 分析执行计划
EXPLAIN SELECT * FROM orders
WHERE user_id = 100
  AND status = 1
  AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 10;

-- 结果:
-- type: ALL(全表扫描)
-- rows: 1000000
-- Extra: Using where; Using filesort

-- 优化方案:
-- 1. 创建联合索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);

-- 2. 再次执行
EXPLAIN SELECT * FROM orders
WHERE user_id = 100
  AND status = 1
  AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 10;

-- 结果:
-- type: range
-- key: idx_user_status_time
-- rows: 50
-- Extra: Using index condition

-- 执行时间:0.01秒
-- 性能提升:250倍

19.7.2 案例2:分页查询优化

-- 慢查询:深度分页
SELECT * FROM orders ORDER BY id LIMIT 500000, 20;
-- 执行时间:5秒

-- 优化方案1:延迟关联
SELECT o.* FROM orders o
INNER JOIN (
  SELECT id FROM orders ORDER BY id LIMIT 500000, 20
) t ON o.id = t.id;
-- 执行时间:1秒

-- 优化方案2:记录上次位置
SELECT * FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;
-- 执行时间:0.01秒

-- 优化方案3:使用覆盖索引
-- 如果只需要部分列
SELECT id, user_id, total FROM orders ORDER BY id LIMIT 500000, 20;
-- 如果(id, user_id, total)有索引,不需要回表

19.8 本章总结

本章学习内容:

  • ✅ 索引优化原则回顾
  • 索引失效场景(函数、类型转换、LIKE、OR等)⭐⭐⭐⭐⭐
  • 联合索引优化(顺序、覆盖索引、索引下推)⭐⭐⭐⭐⭐
  • 索引设计实战(分页、排序、分组、JOIN)⭐⭐⭐⭐⭐
  • ✅ 索引监控与维护
  • ✅ 索引设计最佳实践
  • ✅ 实战案例

重点掌握:

  1. 索引失效的7种场景
  2. 联合索引的最左前缀原则
  3. 覆盖索引减少回表
  4. 分页查询优化(延迟关联、WHERE id > ?)
  5. 小表驱动大表
  6. 前缀索引的使用

索引失效场景:

  1. 使用函数或表达式
  2. 隐式类型转换
  3. LIKE以%开头
  4. OR条件(部分列无索引)
  5. NOT、!=、<>
  6. IS NULL、IS NOT NULL
  7. 联合索引不满足最左前缀

优化技巧:

  • 分页:延迟关联、WHERE id > ?
  • 排序:为ORDER BY列建索引
  • 分组:为GROUP BY列建索引
  • JOIN:小表驱动大表,为连接列建索引

面试重点:

  • 索引失效的场景
  • 联合索引的最左前缀原则
  • 覆盖索引的作用
  • 如何优化分页查询
  • 如何选择联合索引的顺序

下一章预告: MySQL服务器优化


练习题

  1. 列举索引失效的场景
  2. 什么是最左前缀原则?
  3. 什么是覆盖索引?有什么优势?
  4. 如何优化深度分页查询?
  5. 联合索引(a,b,c),哪些查询会使用索引?
  6. 为什么字符串列与数字比较会导致索引失效?
  7. 如何选择联合索引的顺序?
  8. 什么是索引下推?
  9. 前缀索引的优缺点是什么?
  10. 如何查看未使用的索引?

继续学习: 第20章:MySQL服务器优化