"为什么我的查询跑了3分钟还在转圈圈?!" 😭
📖 什么是慢查询?
想象你去餐厅点餐:
- 快查询:点了份炒饭,3分钟上桌 ✅ 😋
- 慢查询:点了份炒饭,等了3小时还没上... ❌ 😡
数据库慢查询就是这样,一条 SQL 执行时间超出预期(通常 > 1秒),导致用户体验变差,甚至拖垮整个系统!
慢查询的危害 💀
sql
用户视角:
点击按钮 → ⏳转圈圈 → 😤等待 → 🤬超时 → 💔关闭页面
系统视角:
慢SQL → 连接占用 → 连接池耗尽 → 新请求无法处理 → 雪崩 💥
🎯 第一步:如何发现慢查询?
方法 1:MySQL 慢查询日志 📝
开启慢查询日志:
sql
-- 查看是否开启
SHOW VARIABLES LIKE 'slow_query%';
-- 开启慢查询日志
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';
查看慢查询日志:
bash
# 直接查看文件
tail -f /var/log/mysql/slow.log
# 使用 mysqldumpslow 分析(MySQL自带工具)
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# -s t:按查询时间排序
# -t 10:显示前10条
日志格式:
sql
# Time: 2024-10-24T10:30:45.123456Z
# User@Host: root[root] @ localhost []
# Query_time: 3.521045 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 1000000
SELECT * FROM users WHERE name LIKE '%张%';
解读:
Query_time:查询总耗时(3.52秒,太慢了!)Lock_time:等待锁的时间Rows_sent:返回的行数(只返回1行)Rows_examined:扫描的行数(扫了100万行!)
关键指标 :如果 Rows_examined >> Rows_sent,说明查询效率很低!
方法 2:使用监控工具 📊
Prometheus + Grafana
yaml
# 监控慢查询指标
- name: mysql_slow_queries
query: rate(mysql_global_status_slow_queries[5m])
alert: slow_queries_high
threshold: > 10 # 5分钟内慢查询超过10次就告警
阿里云 RDS / 腾讯云 MySQL
- 控制台自带慢查询分析
- 可视化图表
- 一键优化建议
🔍 第二步:EXPLAIN - SQL 的"体检报告"
EXPLAIN 是 MySQL 提供的执行计划查看工具,相当于给 SQL 做了个全身检查。
基本用法
sql
EXPLAIN SELECT * FROM users WHERE age = 25;
EXPLAIN 输出字段详解 🎓
| 字段 | 含义 | 重点关注 |
|---|---|---|
| id | 查询序号 | 数字越大越先执行 |
| select_type | 查询类型 | SIMPLE、SUBQUERY、UNION |
| table | 访问的表名 | 哪张表 |
| type ⭐ | 访问类型 | 性能从好到坏 |
| possible_keys | 可能使用的索引 | 候选索引 |
| key ⭐ | 实际使用的索引 | NULL=没用索引 |
| key_len | 索引长度 | 越短越好 |
| ref | 索引的哪一列被使用 | const、字段名 |
| rows ⭐ | 预估扫描行数 | 越少越好 |
| filtered | 过滤百分比 | 越高越好 |
| Extra ⭐ | 额外信息 | 重要优化线索 |
⭐ 最关键:type 字段(性能排行榜)
sql
从最快到最慢:
🚀 system > const > eq_ref > ref > range > index > ALL 🐌
┌─────────┬──────────────────────────────┬────────┐
│ type │ 说明 │ 性能 │
├─────────┼──────────────────────────────┼────────┤
│ system │ 表只有一行(系统表) │ ⭐⭐⭐⭐⭐ │
│ const │ 主键或唯一索引等值查询 │ ⭐⭐⭐⭐⭐ │
│ eq_ref │ 唯一索引扫描(JOIN) │ ⭐⭐⭐⭐ │
│ ref │ 非唯一索引扫描 │ ⭐⭐⭐ │
│ range │ 索引范围扫描(>、<、BETWEEN) │ ⭐⭐ │
│ index │ 全索引扫描 │ ⭐ │
│ ALL │ 全表扫描(最慢!) │ 💀 │
└─────────┴──────────────────────────────┴────────┘
示例对比
sql
-- ❌ type = ALL(全表扫描,太慢!)
EXPLAIN SELECT * FROM users WHERE age = 25;
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
| 1 | SIMPLE | users | ALL | NULL | NULL | NULL | NULL | 100000 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
扫描了 10 万行!
-- ✅ 添加索引后,type = ref(走索引,快!)
CREATE INDEX idx_age ON users(age);
EXPLAIN SELECT * FROM users WHERE age = 25;
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| 1 | SIMPLE | users | ref | idx_age | idx_age | 4 | const | 120 | Using index |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
只扫描 120 行!性能提升 800 倍!
⭐ Extra 字段(隐藏的优化线索)
| Extra 值 | 含义 | 优化建议 |
|---|---|---|
| Using index ✅ | 覆盖索引,只读索引不回表 | 很好! |
| Using where | 使用了 WHERE 过滤 | 普通 |
| Using index condition | 索引下推 | 好! |
| Using filesort ⚠️ | 文件排序(慢) | 优化!添加索引 |
| Using temporary ⚠️ | 使用临时表 | 优化! |
| Using join buffer ⚠️ | JOIN 没用索引 | 加索引! |
| Impossible WHERE | WHERE 永远为假 | 检查逻辑 |
🛠️ 第三步:索引优化 - SQL 的"导航仪"
什么是索引?
生活比喻:
- 没有索引 = 翻字典从第一页一页一页找(全表扫描)📖📖📖
- 有索引 = 查字典的拼音目录,直接定位到对应页(索引查找)🎯
MySQL 索引类型
1️⃣ B+树索引(最常用)
css
[10, 20, 30] ← 根节点(非叶子节点)
/ | \
[3,7] [15,18] [25,28] ← 中间节点
/ \ / \ / \
[1-5] [6-9] [10-19] [20-24] [25-30] ← 叶子节点(存实际数据)
↓ ↓ ↓ ↓ ↓
[→] → [→] → [→] → [→] → [→] ← 叶子节点用链表连接(支持范围查询)
特点:
- 多路平衡树,高度低(通常 3-4 层)
- 只有叶子节点存数据
- 叶子节点有序链表 → 支持范围查询
为什么用 B+ 树而不是二叉树?
css
二叉树: B+树:
10 [10,20,30,40]
/ \ / | | \
5 15 VS. [1-9] [10-19] [20-29] [30-40]
/ \ / \
1 7 12 20 高度:2 层 高度:4 层
磁盘IO:2 次 磁盘IO:100+ 次
高度:4 层
磁盘IO:4 次
B+树 每个节点可以存几百个key,高度更低 → 磁盘IO更少 → 更快!
2️⃣ 哈希索引
bash
hash("张三") = 123 → 直接定位到第123号槽位
特点:
- O(1) 查找,超快!
- ❌ 不支持范围查询(>、<、BETWEEN)
- ❌ 不支持排序(ORDER BY)
- ❌ 不支持最左前缀匹配
适用场景 :精确匹配查询(如 WHERE id = 123)
索引优化最佳实践 🎯
✅ 1. 为 WHERE、JOIN、ORDER BY 字段添加索引
sql
-- ❌ 慢查询
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
-- 全表扫描 10 万行
-- ✅ 添加联合索引
CREATE INDEX idx_user_status ON orders(user_id, status);
-- 只扫描 10 行
✅ 2. 联合索引遵循最左匹配原则
sql
-- 创建联合索引
CREATE INDEX idx_abc ON table(a, b, c);
-- ✅ 会使用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
-- ❌ 不会使用索引(跳过了 a)
WHERE b = 2
WHERE c = 3
WHERE b = 2 AND c = 3
-- ⚠️ 部分使用索引(只用到 a)
WHERE a = 1 AND c = 3 -- 用了 a,没用 c(中间跳过了 b)
比喻:联合索引像电话簿,先按姓氏排序,再按名字排序。
- 你可以找"姓张"的所有人(用第一列)
- 你可以找"姓张名三"的人(用前两列)
- ❌ 你不能直接找"名字叫三"的人(跳过第一列)
✅ 3. 覆盖索引避免回表
sql
-- ❌ 需要回表(先查索引,再查主键)
SELECT id, name, age, address FROM users WHERE age = 25;
-- 步骤:
-- 1. 在 age 索引中找到记录(得到主键 id)
-- 2. 根据 id 回表查询完整数据(address 不在索引中)
-- ✅ 覆盖索引(不需要回表)
CREATE INDEX idx_age_name_address ON users(age, name, address);
SELECT name, age, address FROM users WHERE age = 25;
-- 步骤:
-- 1. 在索引中找到记录(索引已经包含所有字段)
-- 2. 直接返回结果(不需要回表)
优势:减少一次磁盘IO,速度翻倍!
✅ 4. 避免索引失效的坑 ⚠️
| 场景 | 错误写法 | 正确写法 |
|---|---|---|
| 函数操作 | WHERE YEAR(create_time) = 2024 |
WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31' |
| 类型转换 | WHERE phone = 12345678(phone是varchar) |
WHERE phone = '12345678' |
| 前导模糊 | WHERE name LIKE '%张%' |
WHERE name LIKE '张%' |
| OR 条件 | WHERE a = 1 OR b = 2(b无索引) |
给b也加索引,或改用 UNION |
| 不等于 | WHERE status != 1 |
WHERE status IN (2,3,4) |
| IS NOT NULL | WHERE email IS NOT NULL |
尽量设计字段为 NOT NULL |
原因:对索引字段进行函数计算/类型转换,MySQL无法使用索引,只能全表扫描!
✅ 5. 索引不是越多越好
反例:
sql
-- ❌ 疯狂建索引
CREATE INDEX idx_name ON users(name);
CREATE INDEX idx_age ON users(age);
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_phone ON users(phone);
CREATE INDEX idx_address ON users(address);
-- ...建了 20 个索引
问题:
- 写入变慢:每次 INSERT/UPDATE 都要维护所有索引
- 占用空间:索引占用大量磁盘空间
- 优化器困惑:太多索引反而让优化器选错
原则:
- 单表索引数量 < 5 个
- 根据实际查询场景建索引
- 定期删除不使用的索引
📝 第四步:SQL 语句优化
优化技巧 1:避免 SELECT *
sql
-- ❌ 查询所有字段(返回不需要的数据)
SELECT * FROM users WHERE id = 1;
-- 假设表有 50 个字段,总共 10KB 数据
-- ✅ 只查询需要的字段
SELECT id, name, age FROM users WHERE id = 1;
-- 只返回 3 个字段,100 字节
优势:
- 减少网络传输
- 减少内存占用
- 可能使用覆盖索引
优化技巧 2:分页优化
sql
-- ❌ 深度分页慢(翻到第 10000 页)
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- MySQL 会扫描前 100020 行,然后丢弃前 100000 行
-- ✅ 使用子查询优化
SELECT * FROM orders
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 100000, 1)
LIMIT 20;
-- ✅ 使用游标分页(记录上次最后一条ID)
SELECT * FROM orders WHERE id > 123456 ORDER BY id LIMIT 20;
优化技巧 3:批量操作
sql
-- ❌ 逐条插入
INSERT INTO users VALUES (1, '张三');
INSERT INTO users VALUES (2, '李四');
INSERT INTO users VALUES (3, '王五');
-- 3 次网络往返 + 3 次事务提交
-- ✅ 批量插入
INSERT INTO users VALUES
(1, '张三'),
(2, '李四'),
(3, '王五');
-- 1 次网络往返 + 1 次事务提交
性能提升:100 倍以上!
优化技巧 4:JOIN 优化
sql
-- ❌ 没有索引的 JOIN
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id;
-- 如果 user_id 没有索引 → 嵌套循环,O(n*m)
-- ✅ 添加索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 有了索引 → 哈希连接,O(n+m)
JOIN 的注意事项:
- JOIN 字段必须有索引
- 小表驱动大表(小表在前)
- 避免 JOIN 超过 3 张表
优化技巧 5:子查询改 JOIN
sql
-- ❌ IN 子查询(可能很慢)
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE level = 'VIP');
-- ✅ 改用 JOIN
SELECT o.* FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.level = 'VIP';
🗂️ 第五步:表结构优化
1. 选择合适的数据类型
| 场景 | ❌ 差的选择 | ✅ 好的选择 | 原因 |
|---|---|---|---|
| 存储年龄 | VARCHAR(10) |
TINYINT UNSIGNED |
1字节 vs 10字节 |
| 存储性别 | VARCHAR(10) |
TINYINT 或 ENUM('男','女') |
节省空间 |
| 存储价格 | FLOAT |
DECIMAL(10,2) |
精度问题 |
| 存储状态 | VARCHAR(20) |
TINYINT + 常量定义 |
节省空间 + 性能 |
| 存储IP | VARCHAR(15) |
INT UNSIGNED + INET_ATON() |
4字节 vs 15字节 |
2. 字段设计原则
✅ 尽量 NOT NULL
sql
-- ❌ 允许 NULL
CREATE TABLE users (
name VARCHAR(50) NULL
);
-- 问题:
-- 1. NULL 需要额外的标志位存储
-- 2. 索引中 NULL 处理复杂
-- 3. COUNT(name) 会跳过 NULL,容易出错
-- ✅ 设置默认值
CREATE TABLE users (
name VARCHAR(50) NOT NULL DEFAULT ''
);
✅ 合理使用 TEXT/BLOB
sql
-- ❌ 把大字段和普通字段放一起
CREATE TABLE articles (
id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT, -- 可能几MB
author VARCHAR(50)
);
SELECT id, title, author FROM articles; -- 也会加载 content,浪费IO
-- ✅ 拆分大字段到单独的表
CREATE TABLE articles (
id INT PRIMARY KEY,
title VARCHAR(200),
author VARCHAR(50)
);
CREATE TABLE article_content (
article_id INT PRIMARY KEY,
content TEXT
);
3. 分表策略
垂直拆分(按字段)
bash
原来:
users 表 (50个字段)
id | name | age | email | address | ... | 46个不常用字段
拆分后:
users_basic 表 (常用字段)
id | name | age | email
users_detail 表 (不常用字段)
user_id | address | ... | 其他45个字段
优势:热数据小了,缓存命中率高了!
水平拆分(按行)
sql
-- 原来:orders 表 (1亿条记录)
orders
├─ 2020年订单 (1000万)
├─ 2021年订单 (2000万)
├─ 2022年订单 (3000万)
├─ 2023年订单 (4000万)
-- 拆分后:
orders_2020 (1000万)
orders_2021 (2000万)
orders_2022 (3000万)
orders_2023 (4000万)
优势:单表数据量小了,查询更快!
📊 第六步:硬件与配置优化
1. MySQL 配置参数
ini
# my.cnf 或 my.ini
# 缓冲池大小(最重要!)
innodb_buffer_pool_size = 8G # 物理内存的 70%-80%
# 日志文件大小
innodb_log_file_size = 512M
# 查询缓存(MySQL 8.0 已移除)
query_cache_size = 0 # 建议禁用
# 连接数
max_connections = 500
# 慢查询阈值
long_query_time = 1
2. 硬件选择
| 组件 | 建议 | 原因 |
|---|---|---|
| CPU | 多核高频 | MySQL 单个查询是单线程 |
| 内存 | 越大越好 | InnoDB 缓冲池吃内存 |
| 磁盘 | SSD > HDD | 随机IO性能是HDD的100倍 |
| 网络 | 万兆网卡 | 减少数据传输延迟 |
🎯 完整优化流程总结
sql
┌────────────────────────────────────────┐
│ 1. 发现慢查询 │
│ - 慢查询日志 📝 │
│ - 监控告警 📊 │
└───────────┬────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ 2. EXPLAIN 分析 │
│ - type = ALL? 全表扫描!⚠️ │
│ - key = NULL? 没用索引!⚠️ │
│ - rows 很大? 扫描太多行!⚠️ │
└───────────┬────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ 3. 索引优化 │
│ - 添加索引 ✅ │
│ - 联合索引 ✅ │
│ - 覆盖索引 ✅ │
└───────────┬────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ 4. SQL 重写 │
│ - 避免 SELECT * ✅ │
│ - 分页优化 ✅ │
│ - 批量操作 ✅ │
└───────────┬────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ 5. 表结构优化 │
│ - 合适的数据类型 ✅ │
│ - 字段设计 ✅ │
│ - 分表 ✅ │
└───────────┬────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ 6. 配置与硬件 │
│ - MySQL 参数调优 ✅ │
│ - 硬件升级 ✅ │
└────────────────────────────────────────┘
💡 面试加分回答模板
面试官:"你遇到过慢查询吗?怎么优化的?"
标准回答:
"遇到过。印象最深的一次是订单查询接口响应时间从几百毫秒突然变成 30 秒。
定位过程:
- 查看慢查询日志,发现一条 SQL:
SELECT * FROM orders WHERE status = 'paid' AND create_time > '2024-01-01'- EXPLAIN 分析:type = ALL,rows = 500万,没用索引
- 发现 status 和 create_time 字段都没有索引
优化方案:
- 加联合索引 :
CREATE INDEX idx_status_time ON orders(status, create_time)- SQL 改写 :把
SELECT *改成只查需要的 10 个字段- 分页优化:用游标分页代替 OFFSET(因为有深度分页场景)
优化结果:
- 查询时间从 30秒 降到 50毫秒
- EXPLAIN 显示 type = range,rows = 1200(索引生效)
- 服务器 CPU 从 80% 降到 10%
总结经验:
- 开发时养成习惯,WHERE、JOIN 字段都加索引
- 定期 Review 慢查询日志
- 使用 EXPLAIN 验证每条重要的 SQL"
🎉 总结金句
- 索引是性能的基石 - 没有索引的查询就像没有导航的旅行 🧭
- EXPLAIN 是你的 X 光机 - 看不见的问题用它来透视 🔍
- rows 数量决定性能 - 扫描越少越快 🎯
- type = ALL 是大忌 - 全表扫描是性能杀手 💀
- 优化永无止境 - 但要抓主要矛盾 📈
📚 扩展阅读
最后的最后,送你一个公式 🎁:
sql
慢查询优化 = 监控发现 + EXPLAIN 分析 + 索引优化 + SQL 改写
记住这四步,走遍天下都不怕!😎
祝你的 SQL 永远快如闪电! ⚡