SQL 调优完全指南 —— 从入门到实战

一、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+ 树的特点

  1. 所有数据都在叶子节点:叶子节点之间用链表相连,方便范围查询
  2. 树的高度很低:一般 3-4 层就能存储上千万条数据
  3. 查找效率稳定:无论查哪条数据,都是从根到叶,路径长度一样
为什么索引能加速查询?

假设一张表有 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'
  1. 高区分度字段放前面

    sql 复制代码
    -- 好:user_id 区分度高
    INDEX (user_id, status)
    
    -- 差:status 区分度低(只有几个值)
    INDEX (status, user_id)
  2. 尽量覆盖查询字段(覆盖索引)

    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 讲》 丁奇老师的课程,质量极高
实践建议
  1. 用真实项目练手:找出项目中的慢 SQL,尝试优化
  2. 建立自己的案例库:每次优化都记录下来,积累经验
  3. 使用 mysqlslap 压测:优化前后跑压测对比数据
  4. 参与 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 是你最好的朋友
  • 实战 > 理论,多在真实项目中练手

加油!

相关推荐
努力成为DBA的小王1 小时前
MySQL数据类型
数据库·mysql
日取其半万世不竭1 小时前
Supabase 自建:开源的 Firebase 替代品,带数据库的后端服务
数据库·开源
2301_803934611 小时前
html标签怎样划分页面区域_section与div的区别【介绍】
jvm·数据库·python
埃伊蟹黄面1 小时前
MySQL 库的操作
数据库·mysql
埃伊蟹黄面1 小时前
数据库基础认识
数据库
看我干嘛!1 小时前
Redis安装
数据库·redis·缓存
2401_824697662 小时前
如何管理Oracle服务器的内核共享内存_shmmax与shmall计算
jvm·数据库·python
2301_783848652 小时前
mysql数据迁移过程如何降低性能影响_采用增量备份与多线程同步
jvm·数据库·python
【心态好不摆烂】2 小时前
MySQL——表的约束(上)
数据库·mysql