最近排查了一个老项目的订单列表接口,打开页面要等八九秒,用户投诉好几次了。翻了慢查询日志,很快就定位到一条 SQL。
SELECT * FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 20;
慢查询日志记录:
- Query_time: 8.215682s
- Rows_sent: 20
- Rows_examined: 1847234
扫描了 184 万行,只返回 20 行,无效扫描比接近 92361:1。这个比例基本说明 SQL 没有走任何有效的索引路径。
EXPLAIN 里三个致命信号
EXPLAIN 结果里有三个典型的性能信号:
| 字段 | 值 | 含义 |
|---|---|---|
| type | ALL | orders 表全表扫描 |
| Extra | Using filesort | ORDER BY created_at 没有走索引排序,额外排序 |
| Extra | Using join buffer | 关联字段无索引,MySQL 用 join buffer 缓存中间结果 |
其中 Using join buffer 意味着 JOIN 过程根本没有可用索引,只能把驱动表结果集缓存起来,再去被驱动表逐行匹配。加上 Using filesort,184 万行全部扫完后还要再做一次排序,性能直接崩。
修复过程
1. orders 表建联合索引
最核心的一条:
ALTER TABLE orders ADD INDEX idx_status_created_at (status, created_at);
为什么要这样建?WHERE 条件是 status = 'paid' 等值过滤,ORDER BY 是 created_at DESC。基于最左前缀原则,把等值条件的 status 放前面,created_at 放后面,这样索引既能过滤 status,也能直接按 created_at 有序输出,filesort 自然消失。
如果把 created_at 放前面建 (created_at, status),那 status = 'paid' 这个等值条件走不上索引前缀,联合索引退化成无效索引,完全没意义。
2. order_items JOIN 列补索引
ALTER TABLE order_items ADD INDEX idx_order_id (order_id);
ALTER TABLE order_items ADD INDEX idx_product_id (product_id);
order_items 是被驱动表,JOIN 时靠 order_id 和 product_id 关联。这两个字段没索引,MySQL 只能反复全表扫描,Using join buffer 就是从这里来的。
3. 查询字段收窄,减少回表
原 SQL 用了 SELECT *,但订单列表页实际上根本用不着所有列。改成只查需要的字段:
SELECT
o.id,
o.order_no,
o.total_amount,
o.created_at,
o.status,
u.nickname,
u.avatar,
COUNT(oi.id)
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY o.id, o.order_no, o.total_amount, o.created_at, o.status, u.nickname, u.avatar
ORDER BY o.created_at DESC
LIMIT 20;
这样做的目的:
- 避免
SELECT *回表取全量字段,只取列表页渲染需要的列; - COUNT(oi.id) 用于展示订单商品数量,避免在应用层二次查询;
- 去掉 products 表 JOIN,因为列表页不需要商品详情,这个 JOIN 纯属多余。
效果对比
| 指标 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 查询耗时 | 8.215s | 0.031s | 约 265 倍 |
| 扫描行数 | 1847234 | 287 | 约 6436 倍 |
EXPLAIN 复查,原来的 Using filesort 和 Using join buffer 均已消除,orders 的 type 从 ALL 变为 ref,order_items 的关联也走上了索引。
一点复盘
这个问题的根因有三个层次:主表缺过滤+排序的联合索引、JOIN 表缺关联索引、SELECT * 放大了回表代价。修的时候按这个顺序来,效果是层层叠加的。
另外,ESLint 之类的工具救不了这种问题。最直接的还是对业务 SQL 保持敏感:一旦 Rows_examined 和 Rows_sent 差距过大,基本就是访问路径出问题了,第一时间 EXPLAIN。