跳至正文
来两杯美式
返回

MySQL 知识系列(八):SQL 调优(二)—— 索引设计与调优工具

By 来两杯美式
发布于

本文是 MySQL 知识系列的第八篇——SQL 调优专题第二部分。上一篇讲了 EXPLAIN 执行计划、type 性能 7 档、Extra 关键字、ORDER BY 排序原理。这一篇把所有”调优动作”讲透:怎么设计索引、怎么让索引不失效、慢查询怎么定位、COUNT 该用哪个

最左前缀匹配原则

定义:MySQL 从左向右匹配联合索引,遇到范围查询就停止匹配

-- 假设联合索引 (a, b, c, d)
WHERE a = 1 AND b = 2 AND c > 3 AND d = 4
-- 索引命中: a, b, c(c 是范围)
-- 索引失效: d(因为 c 是范围,d 用不到)

-- 调整索引顺序为 (a, b, d, c)
-- 索引命中: a, b, d, c(c 也是范围,所以仍然 stop)
-- 但 a、b、d 都能用上

💡 联合索引的列顺序设计远比想象中重要——(a, b, d, c)(a, b, c, d) 看似相近,实际效果完全不同。

关键细节

1. 范围查询”右侧全部失效”,但 IN 是例外

WHERE a = 1 AND b = 2 AND c IN (3, 5) AND d = 4
-- 索引命中: a, b, c, d(IN 等价于多个等值,d 不失效)
-- MySQL 优化器会把 IN 优化成可识别形式

2. 联合索引必须最左元素出现在条件中

-- 联合索引 (a, b, c)
WHERE b = 2 AND c = 3   -- ✗ 不会命中(没有 a)
WHERE a = 1 AND c = 3   -- ✗ 只命中 a(跳过 b,c 失效)
WHERE a = 1 AND b = 2   -- ✓ 命中 a, b

3. 排序顺序的”序”

联合索引的数据先按 a 排序,再按 b 排序……所以 a 整体是有序的,但 b 在 a 范围内才有序,整体无序。

⚠️ 这就是”用第二个字段条件判断用不到索引”的根本原因。

索引设计三大黄金法则

法则一:联合索引列顺序——三个优先级

-- 经常使用的列优先
-- 选择性高的列优先(基数大)
-- 宽度小的列优先(占用空间小)

典型案例

-- 错误:(name, status),name 重复值多(基数低),status 只有 2~5 个值
-- 正确:(status, name),先按 status 过滤大块数据,再按 name 精确定位

法则二:联合索引尽量覆盖 WHERE + ORDER BY

-- 一个联合索引同时解决 WHERE 和 ORDER BY
WHERE a = 1 AND b = 2
ORDER BY c

-- 联合索引 (a, b, c) 可以同时满足 WHERE 和 ORDER BY

法则三:能用覆盖索引就不回表

-- 假设联合索引 (user_id, status)
-- 业务只需要 user_id 和 status 两个字段
SELECT user_id, status FROM order WHERE user_id = 1;
-- ✓ 覆盖索引,无需回表

索引失效的 5 大经典场景

场景一:函数操作

函数作用在索引列上 → 索引失效

-- 错误:to_days() 作用在索引列 out_date
SELECT * FROM product
WHERE to_days(out_date) - to_days(current_date) <= 30;

-- 正确:把运算放在常量侧
SELECT * FROM product
WHERE out_date <= date_add(current_date, interval 30 day);

原理:索引列必须直接参与比较,被函数包裹就破坏索引的有序性。

场景二:隐式类型转换

-- phone 是 VARCHAR,传入 INTEGER
SELECT * FROM user WHERE phone = 13800138000;   -- 索引失效
SELECT * FROM user WHERE phone = '13800138000'; -- 索引命中

场景三:前导模糊查询

WHERE name LIKE '%abc';    -- ✗ 索引失效
WHERE name LIKE 'abc%';    -- ✓ 索引命中

场景四:负向查询

WHERE status != 1;    -- ✗ 索引失效(部分情况)
WHERE status <> 1;    -- ✗ 索引失效
WHERE status NOT IN (1, 2);  -- ✗ 索引失效

场景五:OR 条件中有非索引列

-- 假设只有 name 有索引,age 无索引
WHERE name = 'a' OR age = 10;   -- ✗ 整个 OR 走全表扫描

前缀索引

-- 对列的前 N 个字符建索引
CREATE INDEX idx_name ON user(name(3));

适用场景

注意点

💡 进阶技巧:身份证号前 6 位都相同,可以倒序存储再建立前缀索引,提升区分度:

CREATE INDEX idx_id_reverse ON user(REVERSE(id_card));

联合索引

CREATE INDEX idx_a_b_c ON table(a, b, c);

列顺序选择(按优先级排序)

  1. 经常使用的列优先
  2. 选择性高(基数大)的列优先
  3. 宽度小(占用空间小)的列优先

实战模板

-- 业务查询:根据 user_id 查 user 最近的订单
-- 联合索引设计:(user_id, created_at)
-- 同时满足 WHERE user_id = ? AND ORDER BY created_at DESC
CREATE INDEX idx_user_created ON order(user_id, created_at);

覆盖索引

覆盖索引:索引上的列已经覆盖了查询需要的所有列(SELECT、WHERE、GROUP BY、ORDER BY 全部在索引中),完全无需回表

-- 假设索引 (user_id, status, created_at)
-- 查询只用到 user_id 和 status 两个字段
SELECT user_id, status FROM order WHERE user_id = 1;
-- EXPLAIN 会出现 "Using index"

