一、SQL 调优是什么?为什么要学?
1.1 什么是 SQL 调优?
SQL 调优,简单说就是让你的 SQL 跑得更快。
同样一个业务需求,不同的 SQL 写法、不同的索引设计、不同的数据库配置,执行时间可能相差几十倍甚至上千倍。SQL 调优就是通过一系列方法,找到性能瓶颈并解决它。
举个真实例子:
优化前:SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';
→ 全表扫描 500 万行,耗时 8.2 秒
优化后:SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';
→ 索引范围扫描 326 行,耗时 0.003 秒
提升:2700 倍!
1.2 为什么要学 SQL 调优?
| 角度 | 原因 |
|---|---|
| 业务体验 | 用户等 5 秒加载一个页面,他就走了;优化到 0.1 秒,留存率翻倍 |
| 系统稳定性 | 一条慢 SQL 可能拖垮整个数据库,导致全站不可用 |
| 成本节约 | 优化 SQL 比加服务器便宜得多 |
| 面试加分 | SQL 调优是后端面试的高频考点,几乎每家都会问 |
| 职业成长 | 能做 SQL 调优的开发工程师,在团队中更有价值 |
1.3 SQL 调优的学习路线
很多人学 SQL 调优感觉无从下手,其实它有一个清晰的学习路径:
SQL 调优学习路线图
第一阶段:基础 第二阶段:进阶 第三阶段:高级
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. 理解索引原理 │ │ 5. 复合索引设计 │ │ 9. 分库分表 │
│ 2. 学会 EXPLAIN │ │ 6. 查询重写技巧 │ │ 10. 读写分离 │
│ 3. 慢查询日志 │ │ 7. JOIN 优化 │ │ 11. 缓存策略 │
│ 4. 基本索引优化 │ │ 8. 子查询优化 │ │ 12. 架构优化 │
└──────────────┘ └──────────────┘ └──────────────┘
↓ ↓ ↓
解决 80% 问题 解决 15% 问题 解决 5% 问题
本文将按照这个路线,从基础到实战,系统地介绍 SQL 调优。
二、调优前的准备:工具链搭建
"工欲善其事,必先利其器"
在开始调优之前,你需要掌握几个核心工具。它们是你的"诊断仪器"。
2.1 慢查询日志(Slow Query Log)
它是什么?
慢查询日志是 MySQL 自带的功能,会自动记录执行时间超过阈值的 SQL。它就像医院的体检报告,帮你找到"生病"的 SQL。
如何开启?
sql
-- 查看当前慢查询日志状态
SHOW VARIABLES LIKE 'slow_query%';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 设置慢查询阈值(单位:秒),超过 1 秒就记录
SET GLOBAL long_query_time = 1;
-- 查看慢查询日志文件位置
SHOW VARIABLES LIKE 'slow_query_log_file';
慢查询日志长什么样?
# Time: 2024-01-15T10:23:45.123456Z
# User@Host: app_user[app_user] @ [192.168.1.100]
# Query_time: 5.234123 Lock_time: 0.000234 Rows_sent: 1 Rows_examined: 4523678
SET timestamp=1705310625;
SELECT * FROM orders WHERE customer_name = '张三';
关键信息解读
| 字段 | 含义 | 本例中的值 |
|---|---|---|
Query_time |
SQL 执行耗时 | 5.23 秒(很慢!) |
Lock_time |
等待锁的时间 | 0.0002 秒(正常) |
Rows_sent |
返回给客户端的行数 | 1 行 |
Rows_examined |
扫描的总行数 | 452 万行(很多!) |
关键看点:返回 1 行却扫描了 452 万行,说明没有用到索引!
使用 mysqldumpslow 分析
当慢查询日志很大时,手动翻看不现实。MySQL 提供了 mysqldumpslow 工具来汇总分析:
bash
# 按执行次数排序,显示 Top 10
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按总耗时排序,显示 Top 10
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按平均耗时排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
推荐配置(生产环境)
ini
# my.cnf 配置文件
[mysqld]
slow_query_log = 1
long_query_time = 1
slow_query_log_file = /var/log/mysql/slow.log
log_queries_not_using_indexes = 1 # 记录没有使用索引的查询(非常有用!)
建议 :生产环境务必开启慢查询日志,阈值设为 1 秒。
log_queries_not_using_indexes建议也开启,它会记录所有没走索引的 SQL,即使执行时间不长。
2.2 EXPLAIN ------ 执行计划分析
EXPLAIN 是 SQL 调优最核心的工具,用来分析 SQL 的执行计划。
基本用法
sql
-- 在 SELECT 前加 EXPLAIN
EXPLAIN SELECT * FROM users WHERE id = 1;
需要重点关注的字段
| 字段 | 重要程度 | 含义 | 关注什么 |
|---|---|---|---|
type |
极高 | 访问类型 | 是否全表扫描?至少达到 range |
key |
极高 | 实际使用的索引 | 是否为 NULL(没用索引)? |
rows |
高 | 预估扫描行数 | 数值是否过大? |
Extra |
高 | 额外信息 | 是否有 Using filesort/temporary? |
possible_keys |
中 | 可能使用的索引 | 是否为 NULL? |
key_len |
中 | 使用索引的长度 | 复合索引用了几个字段? |
filtered |
低 | 过滤比例 | 值越大越好 |
EXPLAIN 的不同输出格式
sql
-- 默认表格格式
EXPLAIN SELECT * FROM users WHERE id = 1;
-- 垂直格式(字段多时更清晰)
EXPLAIN SELECT * FROM users WHERE id = 1\G
-- JSON 格式(信息最详细,包含成本估算)
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1;
-- 树形格式(MySQL 8.0.16+,直观展示执行顺序)
EXPLAIN FORMAT=TREE SELECT * FROM users WHERE id = 1;
-- EXPLAIN ANALYZE(MySQL 8.0.18+,实际执行并返回真实耗时)
EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;
推荐 :日常使用默认格式 +
\G,深入分析时用FORMAT=JSON,性能对比时用EXPLAIN ANALYZE。
详细内容:关于 EXPLAIN 的 type 字段详解,请参考《EXPLAIN-type类型详解-新手入门指南.md》。
2.3 SHOW PROFILE ------ 查看 SQL 执行各阶段耗时
它是什么?
SHOW PROFILE 可以查看一条 SQL 在执行过程中每个阶段的耗时,帮你定位到底是哪个环节慢。
如何使用?
sql
-- 开启 profiling(会话级别)
SET profiling = 1;
-- 执行你要分析的 SQL
SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 100;
-- 查看最近执行的 SQL 列表
SHOW PROFILES;
+----------+------------+--------------------------------------------------------------------+
| Query_ID | Duration | Query |
+----------+------------+--------------------------------------------------------------------+
| 1 | 0.00234500 | SELECT * FROM orders WHERE status = 'PAID' ORDER BY ... |
+----------+------------+--------------------------------------------------------------------+
sql
-- 查看某条 SQL 的详细耗时(这里分析 Query_ID = 1)
SHOW PROFILE FOR QUERY 1;
+----------------------+----------+
| Status | Duration |
+----------------------+----------+
| starting | 0.000052 |
| checking permissions | 0.000007 |
| Opening tables | 0.000018 |
| init | 0.000025 |
| System lock | 0.000008 |
| optimizing | 0.000012 |
| statistics | 0.000089 |
| preparing | 0.000015 |
| Sorting result | 0.001823 | ← 排序耗时最长!
| executing | 0.000003 |
| Sending data | 0.000245 |
| end | 0.000004 |
| query end | 0.000006 |
| closing tables | 0.000008 |
| freeing items | 0.000028 |
| cleaning up | 0.000002 |
+----------------------+----------+
如何解读?
看哪个阶段耗时最长:
Sorting result耗时长 → 排序慢,考虑加排序索引Sending data耗时长 → 数据量大,考虑减少返回字段Creating tmp table耗时长 → 用了临时表,考虑优化 SQL
注意 :MySQL 8.0.18+ 推荐使用
EXPLAIN ANALYZE代替SHOW PROFILE,但后者在旧版本中仍然非常有用。
2.4 performance_schema ------ 性能监控(进阶)
performance_schema 是 MySQL 内置的性能监控数据库,可以统计各种性能指标。
查看执行次数最多的 SQL
sql
SELECT
DIGEST_TEXT AS sql_text,
COUNT_STAR AS exec_count,
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
ROUND(SUM_TIMER_WAIT / 1000000000, 2) AS total_ms,
SUM_ROWS_EXAMINED AS rows_examined
FROM performance_schema.events_statements_summary_by_digest
ORDER BY COUNT_STAR DESC
LIMIT 10;
查看耗时最长的 SQL
sql
SELECT
DIGEST_TEXT AS sql_text,
COUNT_STAR AS exec_count,
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
ROUND(MAX_TIMER_WAIT / 1000000000, 2) AS max_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;
2.5 工具链总结
| 工具 | 用途 | 使用时机 |
|---|---|---|
| 慢查询日志 | 找到慢 SQL | 第一步:发现问题 |
| EXPLAIN | 分析执行计划 | 第二步:定位原因 |
| SHOW PROFILE | 查看各阶段耗时 | 第三步:精确定位 |
| performance_schema | 全局性能监控 | 持续监控、趋势分析 |
三、索引优化 ------ SQL 调优的核心
索引优化能解决 80% 以上的 SQL 性能问题
3.1 索引是什么?------ 一个通俗的解释
没有索引的查找
想象你有一本 500 页的书,要找"B+树"这个词:
- 没有目录:从第 1 页翻到第 500 页,逐页查找 → 全表扫描
- 有目录:翻到目录,找到"B+树"在第 237 页,直接翻过去 → 索引查找
索引就是数据库的"目录"。
MySQL 索引的数据结构
MySQL 最常用的索引结构是 B+ 树(InnoDB 默认)。
B+ 树结构示意图
┌─────────┐
│ 50,100 │ ← 根节点
└────┬────┘
┌───────────┼───────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌─────────┐
│ 10, 30 │ │ 60, 80 │ │120, 150 │ ← 中间节点
└───┬────┘ └────┬────┘ └────┬────┘
▼ ▼ ▼
┌──┬──┬──┐ ┌──┬──┬──┐ ┌──┬──┬──┐
│10│20│30│→ │50│60│70│→ │100│120│150│ ← 叶子节点(存储数据)
└──┴──┴──┘ └──┴──┴──┘ └──┴──┴──┘
B+ 树的特点:
- 所有数据都在叶子节点:叶子节点之间用链表相连,方便范围查询
- 树的高度很低:一般 3-4 层就能存储上千万条数据
- 查找效率稳定:无论查哪条数据,都是从根到叶,路径长度一样
为什么索引能加速查询?
假设一张表有 1000 万行数据,B+ 树高度为 3:
- 没有索引:最坏情况扫描 1000 万行
- 有索引:只需要 3 次磁盘 IO(根 → 中间 → 叶子),直接定位到数据
从 1000 万次 → 3 次,这就是索引的威力!
3.2 索引类型一览
MySQL 支持多种索引类型,每种有不同的用途:
| 索引类型 | 关键字 | 特点 | 适用场景 |
|---|---|---|---|
| 主键索引 | PRIMARY KEY |
唯一 + 非空,每表只能有一个 | 行的唯一标识(如 id) |
| 唯一索引 | UNIQUE |
值唯一,允许 NULL | 不能重复的字段(如 email) |
| 普通索引 | INDEX |
最基本的索引 | 常用查询条件字段 |
| 复合索引 | INDEX(a,b,c) |
多个字段组合 | 多条件查询 |
| 前缀索引 | INDEX(col(10)) |
只索引字段前 N 个字符 | 长字符串字段 |
| 全文索引 | FULLTEXT |
全文搜索 | 文章内容等大文本 |
| 覆盖索引 | --- | 查询字段全在索引中 | 避免回表 |
3.3 什么时候该加索引?
应该加索引的字段
| 场景 | 示例 | 原因 |
|---|---|---|
| WHERE 条件字段 | WHERE status = 'ACTIVE' |
加速过滤 |
| JOIN 关联字段 | ON a.user_id = b.id |
加速关联 |
| ORDER BY 排序字段 | ORDER BY create_time DESC |
避免 filesort |
| GROUP BY 分组字段 | GROUP BY category |
避免临时表 |
| DISTINCT 去重字段 | SELECT DISTINCT city |
加速去重 |
不应该加索引的字段
| 场景 | 原因 |
|---|---|
| 数据量很小的表(几百行) | 全表扫描可能更快 |
| 频繁更新的字段 | 每次更新都要维护索引 |
| 区分度很低的字段(如性别) | 索引效果差 |
| 很少用于查询条件的字段 | 浪费存储空间 |
| TEXT / BLOB 大字段 | 索引太大,考虑前缀索引 |
如何判断"区分度"?
区分度 = 不同值的数量 / 总行数。区分度越高,索引效果越好。
sql
-- 查看各字段的区分度
SELECT
COUNT(DISTINCT id) / COUNT(*) AS id_selectivity, -- 1.0(最高)
COUNT(DISTINCT email) / COUNT(*) AS email_selectivity, -- 接近 1.0
COUNT(DISTINCT city) / COUNT(*) AS city_selectivity, -- 0.01(很低)
COUNT(DISTINCT gender) / COUNT(*) AS gender_selectivity -- 0.0000x(极低)
FROM users;
经验法则:区分度低于 0.1 的字段,索引效果通常不好。
3.4 复合索引设计 ------ 最左前缀原则
什么是复合索引?
复合索引是多个字段组合成一个索引:
sql
-- 创建复合索引
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
这个索引相当于建了一个"先按 status 排序,status 相同再按 create_time 排序"的目录。
最左前缀原则
复合索引 (a, b, c) 的使用规则:
| 查询条件 | 是否走索引 | 使用了哪些列 |
|---|---|---|
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 a = 1 AND c = 3 |
部分 | a(跳过了 b,c 无法使用) |
WHERE a = 1 AND b > 2 AND c = 3 |
部分 | a, b(b 是范围查询,c 无法使用) |
通俗理解最左前缀
把复合索引想象成一个多级目录:
电话簿索引 (城市, 区, 街道)
北京 → 朝阳区 → 建国路
→ 望京
→ 海淀区 → 中关村
-- 好:status 等值在前,create_time 范围在后
INDEX (status, create_time)
WHERE status = 'PAID' AND create_time > '2024-01-01'
-- 差:范围条件在前,后面的字段无法使用索引
INDEX (create_time, status)
WHERE create_time > '2024-01-01' AND status = 'PAID'
-
高区分度字段放前面
sql-- 好:user_id 区分度高 INDEX (user_id, status) -- 差:status 区分度低(只有几个值) INDEX (status, user_id) -
尽量覆盖查询字段(覆盖索引)
sql-- 如果经常执行这个查询: SELECT user_id, status, amount FROM orders WHERE status = 'PAID'; -- 创建覆盖索引,避免回表 INDEX (status, user_id, amount)
3.5 索引失效的常见场景(必背!)
即使创建了索引,以下写法会导致索引失效:
场景 1:对索引列使用函数
sql
-- [X] 索引失效:对 create_time 使用了 DATE() 函数
SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';
-- [O] 索引生效:改为范围查询
SELECT * FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
原因 :索引是按 create_time 原始值排序的,用函数后 MySQL 无法利用排序。
场景 2:隐式类型转换
sql
-- [X] 索引失效:phone 是 VARCHAR,传入数字
SELECT * FROM users WHERE phone = 13800138000;
-- [O] 索引生效:传入字符串
SELECT * FROM users WHERE phone = '13800138000';
原因 :MySQL 会对 phone 列做隐式转换(相当于 CAST(phone AS SIGNED)),导致索引失效。
场景 3:LIKE 左模糊
sql
-- [X] 索引失效
SELECT * FROM users WHERE name LIKE '%张';
SELECT * FROM users WHERE name LIKE '%张%';
-- [O] 索引生效
SELECT * FROM users WHERE name LIKE '张%';
原因:索引是有序的,"以张开头"可以快速定位,"包含张"无法定位。
场景 4:OR 条件部分无索引
sql
-- [X] 如果 age 没有索引,整个查询不走索引
SELECT * FROM users WHERE name = '张三' OR age = 25;
-- [O] 方案 1:给 age 也加索引
ALTER TABLE users ADD INDEX idx_age (age);
-- [O] 方案 2:改为 UNION
SELECT * FROM users WHERE name = '张三'
UNION ALL
SELECT * FROM users WHERE age = 25 AND name != '张三';
场景 5:NOT IN / NOT EXISTS
sql
-- [X] 通常不走索引
SELECT * FROM users WHERE id NOT IN (1, 2, 3);
-- [O] 考虑改写
SELECT * FROM users WHERE id > 3; -- 如果逻辑允许
场景 6:索引列参与运算
sql
-- [X] 索引失效:对 age 进行了运算
SELECT * FROM users WHERE age + 1 = 26;
-- [O] 索引生效:把运算移到右边
SELECT * FROM users WHERE age = 25;
索引失效速记口诀
函数计算索引废, -- 不要对索引列用函数
类型转换索引碎, -- 注意数据类型匹配
模糊左匹配索引退, -- LIKE 不要以 % 开头
OR 一个没有全白费, -- OR 的每个条件都要有索引
NOT IN 效果不太对, -- 尽量用正向条件
列上运算索引毁。 -- 不要在索引列上做运算
四、SQL 写法优化
除了索引之外,SQL 本身的写法也会影响性能。
4.1 避免 SELECT *
sql
-- [X] 差:查出所有字段
SELECT * FROM orders WHERE status = 'PAID';
-- [O] 好:只查需要的字段
SELECT id, order_no, amount FROM orders WHERE status = 'PAID';
为什么 SELECT * 不好?
| 问题 | 说明 |
|---|---|
| 浪费网络带宽 | 传输了不需要的数据 |
| 浪费内存 | 应用程序要存储多余的字段 |
| 无法使用覆盖索引 | 必须回表查完整数据 |
| 可能引发隐式转换 | 当表结构变更时 |
4.2 小表驱动大表
在 JOIN 查询中,让数据量小的表作为驱动表:
sql
-- 假设 departments 有 10 行,employees 有 10 万行
-- [O] 好:小表驱动大表
SELECT * FROM departments d
JOIN employees e ON d.id = e.dept_id;
-- 遍历 10 行 departments,每行去 employees 查(走索引)
-- 总共:10 次索引查找
-- [X] 差:大表驱动小表
SELECT * FROM employees e
JOIN departments d ON e.dept_id = d.id;
-- 遍历 10 万行 employees,每行去 departments 查
-- 总共:10 万次索引查找
注意 :MySQL 优化器通常会自动选择小表作为驱动表。但在复杂查询中,有时需要手动调整。可以用
STRAIGHT_JOIN强制指定驱动表顺序。
4.3 用 EXISTS 代替 IN(大数据量时)
sql
-- 当子查询结果集很大时
-- [X] IN:先执行子查询,结果集可能很大
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders);
-- [O] EXISTS:外层每行只需判断"是否存在"
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
什么时候用 IN,什么时候用 EXISTS?
| 场景 | 建议 |
|---|---|
| 子查询结果集小 | 用 IN |
| 子查询结果集大,外层表小 | 用 EXISTS |
| 两个表数据量差不多 | 都可以,测试决定 |
现代 MySQL 提示:MySQL 8.0 的优化器已经很智能了,很多时候会自动将 IN 改写为半连接(Semi-Join),性能差异可能不大。但了解原理仍然很重要。
4.4 LIMIT 优化深度分页
问题:深度分页性能差
sql
-- 查第 1 页,很快
SELECT * FROM orders ORDER BY id LIMIT 0, 10; -- 0.001 秒
-- 查第 10000 页,很慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 10; -- 2.5 秒
为什么慢? LIMIT 100000, 10 实际上是:先读取 100010 行,丢弃前 100000 行,返回最后 10 行。读了 10 万行只用了 10 行,太浪费!
优化方案 1:基于游标(推荐)
sql
-- 记住上一页最后一条的 id,下一页从这个 id 开始
-- 假设上一页最后一条 id = 100000
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
这种方式不管翻到第几页,速度都一样快 !因为 WHERE id > 100000 可以直接走索引定位。
优化方案 2:延迟关联
sql
-- 先用覆盖索引找到 id,再回表查详情
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 10
) AS tmp ON o.id = tmp.id;
子查询 SELECT id 走覆盖索引,速度快;外层只回表 10 行。
优化方案 3:业务限制
很多时候,用户根本不会翻到第 1 万页。可以:
- 限制最多翻 100 页
- 使用"加载更多"代替传统分页
- 提供搜索功能代替翻页
4.5 避免使用临时表和文件排序
当 EXPLAIN 的 Extra 字段出现以下值时,说明性能可能有问题:
| Extra 值 | 含义 | 优化方向 |
|---|---|---|
Using filesort |
MySQL 需要额外的排序操作 | 给 ORDER BY 字段加索引 |
Using temporary |
MySQL 使用了临时表 | 优化 GROUP BY、DISTINCT |
Using filesort + Using temporary |
既有临时表又有排序 | 重点优化! |
示例:消除 Using filesort
sql
-- [X] 慢:Extra 出现 Using filesort
SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time DESC;
-- 如果只有 idx_status 索引
-- [O] 快:创建复合索引消除排序
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
-- 索引已经按 status + create_time 排序,无需额外排序
五、JOIN 优化
5.1 理解 MySQL 的 JOIN 算法
MySQL 主要使用以下 JOIN 算法:
Nested Loop Join(嵌套循环连接)
for each row in 驱动表:
for each row in 被驱动表:
if 满足关联条件:
输出结果
这是最基本的算法。如果被驱动表的关联字段有索引,内层循环就变成索引查找,效率很高。
Block Nested Loop Join(块嵌套循环)
当被驱动表没有索引时,MySQL 会使用 Join Buffer:
把驱动表的数据读入 Join Buffer
for each row in 被驱动表:
与 Join Buffer 中的所有行比较
if 满足关联条件:
输出结果
MySQL 8.0.18+ 引入了 Hash Join,对于无索引的等值 JOIN,性能大幅提升。
5.2 JOIN 优化要点
要点 1:关联字段必须有索引
sql
-- [X] 差:user_id 没有索引
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- orders.user_id 没索引 → 被驱动表全表扫描
-- [O] 好:确保关联字段有索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
要点 2:关联字段类型必须一致
sql
-- [X] 差:a.user_id 是 INT,b.user_id 是 VARCHAR
-- 隐式类型转换导致索引失效!
-- [O] 好:两边类型、字符集、排序规则都一致
要点 3:控制 JOIN 表的数量
sql
-- [X] 差:JOIN 太多表
SELECT * FROM a
JOIN b ON ... JOIN c ON ... JOIN d ON ... JOIN e ON ... JOIN f ON ...;
-- [O] 好:一般不超过 3-4 张表
-- 如果确实需要多表关联,考虑分步查询或冗余设计
经验法则 :JOIN 的表数量不要超过 4 张。超过时考虑:分步查询、业务层组装、适当的数据冗余。
要点 4:利用覆盖索引减少回表
sql
-- 如果只需要用户名和订单金额
SELECT u.username, o.amount
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID';
-- 给 orders 创建覆盖索引
ALTER TABLE orders ADD INDEX idx_status_userid_amount (status, user_id, amount);
-- 查询 orders 时不需要回表,直接从索引中获取 user_id 和 amount
六、子查询优化
6.1 子查询的性能问题
子查询在某些情况下性能较差,尤其是相关子查询:
sql
-- [X] 相关子查询:对外层每一行都执行一次子查询
SELECT *,
(SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) AS order_count
FROM users;
-- 如果 users 有 10000 行,子查询执行 10000 次!
6.2 子查询改写为 JOIN
sql
-- [O] 改写为 JOIN + GROUP BY
SELECT u.*, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
-- 只需一次 JOIN 操作
6.3 常见子查询改写模式
模式 1:IN 子查询 → JOIN
sql
-- [X] IN 子查询
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE status = 'PAID');
-- [O] JOIN
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'PAID';
模式 2:标量子查询 → JOIN
sql
-- [X] 标量子查询(获取每个用户的最新订单时间)
SELECT *,
(SELECT MAX(create_time) FROM orders WHERE user_id = users.id) AS last_order_time
FROM users;
-- [O] JOIN
SELECT u.*, t.last_order_time
FROM users u
LEFT JOIN (
SELECT user_id, MAX(create_time) AS last_order_time
FROM orders
GROUP BY user_id
) t ON u.id = t.user_id;
模式 3:NOT IN → LEFT JOIN + IS NULL
sql
-- [X] NOT IN(有 NULL 陷阱)
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- [O] LEFT JOIN + IS NULL
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
NOT IN 的 NULL 陷阱 :如果 orders.user_id 中有 NULL 值,
NOT IN会返回空结果集!这是一个非常隐蔽的 bug。用LEFT JOIN + IS NULL可以避免。
七、其他调优技巧
7.1 批量操作代替循环
sql
-- [X] 差:循环逐条插入(10000 条数据需要 10000 次网络往返)
INSERT INTO users (name, age) VALUES ('张三', 25);
INSERT INTO users (name, age) VALUES ('李四', 30);
...(重复 10000 次)
-- [O] 好:批量插入(1 次网络往返)
INSERT INTO users (name, age) VALUES
('张三', 25), ('李四', 30), ('王五', 28), ... ;
-- 注意:每批建议 500-1000 条,不要一次插入太多
7.2 合理使用 COUNT
sql
-- 只需要判断是否存在,不要用 COUNT
-- [X] 差
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 假设结果是 523
if (count > 0) { ... }
-- [O] 好
SELECT 1 FROM orders WHERE user_id = 1 LIMIT 1; -- 找到 1 条就返回
7.3 UPDATE 和 DELETE 的优化
sql
-- [X] 差:一次删除大量数据(可能锁表很久)
DELETE FROM logs WHERE create_time < '2023-01-01';
-- [O] 好:分批删除
DELETE FROM logs WHERE create_time < '2023-01-01' LIMIT 1000;
-- 用循环执行,直到删除完毕
-- 每次删 1000 条,减少锁持有时间
7.4 合理使用数据类型
| 建议 | 原因 |
|---|---|
| 用 INT 不用 VARCHAR 存 ID | INT 比较更快,索引更小 |
| 用 TINYINT 不用 INT 存状态值 | 节省存储空间 |
| 用 DECIMAL 不用 FLOAT 存金额 | FLOAT 有精度问题 |
| 用 DATETIME 不用 VARCHAR 存时间 | 支持时间函数和比较 |
| 字符串长度够用就好 | VARCHAR(50) 比 VARCHAR(500) 索引更小 |
7.5 适当的反范式设计
有时为了查询性能,可以适当冗余数据:
sql
-- 范式设计(需要 JOIN)
-- orders 表只存 user_id
-- 查询时:SELECT o.*, u.username FROM orders o JOIN users u ON ...
-- 反范式设计(避免 JOIN)
-- orders 表冗余存储 username
-- 查询时:SELECT * FROM orders WHERE ... (不需要 JOIN)
适用场景:
- 冗余字段很少变更(如用户名、商品名)
- 读多写少的业务
- JOIN 性能已经成为瓶颈
风险:
- 数据一致性维护成本增加
- 更新时需要同步多处
八、调优实战流程
当你遇到一个慢 SQL,按以下流程操作:
8.1 标准化调优流程图
SQL 调优标准流程
┌──────────────────────────────────┐
│ 第一步:发现慢 SQL │
│ → 慢查询日志 / 监控告警 │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ 第二步:分析执行计划 │
│ → EXPLAIN 看 type、key、rows │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ 第三步:定位问题 │
│ → 没索引?索引失效?SQL 写法差? │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ 第四步:制定优化方案 │
│ → 加索引?改 SQL?改架构? │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ 第五步:验证优化效果 │
│ → 再次 EXPLAIN + 实际执行对比 │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ 第六步:上线观察 │
│ → 监控慢查询、CPU、IO 指标 │
└──────────────────────────────────┘
8.2 完整实战案例
场景
电商系统告警:订单列表页接口响应时间从 200ms 飙升到 5 秒。
第一步:找到慢 SQL
查看慢查询日志:
# Query_time: 4.823456 Rows_examined: 2345678
SELECT o.*, u.username, u.phone
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.tenant_id = 1001
AND o.status IN ('PAID', 'SHIPPED')
AND o.create_time >= '2024-01-01'
ORDER BY o.create_time DESC
LIMIT 0, 20;
第二步:EXPLAIN 分析
sql
EXPLAIN SELECT o.*, u.username, u.phone ...;
+----+-------+--------+------+---------------+------+---------+------+---------+-----------------------------+
| id | table | type | key | key_len | ref | rows | Extra |
+----+-------+--------+------+---------------+------+---------+------+---------+-----------------------------+
| 1 | o | ALL | NULL | NULL | NULL | 2345678 | Using where; Using filesort |
| 1 | u | eq_ref | PRIMARY | 4 | o.user_id | 1 | NULL |
+----+-------+--------+------+---------------+------+---------+------+---------+-----------------------------+
第三步:定位问题
| 问题 | 分析 |
|---|---|
type = ALL |
orders 表全表扫描 234 万行! |
key = NULL |
没有使用任何索引 |
Using filesort |
需要额外排序(没有排序索引) |
第四步:制定优化方案
分析 WHERE 条件:
tenant_id = 1001(等值)status IN ('PAID', 'SHIPPED')(等值 / 集合)create_time >= '2024-01-01'(范围)ORDER BY create_time DESC(排序)
根据复合索引设计原则(等值在前、范围在后):
sql
-- 创建复合索引
ALTER TABLE orders ADD INDEX idx_tenant_status_time (tenant_id, status, create_time);
第五步:验证优化效果
+----+-------+--------+----------------------------+---------+-------+------+----------------------------+
| id | table | type | key | key_len | ref | rows | Extra |
+----+-------+--------+----------------------------+---------+-------+------+----------------------------+
| 1 | o | range | idx_tenant_status_time | 170 | NULL | 56 | Using index condition |
| 1 | u | eq_ref | PRIMARY | 4 | ... | 1 | NULL |
+----+-------+--------+----------------------------+---------+-------+------+----------------------------+
优化效果对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| type | ALL | range | 大幅提升 |
| rows | 2,345,678 | 56 | 减少 99.99% |
| Extra | Using filesort | Using index condition | 消除排序 |
| 实际耗时 | 4.82 秒 | 0.003 秒 | 提升 1600 倍 |
第六步:上线观察
上线后通过监控确认:
- 慢查询告警消失
- 接口响应时间回到 200ms 以内
- 数据库 CPU 从 80% 降到 20%
九、调优学习路线与资源推荐
9.1 学习路线建议
第一阶段:基础入门(1-2 周)
| 学习内容 | 目标 | 推荐方式 |
|---|---|---|
| 索引原理 | 理解 B+ 树、聚簇索引、回表 | 看博客 + 画图理解 |
| EXPLAIN | 看懂执行计划的每个字段 | 在本地数据库实操 |
| 慢查询日志 | 会开启、会分析 | 在测试环境配置 |
| 基本索引优化 | 知道什么时候该加索引 | 结合项目实践 |
第二阶段:进阶提高(2-4 周)
| 学习内容 | 目标 | 推荐方式 |
|---|---|---|
| 复合索引设计 | 掌握最左前缀原则 | 做练习题 |
| 索引失效场景 | 能识别并修复 | 整理案例库 |
| SQL 改写技巧 | 掌握常见优化模式 | 优化项目中的 SQL |
| JOIN 优化 | 理解 NLJ 算法、驱动表选择 | 看文档 + 实操 |
第三阶段:高级进阶(持续学习)
| 学习内容 | 目标 | 推荐方式 |
|---|---|---|
| MySQL 架构原理 | 理解查询执行流程 | 看书 |
| 锁和事务 | 理解死锁、行锁、间隙锁 | 看书 + 实验 |
| 分库分表 | 掌握水平拆分策略 | 了解 ShardingSphere |
| 读写分离 | 主从复制原理 | 看文档 |
9.2 推荐学习资源
书籍
| 书名 | 难度 | 推荐理由 |
|---|---|---|
| 《MySQL 是怎样运行的》 | 初级 | 新手友好,图文并茂 |
| 《高性能 MySQL(第4版)》 | 中高级 | 经典,内容全面 |
| 《MySQL 技术内幕:InnoDB 存储引擎》 | 高级 | 深入原理,进阶必读 |
在线资源
| 资源 | 说明 |
|---|---|
| MySQL 官方文档 | 最权威的参考资料 |
| 掘金 / 博客园 | 大量实战优化案例 |
| 极客时间《MySQL 实战 45 讲》 | 丁奇老师的课程,质量极高 |
实践建议
- 用真实项目练手:找出项目中的慢 SQL,尝试优化
- 建立自己的案例库:每次优化都记录下来,积累经验
- 使用 mysqlslap 压测:优化前后跑压测对比数据
- 参与 Code Review:关注同事的 SQL 写法,互相学习
9.3 面试中的 SQL 调优问题
高频面试题
| 问题 | 考察点 |
|---|---|
| "说说你做过的 SQL 优化案例" | 实战经验 |
| "EXPLAIN 的 type 有哪些值?" | 基础知识 |
| "什么情况下索引会失效?" | 索引原理 |
| "复合索引的最左前缀原则是什么?" | 索引设计 |
| "如何优化深度分页?" | 实战技巧 |
| "大表 JOIN 怎么优化?" | JOIN 知识 |
回答模板
面试时回答 SQL 优化问题,可以用这个模板:
1. 发现问题:怎么发现这条 SQL 慢的?(慢查询日志/监控告警)
2. 分析问题:用 EXPLAIN 看到了什么?(type=ALL,没走索引等)
3. 定位原因:为什么慢?(缺少索引/索引失效/SQL 写法差)
4. 解决方案:做了什么优化?(加索引/改 SQL/改架构)
5. 效果数据:优化后提升了多少?(耗时从 5 秒降到 0.01 秒)
十、总结
10.1 SQL 调优核心方法论
┌────────────────────────────────────────────────────────────┐
│ SQL 调优方法论 │
├────────────────────────────────────────────────────────────┤
│ │
│ 1. 开启慢查询日志,找到问题 SQL │
│ ↓ │
│ 2. EXPLAIN 分析执行计划 │
│ ↓ │
│ 3. 优先考虑索引优化(解决 80% 问题) │
│ - 给 WHERE/JOIN/ORDER BY 字段加索引 │
│ - 检查索引失效场景 │
│ - 设计合理的复合索引 │
│ ↓ │
│ 4. 优化 SQL 写法(解决 15% 问题) │
│ - 避免 SELECT * │
│ - 子查询改 JOIN │
│ - 优化分页查询 │
│ ↓ │
│ 5. 架构优化(解决 5% 问题) │
│ - 读写分离、分库分表、缓存 │
│ │
└────────────────────────────────────────────────────────────┘
10.2 调优检查清单
每次遇到慢 SQL,对着这个清单逐条检查:
□ 是否开启了慢查询日志?
□ EXPLAIN 的 type 是什么?是否为 ALL 或 index?
□ WHERE 条件字段是否有索引?
□ 是否对索引列使用了函数?
□ 是否存在隐式类型转换?
□ LIKE 是否使用了左模糊?
□ JOIN 的关联字段是否有索引?类型是否一致?
□ 是否可以用覆盖索引避免回表?
□ 复合索引的顺序是否合理(等值在前、范围在后)?
□ ORDER BY 字段是否有索引?
□ 是否有不必要的 SELECT *?
□ 深度分页是否可以用游标优化?
□ 子查询是否可以改为 JOIN?
□ 批量操作是否可以代替循环?
□ Extra 是否有 Using filesort 或 Using temporary?
10.3 送给你的一句话
SQL 调优不是玄学,而是一套可学习、可复制的方法论。
你不需要一开始就精通所有内容。从"学会看 EXPLAIN"开始,每次遇到慢 SQL 就用文中的方法分析一下,积累几十个案例后,你就是团队里的 SQL 调优专家了。
记住:
- 80% 的问题靠索引优化就能解决
- EXPLAIN 是你最好的朋友
- 实战 > 理论,多在真实项目中练手
加油!