下午三点半,DBA 群里 @ 我,甩了一条慢查询告警过来------"订单列表接口,P99 延迟 8.3 秒,再不处理要影响线上 SLA 了。"我看了一眼 SQL,好家伙,5 张表 JOIN,WHERE 条件里还套了函数。这优化空间,大到可以写篇文章了。
先看看这条"八秒 SQL"长什么样
DBA 发过来的原始 SQL 大概长这样:
sql
-- 原始慢查询:订单列表接口,关联用户表、订单表、商品表、支付表、物流表
SELECT
o.order_no, -- 订单号
u.username, -- 用户名
o.created_at, -- 下单时间
p.pay_status, -- 支付状态
l.logistics_status -- 物流状态
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN t_order_item oi ON oi.order_id = o.id
LEFT JOIN t_product p2 ON oi.product_id = p2.id
LEFT JOIN t_payment p ON p.order_id = o.id
LEFT JOIN t_logistics l ON l.order_id = o.id
WHERE YEAR(o.created_at) = 2024 -- 问题1:对索引列套函数,索引直接废了
AND o.status IN (1, 2, 3) -- 问题2:IN 列表本身没问题,但配合上面的函数查询就炸了
AND p2.category_id = 15 -- 问题3:category_id 上没有索引
AND u.username LIKE '%张%' -- 问题4:左模糊查询,走不了索引
ORDER BY o.created_at DESC -- 问题5:没有索引支撑排序,触发 filesort
LIMIT 20 OFFSET 0; -- 分页本身还好,但数据量大时 OFFSET 也会拖慢
这条 SQL 一跑,全表扫描 + filesort + 临时表,三件套齐了。EXPLAIN 一看,type 全是 ALL,rows 加起来扫了几十万行。不慢才怪。
用 EXPLAIN 定位到底慢在哪
拿到 SQL 第一步别急着加索引,先跑个 EXPLAIN 看清楚执行计划:
sql
-- 在原始 SQL 前面加 EXPLAIN 就能看到执行计划
EXPLAIN SELECT o.order_no, u.username, o.created_at, p.pay_status, l.logistics_status
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN t_order_item oi ON oi.order_id = o.id
LEFT JOIN t_product p2 ON oi.product_id = p2.id
LEFT JOIN t_payment p ON p.order_id = o.id
LEFT JOIN t_logistics l ON l.order_id = o.id
WHERE YEAR(o.created_at) = 2024
AND o.status IN (1, 2, 3)
AND p2.category_id = 15
AND u.username LIKE '%张%'
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 0;
跑出来的结果让我倒吸一口凉气:
| 字段 | 优化前 | 说明 |
|---|---|---|
| type | ALL | 全表扫描,最差的访问类型 |
| key | NULL | 没用到任何索引 |
| rows | 387,520 | 预估扫描 38 万行 |
| Extra | Using filesort; Using temporary | 文件排序 + 临时表,性能杀手 |
| filtered | 0.12% | 扫描 38 万行最后只要 0.12% 的数据 |
几个关键字段解释一下:
- type 反映访问类型,从好到差依次是
system > const > eq_ref > ref > range > index > ALL。看到 ALL 就知道大事不妙。 - key 是实际用到的索引,NULL 说明完全没走索引。
- rows 是优化器估算要扫描的行数,这个数字越接近最终返回行数越好。
- Extra 里的
Using filesort说明排序没走索引,Using temporary说明用了临时表来分组或排序。
问题定位清楚了,接下来逐个击破。
索引优化:该加的加,该改的改
第一个要处理的是 YEAR(o.created_at) = 2024 这个条件。在索引列上套函数,MySQL 没法用 B+ 树索引,只能逐行计算再比较。改成范围查询就行:
sql
-- 优化后:把函数调用改成范围条件,索引就能生效了
-- 改之前:WHERE YEAR(o.created_at) = 2024 ← 索引失效
-- 改之后:
WHERE o.created_at >= '2024-01-01 00:00:00' -- 范围左边界,索引可以走
AND o.created_at < '2025-01-01 00:00:00' -- 范围右边界,左闭右开
然后是 u.username LIKE '%张%',左模糊查询 B+ 树索引帮不了忙。这个场景有两个选择:如果用户名查询不是核心需求,可以改成右模糊 LIKE '张%';如果确实需要全文搜索,得上 Elasticsearch 或者 MySQL 全文索引。
我这里选了折中方案------把用户名搜索从 SQL 里拆出来,前端改成"先选用户,再看订单"的交互,SQL 里直接用 u.id = ? 精确匹配。
接下来加索引。根据查询条件,我给几个关键位置补上了索引:
sql
-- 给 t_order 表加联合索引,覆盖 WHERE 和 ORDER BY 用到的字段
-- 注意字段顺序:等值条件放前面,范围条件放后面
ALTER TABLE t_order ADD INDEX idx_status_created (status, created_at);
-- 给 t_product 表的 category_id 加索引
ALTER TABLE t_product ADD INDEX idx_category (category_id);
-- 给 t_payment 和 t_logistics 的 order_id 确认有索引(关联字段必须有)
ALTER TABLE t_payment ADD INDEX idx_order_id (order_id);
ALTER TABLE t_logistics ADD INDEX idx_order_id (order_id);
联合索引的字段顺序有讲究:等值查询的字段放前面,范围查询的字段放后面。因为 MySQL 联合索引遵循最左前缀原则,范围条件之后的字段就用不上索引了。
SQL 重写:减少不必要的 JOIN
原始 SQL 关联了 5 张表,但其实 t_order_item 和 t_product 的 JOIN 只是为了筛选 category_id = 15。这个子查询完全可以提前过滤,减少主查询的数据量:
sql
-- 优化后的完整 SQL
SELECT
o.order_no, -- 订单号
u.username, -- 用户名
o.created_at, -- 下单时间
p.pay_status, -- 支付状态
l.logistics_status -- 物流状态
FROM t_order o
-- 先用 EXISTS 子查询替代 JOIN,避免产生重复行和临时表
WHERE EXISTS (
SELECT 1 FROM t_order_item oi
JOIN t_product p2 ON oi.product_id = p2.id
WHERE oi.order_id = o.id
AND p2.category_id = 15 -- 提前在子查询里过滤商品分类
)
AND o.user_id = u.id -- 精确匹配用户 ID,走主键索引
AND o.created_at >= '2024-01-01 00:00:00' -- 范围查询,走联合索引
AND o.created_at < '2025-01-01 00:00:00'
AND o.status IN (1, 2, 3) -- 等值匹配,走联合索引前缀
AND p.order_id = o.id -- 支付表关联
AND l.order_id = o.id -- 物流表关联
ORDER BY o.created_at DESC -- 排序字段在索引里,不再触发 filesort
LIMIT 20 OFFSET 0; -- 分页,配合索引可以快速定位
这里有个关键改动:把 LEFT JOIN t_order_item + t_product 换成了 EXISTS 子查询。区别在于:
- JOIN 会先把两张表笛卡尔积再过滤,中间结果集可能很大。
- EXISTS 只要子查询找到一条匹配就返回 true,不需要物化中间结果。
另外我把 u.username LIKE '%张%' 去掉了,改成前端传 user_id 精确查询。如果业务确实需要按用户名模糊搜索,建议单独走 Elasticsearch,别在订单列表查询里做。
优化效果对比
一顿操作下来,再看 EXPLAIN 的结果:
| 字段 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| type | ALL | range | 全表扫描 → 范围扫描 |
| key | NULL | idx_status_created | 没索引 → 走联合索引 |
| rows | 387,520 | 1,246 | 扫描行数降了 99.7% |
| Extra | Using filesort; Using temporary | Using index condition | 文件排序没了 |
| 执行时间 | 8.3s | 0.19s | 快了 43 倍 |
执行时间从 8.3 秒降到了 190 毫秒,DBA 那边告警也消了。
整个优化过程总结下来就这几步:
- 先 EXPLAIN 再动手,别猜,让数据库告诉你慢在哪。
- 索引列上别套函数 ,
YEAR()、DATE()、CONVERT()这些都会让索引失效。 - 联合索引注意字段顺序,等值条件在前,范围条件在后。
- JOIN 不是万能的,能用 EXISTS 或子查询提前过滤的就别硬 JOIN。
- 左模糊查询别走 MySQL,全文搜索交给 Elasticsearch。
- 分页 OFFSET 大了也会慢,数据量上来之后考虑游标分页或延迟关联。
这条 SQL 的问题其实挺典型的,基本上慢查询的常见坑都踩了一遍。你们线上有没有遇到过更离谱的慢 SQL?评论区聊聊,看看谁的更刺激。