# SQL 运营监控查询性能优化方案 ## 问题分析 当前查询耗时约 2 分钟,主要性能瓶颈来自以下几个方面: ### 瓶颈一:CROSS JOIN 笛卡尔积(最严重) ```sql -- DailyMetrics 中: FROM DistinctDates dd CROSS JOIN OrderFullInfo ofi ``` `DistinctDates` 有 N 行(假设 60 天就是 60 行),`OrderFullInfo` 有 M 行(假设 7000 条订单),则这个 CROSS JOIN 产生 **60 × 7000 = 420,000 行**的笛卡尔积,然后再对每一行做 5 个 `COUNT(DISTINCT CASE ...)` 聚合,计算量极大。 ### 瓶颈二:OrderFullInfo 被多次物化 `OrderFullInfo` 这个 CTE 在 `AllDates`(步骤 10)中被引用了 **5 次**,然后在 `DailyMetrics` 里又被 CROSS JOIN 引用一次。MySQL 对 CTE 不做缓存(非 Materialized Hint),每次引用都会重新执行整个计算链路(从 `FormRequestRelation → FormTotalOrderCount → FormLabelPushTimes → FormFirstQualifiedDate → FormLabelRateAndQualifiedDate → OrderAssessment → OrderScanStatus → OrderFullInfo`),造成严重的重复计算。 ### 瓶颈三:label_scan_history 被扫描多次 `label_scan_history` 表在以下 CTE 中被反复全表扫描: - `FormFirstScan`:JOIN scan history - `OrderScanStatus`:GROUP BY NeutralWaybillNumber - `DailyScanCount`:COUNT 全表 - `DailySuccessCount`:WHERE Result=0 - `DailyFailCount`:子查询 + GROUP BY - `DailyStopCount`:WHERE Description LIKE '%STOP%' - `AllDates`:UNION 中三次引用 共计 **7+ 次**扫描,而 `Description LIKE '%成功返回STOP标签%'` 是前缀通配符,无法使用索引。 ### 瓶颈四:GREATEST() 中重复计算 AssessmentBaseTime `OrderAssessment` 中,`GREATEST(CASE...END, flr.FirstQualifiedTime_UTC5)` 这个表达式在同一行被计算了 **3 次**(用于 AssessmentBaseTime、HOUR() 判断、DATE() 计算),每次都重新计算。 ### 瓶颈五:FormRequestRelation 双 LEFT JOIN + COALESCE ```sql LEFT JOIN ArrivalForms f_m ON r.MasterPackageNumber = f_m.HandoverNumber LEFT JOIN ArrivalForms f_b ON r.BillOfLadingNumber = f_b.HandoverNumber WHERE COALESCE(f_m.FormId, f_b.FormId) IS NOT NULL ``` `arrival_handover_forms` 被扫描两次,虽然 `HandoverNumber` 有唯一索引,但 `MasterPackageNumber` 和 `BillOfLadingNumber` 在 `label_replace_requests` 上**没有索引**,导致这两个 JOIN 是全表扫描。 ### 瓶颈六:AllDates 中 UNION 包含冗余子查询 `AllDates` 的 UNION 中有 8 个子查询,其中多个查 `label_scan_history` 的日期,结果存在大量重复,但又必须 UNION 去重,造成额外的排序和去重开销。 --- ## 优化方案:使用物化临时表(存储过程) ### 选择方案:存储过程 + 临时表 **理由**: - 普通视图(VIEW)无法缓存中间结果,MySQL 会每次重新执行,对复杂多步 CTE 无帮助 - 物化视图(MySQL 不原生支持)需要额外维护 - **存储过程 + 临时表** 是 MySQL 中最有效的方式:将每个 CTE 的结果显式写入临时表,并在关键列上建索引,彻底消除重复计算和 CROSS JOIN 笛卡尔积问题 ### 优化要点 #### 1. 消除 CROSS JOIN 笛卡尔积 将 `DailyMetrics` 的计算方式从"日期 × 订单 CROSS JOIN"改为"按订单数据聚合": - 每个订单的 `ReceiptDate`、`LabelRetrievedDate_UTC5`、`AssessmentBaseTime`、`AssessmentTime`、`FirstSuccessDate_UTC5` 都是已知的固定值 - 改用 **预先按订单计算每个指标所属的日期范围**,再按日期 GROUP BY 汇总,避免笛卡尔积 #### 2. 将 OrderFullInfo 写入临时表并建索引 ```sql CREATE TEMPORARY TABLE tmp_order_full_info (...); -- 建索引: ALTER TABLE tmp_order_full_info ADD INDEX idx_receipt_date (ReceiptDate); ALTER TABLE tmp_order_full_info ADD INDEX idx_label_date (LabelRetrievedDate_UTC5); ALTER TABLE tmp_order_full_info ADD INDEX idx_success_date (FirstSuccessDate_UTC5); ALTER TABLE tmp_order_full_info ADD INDEX idx_assessment_base_date (AssessmentBaseDate); ALTER TABLE tmp_order_full_info ADD INDEX idx_assessment_date (AssessmentDate); ``` #### 3. 将 label_scan_history 的聚合结果写入临时表 将 `OrderScanStatus`、`DailyScanCount`、`DailySuccessCount`、`DailyFailCount`、`DailyStopCount` 提前计算并缓存到临时表,避免多次扫描 `label_scan_history`。 #### 4. 为 label_replace_requests 补充关联字段索引 在 `label_replace_requests` 上为 `BillOfLadingNumber` 和 `MasterPackageNumber` 添加索引,加速 `FormRequestRelation` 的 JOIN。 #### 5. 提前计算 AssessmentBaseTime,避免重复计算 在 `OrderAssessment` 阶段先计算出 `AssessmentBaseTime`,后续直接引用,不再重复展开 CASE WHEN。 --- ## 实施步骤 ### 步骤 1:添加缺失的索引(针对 BillOfLadingNumber 和 MasterPackageNumber) 新建文件:`database/migrations/003_add_join_indexes.sql` ```sql -- 为 label_replace_requests 添加 JOIN 关联字段索引 ALTER TABLE label_replace_requests ADD INDEX IF NOT EXISTS idx_bill_of_lading_number (BillOfLadingNumber), ADD INDEX IF NOT EXISTS idx_master_package_number (MasterPackageNumber); ``` ### 步骤 2:将 最新运营监控.sql 重写为存储过程 新建文件:`database/migrations/003_create_sp_operations_monitor.sql` 存储过程逻辑: 1. 建临时表 `tmp_scan_agg`(label_scan_history 聚合,按 NeutralWaybillNumber) 2. 建临时表 `tmp_daily_scan`(每日扫描统计,包含 扫描数/成功数/失败数/STOP数) 3. 建临时表 `tmp_form_order`(FormRequestRelation 的结果,含交接单信息) 4. 建临时表 `tmp_form_stats`(每个 FormId 的 TotalOrderCount、QualifyNeedCount、FirstQualifiedTime) 5. 建临时表 `tmp_order_full`(OrderFullInfo 结果,含 AssessmentBaseTime、AssessmentTime、IsSuccess 等所有字段) 6. 在 `tmp_order_full` 上建必要的日期索引 7. 直接按日期聚合计算各项指标,输出最终结果(无 CROSS JOIN) 调用方式:`CALL sp_GetOperationsMonitor();` ### 步骤 3:新建调用文件(不修改原文件) 新建文件:`运营监控_优化版.sql` 内容为:`CALL sp_GetOperationsMonitor();`,作为新的日常使用入口,原 `最新运营监控.sql` 保持不变。 --- ## 预期优化效果 | 优化点 | 优化前 | 优化后 | |---|---|---| | CROSS JOIN 笛卡尔积 | N日期 × M订单行 | 消除,直接按订单聚合 | | OrderFullInfo 重复计算 | 6+ 次 | 1 次,写入临时表 | | label_scan_history 扫描次数 | 7+ 次 | 2 次(一次聚合,一次日统计) | | BillOfLadingNumber JOIN | 全表扫描 | 索引扫描 | | MasterPackageNumber JOIN | 全表扫描 | 索引扫描 | | AssessmentBaseTime 重复计算 | 3 次 | 1 次 | **预期查询时间:从 120s 降至 5~20s**(取决于数据量)。 --- ## 文件变更清单 | 操作 | 文件路径 | 说明 | |---|---|---| | 新建 | `database/migrations/003_add_join_indexes.sql` | 添加 BillOfLadingNumber、MasterPackageNumber 索引 | | 新建 | `database/migrations/004_create_sp_operations_monitor.sql` | 创建存储过程 `sp_GetOperationsMonitor` | | 新建 | `运营监控_优化版.sql` | 调用存储过程的入口文件(原文件保持不变) | --- ## 注意事项 - 存储过程使用 `DROP TEMPORARY TABLE IF EXISTS` 开头清理,保证每次调用都是全量最新数据 - 临时表生命周期仅限本次连接,不占用持久存储 - 索引使用 `ADD INDEX IF NOT EXISTS`(MySQL 8.0 支持) - 原 `最新运营监控.sql` 文件不做任何修改,保留作为参考