SQL性能优化综合案例能够帮我们系统性地理解慢查询的成因与解决办法。面对一个响应缓慢的查询,不能只靠直觉加索引,而要结合执行计划、数据分布和业务语义逐步剖析。

一、案例背景与慢查询现象
某交易系统需要统计最近三十天每个用户的订单总金额与下单次数,相关表结构简化如下:用户表 user_info 约五十万行,订单表 order_record 约两千万行,订单表含有用户编号 user_id、订单状态 status、创建时间 create_time 和金额 amount 等字段。原始查询语句如下:
SELECT u.id, u.name, COUNT(o.id) AS order_cnt, SUM(o.amount) AS total_amount FROM user_info u LEFT JOIN order_record o ON u.id = o.user_id AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE u.status = 1 GROUP BY u.id, u.name;
该语句在生产环境平均耗时十二秒以上,数据库 CPU 使用率峰值明显,磁盘 IO 等待偏高。从业务角度看,只需要活跃用户与近期订单的聚合结果,但执行过程却扫描了大量无关历史数据。
初步判断问题可能出在连接顺序与过滤时机上。LEFT JOIN 之后才做时间过滤,使优化器先生成了巨大的中间结果集,再整体分组。同时 user_info 表虽然不大,但若索引选择不当,也会成为瓶颈。我们需要借助执行计划进一步确认。
二、通过执行计划定位瓶颈
在 MySQL 中可以使用 EXPLAIN 查看优化器选定的执行路径。对原语句执行 EXPLAIN 后得到关键信息:order_record 表类型为 ALL,即全表扫描;rows 估算约两千万;user_info 虽走了主键,但连接时被放在内层循环。这说明优化器没有利用 create_time 与 user_id 的复合索引。
EXPLAIN SELECT u.id, u.name, COUNT(o.id) AS order_cnt, SUM(o.amount) AS total_amount FROM user_info u LEFT JOIN order_record o ON u.id = o.user_id AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE u.status = 1 GROUP BY u.id, u.name;
执行计划暴露了两个核心问题。第一,order_record 缺少 (user_id, create_time) 复合索引,导致连接与过滤都只能线性扫描。第二,LEFT JOIN 的语义使优化器倾向于以 user_info 为驱动表,但 order_record 侧无有效裁剪,中间集膨胀。
我们还可以通过 SHOW STATUS 或慢查询日志确认 Handler_read_rnd_next 数值极高,代表大量随机读。这种指标与 ALL 扫描相互印证,说明磁盘压力来自无序全表访问,而非单纯的计算量大。
三、索引优化与语句改写
首先建立复合索引,让订单表可以先按用户再按时间定位。索引顺序将 user_id 放在前面,以匹配连接条件中的等值匹配,create_time 随后支持范围过滤。
ALTER TABLE order_record ADD INDEX idx_user_time (user_id, create_time);
建立索引后,原语句性能有所改善,但仍不够理想,因为 LEFT JOIN 与 GROUP BY 的组合让优化器难以完全下推聚合。我们进一步将近期订单先聚合成临时派生表,再与用户表关联,缩小参与连接的数据量。
SELECT
u.id,
u.name,
IFNULL(t.order_cnt, 0) AS order_cnt,
IFNULL(t.total_amount, 0) AS total_amount
FROM user_info u
LEFT JOIN (
SELECT
user_id,
COUNT(id) AS order_cnt,
SUM(amount) AS total_amount
FROM order_record
WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
) t ON u.id = t.user_id
WHERE u.status = 1;
改写后的子查询先在 order_record 上利用 idx_user_time 做范围扫描并分组,数据量从两千万行降至约八十万行聚合结果。外层再以用户表关联,逻辑读取行数下降一个数量级。同时,对 NULL 结果使用 IFNULL 保证业务语义不变。
若数据库版本支持,还可以将子查询物化为临时表或利用覆盖索引避免回表。由于 idx_user_time 未包含 amount,可在索引中追加 amount 形成 (user_id, create_time, amount) 的覆盖索引,使 SUM 操作无需访问主表记录。
四、优化前后对比与经验总结
我们用一张简表汇总核心指标的变化,便于直观判断优化效果:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均响应时间 | 12.4 秒 | 0.3 秒 |
| 扫描行数估算 | 约 2050 万 | 约 88 万 |
| order_record 访问类型 | ALL | range / ref |
| 索引命中率 | 约 40% | 近 100% |
从对比可见,复合索引与查询重写共同消除了全表扫描与冗余连接。对于报表类查询,先聚合后关联往往比直接大表连接更高效,因为聚合能显著缩减中间集。
在综合案例中,我们也应避免盲目加索引。索引会增加写入开销,因此只应在高频查询路径上建立必要索引。结合执行计划验证,而不是凭经验猜测,是性能优化必须养成的习惯。通过这类案例训练,开发者能在类似慢查询面前快速形成分析闭环。