一条 SQL 跑了 8 秒,优化到 200ms 的全过程

下午三点半,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_itemt_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?评论区聊聊,看看谁的更刺激。

相关推荐
FYKJ_20102 小时前
【毕设分享】基于Web的校园兼职信息发布与申请系统07059
前端·vue.js·spring boot·mysql·typescript·spark·课程设计
文人sec3 小时前
MYSQL:insert...select:为什么锁源表的所有行和间隙?怎么最快地复制一张表?
数据库·python·mysql
RestCloud5 小时前
大数据量ETL同步优化:亿级表同步与性能调优
mysql·数据迁移·数据传输·数据同步·亿级数据传输·大数据量传输
feng_you_ying_li5 小时前
mysql基本介绍,截止到类型float的一部分介绍
mysql
高级程序源6 小时前
django校企合作实习基地管理系统82506-计算机课程设计、毕业设计
vue.js·后端·python·mysql·django·课程设计·pygame
dkbnull6 小时前
数据库性能调优详解
数据库·mysql
yxlalm6 小时前
对话记忆持久化-Redis热与MySQL冷
android·redis·mysql
冰暮流星6 小时前
mysql多表练习2
数据库·sql·mysql
风哥2号15 小时前
数据库教程FGMT27‑MySQL数据库基础知识与体系架构
数据库·mysql