SQL 性能优化
一、EXPLAIN 执行计划
1.1 基本使用
sql
-- 查看 SQL 执行计划
EXPLAIN SELECT * FROM employee WHERE id = 1;
-- 或
EXPLAIN FORMAT=JSON SELECT * FROM employee WHERE id = 1; -- 更详细
-- MySQL 8.0.16+ 支持 EXPLAIN ANALYZE(实际执行并给出耗时)
EXPLAIN ANALYZE SELECT * FROM employee e
JOIN department d ON e.dept_id = d.id
WHERE e.salary > 10000;
1.2 各字段含义详解
sql
-- 示例输出
EXPLAIN SELECT e.name, d.dept_name
FROM employee e
JOIN department d ON e.dept_id = d.id
WHERE e.salary > 10000\G
| 字段 | 示例值 | 说明 |
|---|---|---|
| id | 1 | 查询标识,id 越大越先执行,id 相同从上到下执行 |
| select_type | SIMPLE | SIMPLE/PRIMARY/SUBQUERY/DERIVED/UNION |
| table | e | 当前操作的表 |
| partitions | NULL | 涉及的分区 |
| type | ref | 访问类型(重要) |
| possible_keys | idx_dept_id | 可能用到的索引 |
| key | idx_dept_id | 实际使用的索引(重要) |
| key_len | 5 | 索引使用的字节数 |
| ref | const | 索引匹配的列或常量 |
| rows | 1000 | 预估扫描行数(重要) |
| filtered | 33.33 | 按表条件过滤后的百分比 |
| Extra | Using where | 额外信息(重要) |
1.3 type 访问类型(性能从优到劣)
system > const > eq_ref > ref > range > index > ALL
sql
-- system:表只有一行(系统表)
EXPLAIN SELECT * FROM mysql.proxies_priv;
-- const:主键或唯一索引等值查询
EXPLAIN SELECT * FROM employee WHERE id = 1;
-- type: const
-- eq_ref:JOIN 时使用主键/唯一索引关联
EXPLAIN SELECT * FROM employee e
JOIN department d ON e.dept_id = d.id;
-- 如果 d.id 是主键,type 为 eq_ref
-- ref:普通索引等值查询
EXPLAIN SELECT * FROM employee WHERE dept_id = 1;
-- type: ref(dept_id 上有索引)
-- range:索引范围查询
EXPLAIN SELECT * FROM employee WHERE salary BETWEEN 10000 AND 20000;
-- type: range(salary 上有索引)
-- index:扫描整个索引树
EXPLAIN SELECT COUNT(*) FROM employee;
-- type: index(比全表扫描稍好)
-- ALL:全表扫描(最差)
EXPLAIN SELECT * FROM employee WHERE name = '张三';
-- type: ALL(name 无索引)
1.4 Extra 常见值及含义
| Extra | 含义 | 严重程度 |
|---|---|---|
| Using index | 覆盖索引,无需回表 | 优秀 |
| Using where | 在服务层过滤数据 | 正常 |
| Using index condition | 索引下推 | 好 |
| Using filesort | 需要额外排序 | 需优化 |
| Using temporary | 需要临时表(常见于 GROUP BY) | 需优化 |
| Using MRR | 多范围读取优化 | 正常 |
| Using join buffer | JOIN 没有用到索引 | 需优化 |
二、慢查询日志
2.1 开启慢查询日志
sql
-- MySQL 命令行设置(当前生效)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1 秒
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录无索引查询
-- my.cnf 持久化配置
[mysqld]
slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
-- 查看配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
2.2 分析慢查询日志
sql
-- 查看慢查询日志数量
SHOW GLOBAL STATUS LIKE 'Slow_queries';
-- 使用 mysqldumpslow 分析工具
mysqldumpslow -t 10 /var/log/mysql/slow.log -- 最慢的 10 条
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log -- 统计次数最多的 10 条
-- 使用 pt-query-digest 工具(Percona Toolkit)
pt-query-digest /var/log/mysql/slow.log
-- 慢查询日志示例内容
# Time: 2024-06-15T10:30:00.123456Z
# Query_time: 2.345678 Lock_time: 0.001234 Rows_sent: 100 Rows_examined: 500000
SET timestamp = 1718443800;
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
2.3 慢查询分析思路
- 提取慢 SQL:从慢查询日志获取具体的慢 SQL
- EXPLAIN 分析:查看执行计划的 type、rows、Extra
- 定位问题:
ALL:全表扫描 → 检查是否缺索引Using filesort→ 索引无法支持排序Using temporary→ GROUP BY 或 DISTINCT 需优化rows很大 → 过滤性差或索引不合适
- 制定优化方案:加索引、改写 SQL、或业务层面优化
三、单表/多表 SQL 优化技巧
3.1 字段优化
sql
-- 1. 避免 SELECT *
-- 差:查询所有字段,可能用不到覆盖索引
SELECT * FROM employee WHERE dept_id = 1;
-- 好:只查需要的字段
SELECT id, name FROM employee WHERE dept_id = 1;
-- 2. 避免在 WHERE 中用函数
-- 差:DATE() 函数导致索引失效
SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';
-- 好:范围查询
SELECT * FROM orders
WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';
-- 3. 用 UNION ALL 替代 UNION(不需要去重时)
SELECT id FROM orders_2023
UNION ALL
SELECT id FROM orders_2024; -- 避免去重排序
3.2 查询条件优化
sql
-- 1. 用 IN 替代 OR(IN 会优化,OR 可能索引失效)
-- 差:
SELECT * FROM employee WHERE dept_id = 1 OR dept_id = 2;
-- 好:
SELECT * FROM employee WHERE dept_id IN (1, 2);
-- 2. 用 EXISTS 替代 IN(子表大时)
-- 查询有订单的用户
-- 差(orders 大时):
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
-- 好:
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- 3. 用 JOIN 替代子查询
-- 差:
SELECT * FROM employee
WHERE dept_id IN (SELECT id FROM department WHERE dept_name LIKE '技术%');
-- 好:
SELECT e.* FROM employee e
JOIN department d ON e.dept_id = d.id
WHERE d.dept_name LIKE '技术%';
3.3 JOIN 优化
sql
-- 1. 小表驱动大表
-- 如果 department 数据少,employee 数据多
-- MySQL 会自动选择小表作为驱动表
SELECT e.*, d.dept_name
FROM employee e -- 大表
JOIN department d ON e.dept_id = d.id; -- 小表
-- 2. JOIN 字段要有索引
-- e.dept_id 和 d.id 都要有索引
-- 3. 注意 JOIN 缓冲
-- 如果 JOIN 时 type 为 ALL 且 Extra 有 Using join buffer
-- 说明关联字段缺少索引
EXPLAIN SELECT * FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;
-- 检查 type 和 Extra
3.4 分页查询优化
sql
-- 传统分页(越往后越慢)
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 优化1:延迟关联(子查询优化)
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) tmp ON o.id = tmp.id;
-- 优化2:范围查询(适用于连续翻页)
SELECT * FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;
-- 优化3:记录上次位置(游标分页,推荐)
-- 第一页:
SELECT * FROM orders ORDER BY id LIMIT 20;
-- 后续页:传入上一页最后一条的 id
SELECT * FROM orders
WHERE id > 100020
ORDER BY id
LIMIT 20;
四、专项优化场景
4.1 JOIN 优化实战
sql
-- 问题 SQL:
SELECT u.name, o.order_no, oi.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
WHERE o.create_time >= '2024-01-01'
ORDER BY o.create_time DESC
LIMIT 20;
-- EXPLAIN 发现问题:
-- type: ALL(全表扫描)
-- rows: 100000+
-- Extra: Using temporary; Using filesort
-- 优化1:添加索引
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_order_id ON order_items(order_id);
CREATE INDEX idx_create_time ON orders(create_time);
-- 优化2:改写 SQL,先缩小数据范围
SELECT u.name, t.order_no, t.product_name
FROM users u
JOIN (
SELECT o.id, o.user_id, o.order_no, oi.product_name
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.create_time >= '2024-01-01'
ORDER BY o.create_time DESC
LIMIT 20
) t ON u.id = t.user_id;
4.2 子查询优化实战
sql
-- 问题 SQL:查询最近30天下单的用户及其最新订单
SELECT u.*,
(SELECT order_no FROM orders
WHERE user_id = u.id
ORDER BY create_time DESC LIMIT 1) AS last_order_no,
(SELECT amount FROM orders
WHERE user_id = u.id
ORDER BY create_time DESC LIMIT 1) AS last_amount
FROM users u
WHERE u.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY);
-- 优化:使用 JOIN + 派生表(避免相关子查询)
SELECT u.*, t.order_no AS last_order_no, t.amount AS last_amount
FROM users u
LEFT JOIN (
SELECT user_id, order_no, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn
FROM orders
) t ON u.id = t.user_id AND t.rn = 1
WHERE u.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY);
4.3 分页优化实战
sql
-- 场景:后台订单列表翻到第 1000 页
-- 每页 20 条,LIMIT 19980, 20
-- 优化前:
EXPLAIN SELECT * FROM orders ORDER BY create_time DESC LIMIT 19980, 20;
-- type: ALL, rows: 500000, Extra: Using filesort
-- 优化方案1:覆盖索引 + 延迟关联
EXPLAIN
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY create_time DESC
LIMIT 19980, 20
) tmp ON o.id = tmp.id;
-- 子查询走覆盖索引,回表只取 20 条
-- 优化方案2:游标分页(需前端配合)
SELECT * FROM orders
WHERE create_time < '2024-06-01 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
4.4 分组统计优化
sql
-- 问题 SQL:统计各部门工资分布
SELECT d.dept_name,
CASE
WHEN e.salary < 8000 THEN '低'
WHEN e.salary < 15000 THEN '中'
ELSE '高'
END AS salary_level,
COUNT(*) AS emp_count
FROM employee e
JOIN department d ON e.dept_id = d.id
GROUP BY d.dept_name, salary_level;
-- Extra: Using temporary; Using filesort
-- 优化:给 GROUP BY 字段建索引
CREATE INDEX idx_dept_salary ON employee(dept_id, salary);
-- 进一步优化:使用虚拟列(MySQL 5.7+)
ALTER TABLE employee ADD COLUMN salary_level VARCHAR(5)
GENERATED ALWAYS AS (
CASE
WHEN salary < 8000 THEN '低'
WHEN salary < 15000 THEN '中'
ELSE '高'
END
) STORED;
CREATE INDEX idx_dept_level ON employee(dept_id, salary_level);
-- 优化后查询
SELECT d.dept_name, e.salary_level, COUNT(*)
FROM employee e
JOIN department d ON e.dept_id = d.id
GROUP BY d.dept_name, e.salary_level;
五、实战案例:完整 SQL 优化流程
案例:优化一个慢查询
sql
-- 原始 SQL(来自慢查询日志,耗时 3.2 秒)
SELECT
u.name,
u.phone,
o.order_no,
o.amount,
oi.product_name,
oi.quantity
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
WHERE u.create_time >= '2024-01-01'
AND u.status = 1
AND o.status = 1
ORDER BY o.create_time DESC
LIMIT 20;
-- 步骤1:EXPLAIN 分析
EXPLAIN SELECT ...;
-- type: ALL, rows: 200000
-- Extra: Using where; Using temporary; Using filesort
-- JOIN 关联字段无索引
-- 步骤2:添加索引
CREATE INDEX idx_user_status ON users(status, create_time);
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_order_status ON orders(status, create_time);
CREATE INDEX idx_order_id ON order_items(order_id);
-- 步骤3:优化 SQL 写法
-- 优化:先缩小核心数据集再 JOIN
SELECT u.name, u.phone, t.*
FROM (
SELECT id, order_no, amount, create_time, user_id
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20
) o
JOIN users u ON o.user_id = u.id AND u.status = 1 AND u.create_time >= '2024-01-01'
JOIN order_items oi ON o.id = oi.order_id;
-- 步骤4:验证效果
EXPLAIN SELECT ...;
-- type: ref/range, rows: 20
-- Extra: Using index condition
-- 最终耗时:3.2s → 15ms
总结
| 优化方向 | 要点 |
|---|---|
| EXPLAIN | 重点关注 type、key、rows、Extra |
| 慢查询日志 | 定期分析、设置合理阈值(1~3秒) |
| 索引优化 | 覆盖索引、索引下推、避免索引失效 |
| 查询优化 | 减少 SELECT *、小表驱动大表、游标分页 |
| JOIN 优化 | 关联字段建索引、避免相关子查询 |
| 分页优化 | 延迟关联、游标分页替代 LIMIT 深度分页 |