💡 为什么”用 SELECT *” 通常是反模式?因为它基本不可能走覆盖索引,必然回表。

慢查询日志:定位慢 SQL

慢查询日志开启

-- 查看是否开启
SHOW VARIABLES LIKE 'slow_query_log';

-- 开启
SET GLOBAL slow_query_log = ON;

-- 慢查询阈值(单位:秒,可设小数)
SET GLOBAL long_query_time = 0.1;        -- 100ms

-- 把没走索引的 SQL 也记录下来
SET GLOBAL log_queries_not_using_indexes = ON;

-- 注意:上述命令 MySQL 重启后失效;永久配置需改 my.cnf

调优基本思路

  1. 慢查询日志定位 SQL
  2. EXPLAIN 分析 SQL 执行计划
  3. 关注 type(index/ALL 都不好)
  4. 关注 Extra(Using filesort / Using temporary 必须优化)
  5. 调整 SQL 或索引
  6. 再次跑实际执行时间验证

调优工具:mysqldumpslow

# 分析前 5 条最慢 SQL
mysqldumpslow -t 5 /var/lib/mysql/slow.log

常用参数

调优工具:pt-query-digest(推荐)

pt-query-digest 是 Percona 公司出品的慢查询分析神器,比 mysqldumpslow 功能强大得多。

pt-query-digest 两种输出方式(文件/数据库)

输出到文件

pt-query-digest slow-log > slow_log.report

输出到数据库(可记录历史):

pt-query-digest slow-log --review \
  h=127.0.0.1,D=test,p=root,P=3306,u=root,t=query_review \
  --create-review-table \
  --review-history t=hostname_slow

关键参数

参数含义
h数据库 host
D数据库名
p密码
P端口
u用户名
t目标表
--create-review-table自动创建 review 表
--review-history历史记录表

💡 生产环境推荐:把 pt-query-digest 的输出定期入库(如每天 0 点执行一次),积累数据做慢 SQL 趋势分析。

COUNT 函数选型

SELECT COUNT(*) FROM table_name;
SELECT COUNT(1) FROM table_name;
SELECT COUNT(id) FROM table_name;
SELECT COUNT(name) FROM table_name;

三种写法的实际行为

写法MySQL 实际做的事性能
COUNT(*)直接挑一个索引树,返回该索引树中数据的个数(MySQL 优化)⭐⭐⭐ 最优
COUNT(1)1 是常量,遍历时给每行”赋 1”,但仍会做非空判断略差
COUNT(列名)统计该列不为 NULL 的行数取决于列上是否有索引

💡 结论直接用 COUNT(*)——MySQL 专门为它做了优化。

高级技巧:一条 SQL 统计多个条件

COUNT(条件 OR NULL) 优化写法

-- 推荐:用 COUNT(条件 OR NULL)
SELECT
    COUNT(release_year = '2006' OR NULL) AS '2006年电影数量',
    COUNT(release_year = '2007' OR NULL) AS '2007年电影数量'
FROM film;

原理COUNT() 不统计 NULL 值;条件 = TRUE 时是 1,条件 = FALSE 时是 0,会被 COUNT 计入——所以要 OR NULLFALSE 转成 NULL 排除。

⚠️ 错误写法对比

-- 错误:COUNT(release_year = '2006') 会把 FALSE 的行也计入
SELECT COUNT(release_year = '2006') AS '2006年电影数量' FROM film;

索引越多越好吗?

当然不是。 索引是有代价的:

代价详细
空间成本每个索引都是一棵 B+ 树,占用磁盘
写入成本INSERT / UPDATE / DELETE 都要维护所有相关索引
优化器成本可用索引太多,优化器选错索引的概率上升

索引设计原则

实战清单

调优一个慢 SQL 的标准流程:

  1. EXPLAIN 看执行计划,重点是 typeExtra
  2. 确保 type ≥ range,理想是 ref / eq_ref
  3. 消除 Using filesort——调整 ORDER BY 或联合索引
  4. 消除 Using temporary——GROUP BY 走索引
  5. 加合适的索引——按”3 大黄金法则”设计
  6. 避免索引失效——函数、隐式转换、前导 %
  7. 覆盖索引——只查需要的列
  8. 慢 SQL 入库——pt-query-digest 长期跟踪

下一篇(篇九)会讲”CASE 语句、乐观锁与悲观锁实战”——用一条 UPDATE 多字段的 CASE WHEN 写法、三种并发更新方案对比、乐观锁的版本号/时间戳实现、锁选型决策树。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
MySQL
第 8 / 9 篇
查看系列全部文章
  1. 01.MySQL 知识系列(一):整体架构与存储引擎
  2. 02.MySQL 知识系列(二):索引结构与查询原理
  3. 03.MySQL 知识系列(三):锁机制与并发控制
  4. 04.MySQL 知识系列(四):日志系统:redo log、undo log、binlog
  5. 05.MySQL 知识系列(五):隔离级别、Spring 事务与查询流程
  6. 06.MySQL 知识系列(六):SQL 注入、版本特性与容量评估
  7. 07.MySQL 知识系列(七):SQL 调优(一)—— EXPLAIN 执行计划
  8. 08.MySQL 知识系列(八):SQL 调优(二)—— 索引设计与调优工具
  9. 09.MySQL 知识系列(九):CASE 语句、乐观锁与悲观锁实战

上一篇
MySQL 知识系列(九):CASE 语句、乐观锁与悲观锁实战
下一篇
MySQL 知识系列(七):SQL 调优(一)—— EXPLAIN 执行计划