一个 WMS 波次计划列表查询一页数据需要约 6 秒。这篇文章记录实际排查路径:从应用日志拿到真实 SQL,检查现有索引,测试并回退无效索引,最后验证新的 SQL 执行结构。
文中的环境和业务标识均已脱敏,SQL 结构、耗时数据和判断过程来自实际排查。
慢查询的执行结构
页面需要查询波次主表,关联明细与出库单,聚合 OMS 订单号和 Homebase,关联创建人、修改人信息,并查询装车时间,最后按创建时间倒序返回 50 条。
日志中的关键筛选条件是修改时间范围、组织和项目条件,同时按照创建时间排序。
问题就在这里:SQL 在确定页面最终需要的 50 个波次前,已经开始展开明细、聚合并排序。
先检查索引,而不是直接加索引
首先核对主表、明细表和出库单表的现有索引。明细关联出库单的路径已经存在可用联合索引。同时,波次主表上存在两条字段顺序完全一致的索引,因此删除其中一条重复索引,减少后续写入维护成本。
这个清理是必要的,但不足以解释 6 秒查询。当前列表按创建时间排序的路径本来就有索引支持,说明瓶颈不只是“没有索引”。
尝试装车时间子查询索引,然后回退
原 SQL 中存在一个相关子查询,逻辑等价于:
SELECT FM_LD_TIME
FROM wm_ld_header
WHERE DEF1 = WWH.WAVE_NO
AND ORG_ID = WWH.ORG_ID
LIMIT 1;
因为装车表没有按组织和波次引用字段访问的索引,测试增加了 ORG_ID + DEF1(32) 的索引。DEF1 为 TEXT 字段,因此使用前缀索引。
但核查数据分布时发现,生产数据中的 DEF1 全部为空。继续使用 EXPLAIN 验证后,MySQL 虽然识别了该候选索引,实际执行仍然对相关子查询做全表扫描,每次预估扫描一万多行。
因此该测试索引被回退。更重要的结论是:当前数据条件下,装车时间子查询无法返回有效值,却仍在持续增加查询工作量。
按真实过滤字段尝试 MODIFY_TIME 索引,也回退
日志中的范围条件使用 MODIFY_TIME,而当前列表索引主要围绕 CREATE_TIME。因此测试增加了以组织、项目、修改时间、波次号组成的联合索引。
索引的区分度正常,但执行计划仍更倾向使用原有创建时间索引。随后对同一组查询参数进行真实耗时对比:
| 方案 | 执行路径 | 耗时 |
|---|---|---|
| A | 优化器自动选择 | 6.859s |
| B | 强制原创建时间索引 | 6.225s |
| C | 强制新增修改时间索引 | 7.433s |
新增索引在该页面查询下更慢,因此同样删除。这个结果说明:仅继续追加索引,无法解决当前列表的秒级卡顿。
EXPLAIN 给出的证据
原查询执行计划中,明细表与出库单表的关联已经命中索引,单次关联行数很少。真正值得关注的是:
- 主查询在聚合后需要临时表和文件排序;
- 装车时间相关子查询作为依赖子查询,扫描超过一万行;
- 所有这些工作完成后,页面最终只取 50 条。
因此慢点不是单个 JOIN 的缺索引问题,而是执行顺序问题:先聚合大量候选数据,再做分页。
改写方向:先分页主表,再聚合明细
成功的测试查询改变了执行顺序:
- 先筛选并排序
wm_wv_header; - 只取得页面实际需要的 50 条主表记录;
- 只针对这 50 个波次聚合 OMS 订单号和 Homebase;
- 在装车时间实际关联关系确认前,当前场景先返回空值,停止无效扫描。
核心结构如下:
SELECT
WWH.JOB_ID,
WWH.WAVE_NO,
(SELECT GROUP_CONCAT(...) FROM wm_wv_detail ... WHERE ... = WWH.WAVE_NO) AS OMS_ORDER_NO,
(SELECT GROUP_CONCAT(DISTINCT ...) FROM wm_wv_detail ... WHERE ... = WWH.WAVE_NO) AS HOMEBASE_IDS,
NULL AS FM_LD_TIME
FROM (
SELECT *
FROM wm_wv_header
WHERE MODIFY_TIME BETWEEN :from_time AND :to_time
AND ORG_ID = :org_id
AND PROJECT_ID = :project_id
ORDER BY CREATE_TIME DESC
LIMIT 50
) WWH
ORDER BY WWH.CREATE_TIME DESC;
这不是说子查询天然比联表快,而是分页被前置之后,明细聚合只处理本页真正需要的数据。
实测结果
使用相同筛选条件并关闭查询缓存后,新结构测试三次:
| 次数 | 耗时 |
|---|---|
| 1 | 0.006605s |
| 2 | 0.006195s |
| 3 | 0.006315s |
平均耗时约为 0.00637s。与原查询中较快的一次 6.225s 相比,该测试场景约提升 977 倍,耗时降低约 99.9%。
这个结果只代表本次实际数据与筛选条件下的验证结论,不能直接推断所有查询场景都会获得相同倍率。
上线前的限制:分页不能写死
性能测试将 LIMIT 50 放到了只查询波次主表的内层子查询中,这正是速度提升的关键。
但系统当前由查询框架在外层追加分页。如果在 Hibernate SQL 中直接写死 LIMIT 50,会导致第二页之后无法正确查询,也可能影响导出流程。因此正式实现必须让分页参数进入内层主表查询,或调整分页方式,确保先分页主表、再聚合明细。
正式发布前还需要验证:默认列表、状态筛选、按出库单查询、翻页、导出,以及创建人和修改人筛选。
另一个应单独修复的正确性问题
原查询中,修改人显示关联疑似仍然使用了创建人字段。这个问题会改变展示结果和筛选行为,应作为单独的缺陷修复提交,不应与性能重构混在一个变更中。
总结
这次排查的路径是:
从日志找到真实慢 SQL
→ 检查索引并清理重复项
→ 尝试新增索引并通过实际结果回退
→ 使用 EXPLAIN 与耗时数据定位结构问题
→ 先分页主表,再聚合明细
最终收益不是来自继续堆索引,而是让数据库只对页面真正需要的记录做聚合处理。对于包含一对多明细聚合和分页的列表查询,这通常比继续给复杂 SQL 打补丁更有效。