为什么你的查询越来越慢?
在实际项目中,随着数据量增长,原本运行良好的 SQL 查询可能在某天突然变慢。一个没有优化的慢查询不仅拖慢整个系统,还可能在高并发下导致数据库连接池耗尽,引发雪崩效应。
本文通过一个真实的电商订单系统案例,演示从发现慢查询到最终优化的完整流程,所有代码均可直接复用到你的项目中。
一、开启慢查询日志:让问题可见
1.1 查看当前慢查询配置
1
2
3
|
-- 查看慢查询相关参数
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
|
1.2 开启慢查询日志
1
2
3
4
5
6
|
-- 临时开启(重启后失效)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录到慢查询日志
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 永久开启(写入配置文件 my.cnf)
|
在 my.cnf 中添加:
1
2
3
4
5
|
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1 # 记录未使用索引的查询
|
1.3 使用 mysqldumpslow 分析慢查询
1
2
3
4
5
6
7
8
|
# 按查询次数排序,取前10条
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按平均耗时排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
# 按总耗时排序,只看 SELECT 语句
mysqldumpslow -s t -t 10 -g "SELECT" /var/log/mysql/slow.log
|
输出示例:
1
2
|
Count: 1523 Time=2.35s (3579s) Lock=0.01s (15s) Rows=856.0 (1303512)
SELECT * FROM orders WHERE user_id = N AND status = N AND create_time > 'S'
|
这告诉我们:orders 表上 user_id + status + create_time 的组合查询被执行了 1523 次,平均 2.35 秒。
二、EXPLAIN 深度分析:定位问题根因
2.1 EXPLAIN 输出解读
1
2
3
4
5
6
7
8
|
EXPLAIN SELECT o.id, o.order_no, o.total_amount, u.username
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345
AND o.status = 1
AND o.create_time > '2026-01-01'
ORDER BY o.create_time DESC
LIMIT 20;
|
关键字段解读:
1
2
3
4
5
6
|
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+
| 1 | SIMPLE | o | ALL | NULL | NULL | NULL | NULL | 523000 | Using where; Using temporary; Using filesort |
| 1 | SIMPLE | u | eq_ref| PRIMARY | id | 4 | o.user_id | 1 | NULL |
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+
|
🔴 问题一目了然:
type = ALL:全表扫描,52万行数据逐行检查
possible_keys = NULL:没有可用索引
rows = 523000:扫描了全部行
Using filesort:ORDER BY 无法利用索引,需要额外排序
Using temporary:JOIN + ORDER BY 导致创建临时表
2.2 EXPLAIN 输出字段速查表
| 字段 |
好的值 |
差的值 |
说明 |
| type |
system/const/eq_ref |
ALL |
访问类型,从好到差 |
| possible_keys |
有索引名 |
NULL |
可能使用的索引 |
| key |
有索引名 |
NULL |
实际使用的索引 |
| rows |
越小越好 |
数十万 |
预估扫描行数 |
| Extra |
NULL/Using index |
Using filesort/Using temporary |
额外信息 |
三、索引设计优化:从全表扫描到索引覆盖
3.1 创建合适的联合索引
根据 WHERE 条件和 ORDER BY 的字段,设计联合索引:
1
2
3
4
5
6
7
|
-- 分析查询的字段使用情况:
-- WHERE: user_id(等值) + status(等值) + create_time(范围)
-- ORDER BY: create_time DESC
-- 创建联合索引(注意字段顺序)
CREATE INDEX idx_orders_user_status_time
ON orders(user_id, status, create_time);
|
联合索引的字段顺序原则:
- 等值查询的字段放前面(
user_id, status)
- 范围查询的字段放后面(
create_time)
- ORDER BY 字段紧跟范围查询字段(利用索引有序性避免 filesort)
3.2 验证优化效果
1
2
3
4
5
6
7
8
|
EXPLAIN SELECT o.id, o.order_no, o.total_amount, u.username
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345
AND o.status = 1
AND o.create_time > '2026-01-01'
ORDER BY o.create_time DESC
LIMIT 20;
|
优化后:
1
2
3
4
5
6
7
|
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+
| 1 | SIMPLE | o | range | idx_orders_user_status | idx_orders_user_status | 15 | NULL | 856 | Using where;|
| | | | | | | | | | Using index |
| 1 | SIMPLE | u | eq_ref| PRIMARY | PRIMARY | 4 | o.user_id | 1 | NULL |
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+
|
🔥 效果对比:
| 指标 |
优化前 |
优化后 |
| type |
ALL(全表扫描) |
range(范围扫描) |
| 扫描行数 |
523,000 |
856 |
| Extra |
Using filesort, Using temporary |
Using index |
| 预估耗时 |
~2.35s |
~0.02s |
扫描行数从 52 万降到 856,性能提升约 600 倍。
3.3 覆盖索引:避免回表查询
如果我们只需要 orders 表的字段,可以用覆盖索引进一步优化:
1
2
3
4
5
6
7
8
|
-- 只查 orders 表字段,不需要 JOIN users 表
SELECT id, order_no, user_id, status, total_amount, create_time
FROM orders
WHERE user_id = 12345
AND status = 1
AND create_time > '2026-01-01'
ORDER BY create_time DESC
LIMIT 20;
|
如果查询字段全部包含在索引中,EXPLAIN 的 Extra 会显示 Using index,表示直接从索引读取数据,不需要回表。
四、SQL 重写技巧:绕过索引失效
4.1 避免索引失效的常见陷阱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
-- ❌ 陷阱1:对索引列使用函数
SELECT * FROM orders WHERE YEAR(create_time) = 2026;
-- ✅ 改写:使用范围查询
SELECT * FROM orders
WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01';
-- ❌ 陷阱2:隐式类型转换
SELECT * FROM users WHERE phone = 13800138000; -- phone 是 varchar
-- ✅ 改写:使用正确的类型
SELECT * FROM users WHERE phone = '13800138000';
-- ❌ 陷阱3:LIKE 左模糊
SELECT * FROM products WHERE name LIKE '%手机%';
-- ✅ 改写:使用全文索引或搜索引擎
ALTER TABLE products ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM products WHERE MATCH(name) AGAINST('手机' IN BOOLEAN MODE);
-- ❌ 陷阱4:OR 导致索引失效
SELECT * FROM orders WHERE user_id = 123 OR status = 1;
-- ✅ 改写:拆分为 UNION ALL
SELECT * FROM orders WHERE user_id = 123
UNION ALL
SELECT * FROM orders WHERE status = 1 AND user_id != 123;
|
4.2 分页查询深度优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
-- ❌ 深度分页问题:LIMIT 100000, 20
-- MySQL 需要扫描前 100020 行,丢弃前 100000 行
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 100000, 20;
-- ✅ 优化方案1:延迟关联(Deferred Join)
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) AS tmp ON o.id = tmp.id;
-- ✅ 优化方案2:游标分页(适合连续翻页场景)
-- 第一页
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 后续页:记录上一页最后一条的 create_time 和 id
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
AND (create_time, id) < ('2026-06-15 10:30:00', 98765)
ORDER BY create_time DESC, id DESC
LIMIT 20;
|
五、实战:批量数据下的性能验证
让我们用 Python 脚本创建测试数据并验证优化效果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
|
import pymysql
import time
# 连接数据库
conn = pymysql.connect(
host='localhost', port=3306,
user='root', password='your_password',
database='ecommerce', charset='utf8mb4'
)
cursor = conn.cursor()
# 创建测试表
cursor.execute("""
CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id INT NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# 批量插入 50 万条测试数据
print("插入测试数据中...")
batch_size = 1000
total = 500000
for batch_start in range(0, total, batch_size):
values = []
for i in range(batch_size):
idx = batch_start + i
user_id = (idx % 10000) + 1
status = (idx % 5) + 1
amount = round(10.0 + (idx % 10000), 2)
order_no = f"ORD{idx:012d}"
values.append((order_no, user_id, status, amount))
cursor.executemany(
"INSERT INTO orders (order_no, user_id, status, total_amount) VALUES (%s,%s,%s,%s)",
values
)
conn.commit()
if (batch_start // batch_size) % 10 == 0:
print(f" 已插入 {batch_start + batch_size} / {total}")
print(f"共插入 {total} 条数据")
# 性能测试:优化前
print("\n=== 优化前(无索引)===")
start = time.time()
cursor.execute("""
SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
results = cursor.fetchall()
elapsed = time.time() - start
print(f" 耗时: {elapsed:.4f}s, 返回行数: {len(results)}")
# 创建索引
print("\n创建索引 idx_orders_user_status_time...")
cursor.execute("""
CREATE INDEX idx_orders_user_status_time
ON orders(user_id, status, create_time)
""")
conn.commit()
print("索引创建完成")
# 性能测试:优化后
print("\n=== 优化后(有索引)===")
start = time.time()
cursor.execute("""
SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
results = cursor.fetchall()
elapsed = time.time() - start
print(f" 耗时: {elapsed:.4f}s, 返回行数: {len(results)}")
# 查看执行计划
print("\n=== EXPLAIN ===")
cursor.execute("""
EXPLAIN SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
for row in cursor.fetchall():
print(row)
cursor.close()
conn.close()
|
运行结果示例:
1
2
3
|
优化前(无索引): 耗时: 1.8732s, 返回行数: 20
优化后(有索引): 耗时: 0.0018s, 返回行数: 20
性能提升: 1040x
|
六、优化检查清单
在实际项目中,建议定期执行以下检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
-- 1. 查看表的索引使用情况
SELECT
object_schema, object_name, index_name, count_star,
count_read, count_fetch
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'ecommerce'
AND count_read > 0
ORDER BY count_read DESC
LIMIT 20;
-- 2. 查看从未使用过的索引(考虑删除,节省空间和写入性能)
SELECT
object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'ecommerce'
AND count_star = 0
AND index_name IS NOT NULL
AND index_name != 'PRIMARY';
-- 3. 查看 InnoDB 缓冲池命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
-- 命中率低于 99% 需要增加缓冲池大小
-- 4. 查看索引碎片率
SELECT
table_name,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_size_mb,
ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
FROM information_schema.tables
WHERE table_schema = 'ecommerce'
AND data_free > 1024 * 1024
ORDER BY data_free DESC;
|
总结
MySQL 慢查询优化的核心思路:
- 发现:开启慢查询日志,用
mysqldumpslow 定位热点 SQL
- 分析:
EXPLAIN 查看执行计划,重点关注 type、rows、Extra
- 索引:根据查询模式设计联合索引,注意字段顺序
- 重写:避免函数调用、类型转换、左模糊等索引失效场景
- 验证:对比优化前后的查询耗时和扫描行数
- 监控:定期检查索引使用率和缓冲池命中率
好的数据库优化不是一蹴而就的,而是需要持续观察、分析、调整的过程。建议在项目中建立慢查询监控机制,在问题变严重之前及时发现和解决。