报表大 IN 优化:一条 product_profile 超大 IN SQL 背后的报表任务治理

报表大 IN 优化:一条 product_profile 超大 IN SQL 背后的报表任务治理

0. 先说结论

有些慢 SQL 看起来只是数据库问题:

sql 复制代码
SELECT *
FROM product_profile
WHERE deleted = 0
  AND merchant_id = ?
  AND id IN (?, ?, ?, ...);

IN 里面几百、几千个商品资料 ID,SQL 文本很长,字段还全量返回。

直觉上的优化方案很容易落到这几类:

text 复制代码
给 id 加索引。
把 IN 拆小。
调整慢 SQL 阈值。
改定时任务时间。
加缓存。

这些都可能有用,但如果只盯着一条 SQL,很容易治标不治本。

这次排查真正有价值的地方,不是发现了某个参数,而是把"报表任务"拆开看清楚了:

text 复制代码
慢 SQL 出现在哪个时间窗口?
这个时间是系统时区,还是商户本地时区?
哪几个报表任务连续触发?
为什么单商户会把历史全部 SKU 塞进一条 IN?
未售商品到底要不要进报表?
优化要先保口径,还是直接改查询范围?

我最后更认可的治理路径是:

text 复制代码
第一层:先做 IN 分批,控制单条 SQL 体积,保持报表口径不变。
第二层:再做列裁剪,减少无意义 IO。
第三层:和产品确认报表口径后,改成按需加载商品资料。
第四层:把报表任务的时区、调度窗口、重算范围和观测指标纳入治理。

这篇文章聊一次报表大 IN 排查:从一条 product_profile 超大 IN SQL 开始,怎么通过时间还原、调用链追踪、方案对比和任务治理,把它从"SQL 优化问题"升级成"报表生成链路治理问题"。

1. 现象:一条看起来不太对劲的 SQL

监控里看到的 SQL 形态大概是这样:

sql 复制代码
SELECT id,
       merchant_id,
       product_group_id,
       type,
       combo_mode,
       major_name,
       minor_name,
       product_code,
       remark,
       ...
FROM product_profile
WHERE deleted = 0
  AND merchant_id = ?
  AND id IN (?, ?, ?, ...);

特征很明显:

特征 说明
单商户 merchant_id 只有一个商户
超大 IN id IN (...) 里塞了大量商品资料 ID
全字段 查询没有列裁剪,等价于把商品资料整行拉出
定时出现 集中出现在每天凌晨附近的报表窗口
重复出现 库存类报表和商品类报表相隔不久连续命中

这个时候先不要急着问"为什么 MySQL 没走好索引"。

因为 merchant_id + id 本身通常已经具备较强过滤能力。问题的核心不是单个 ID 查不到,而是系统把太多 ID 放进同一条 SQL 里,同时还返回了不必要的列。

这类 SQL 的风险不只在执行耗时。

它还会带来:

text 复制代码
SQL 文本过长。
数据库解析成本上升。
网络传输变大。
连接持有时间变长。
报表 Job 被拖慢。
同一时间窗口的其他任务被挤压。

如果商户商品量继续增长,原本只是"偶发慢查询"的问题,后面很容易变成"定时任务窗口不稳定"。

2. 第一步不是改 SQL,而是还原时间

排查定时任务问题时,时间非常容易误导人。

比如日志里看到北京时间早上 06:30 附近出现大量报表 SQL,第一反应可能是:

text 复制代码
为什么早上 6 点多跑报表?
是不是定时任务配置错了?
是不是所有商户都挤在北京时间触发?

但如果系统是海外 SaaS,多租户又按商户本地时区跑任务,这个判断就不一定成立。

假设某个商户在 UTC+3 时区:

text 复制代码
北京时间 06:15 = 商户本地 01:15
北京时间 06:30 = 商户本地 01:30

也就是说,监控里看到的"北京时间早上 06:30",实际可能是商户当地的凌晨报表窗口。

这一步很关键,因为它把问题从"系统为什么早上跑任务",变成了:

text 复制代码
商户本地凌晨有哪些报表任务会连续触发?
这些任务是不是共享同一条商品资料加载链路?
调度间隔是否足够?
慢 SQL 是否集中在同一个任务窗口?

我一般会先画一个时间对照表。

平台观测时间 商户本地时间 任务类型 排查判断
06:15 01:15 库存日报类任务 可能触发商品资料加载
06:30 01:30 商品日报类任务 可能再次触发同类加载
06:45 01:45 财务日报类任务 需确认是否经过商品链路
07:15 02:15 业绩类任务 需确认是否与本 SQL 相关

这里有一个经验:定时任务排查不要只看"服务器时间"。必须把服务器时间、平台观测时间、商户本地时间和业务日边界放在同一张表里。

否则你可能会把一个正常的商户本地凌晨任务,看成异常的北京时间任务。

3. 调用链:为什么单商户会拼出超大 IN

时间窗口确认后,下一步是追调用链。

抽象后的链路大概是这样:

text 复制代码
报表调度器
-> 商品日报生成
-> 加载报表日期截止前的商品 SKU
-> 收集这些 SKU 所属的商品资料 ID
-> 查询 product_profile
-> 回填商品名称、分类、编码等展示字段
-> 生成日报行

问题出在"加载商品资料"的边界上。

报表任务真正需要的是当天报表要展示的商品维度信息。

但旧链路为了兼容"全量商品日报"或"已删商品补齐"等逻辑,可能会把某个商户历史截止到报表日的全部 SKU 拉出来,再收集它们对应的全部 product_profile ID,一次性去查商品资料表。

这就解释了为什么 SQL 是单商户超大 IN:

text 复制代码
Job 外层已经按商户分批。
每批只处理一个商户。
但商户内部又把历史全部商品资料 ID 一次性查出。

于是数据库看到的不是"多个商户各查一点",而是:

text 复制代码
某一个大商品量商户,一条 SQL 查几百到几千个 product_profile。

如果餐饮、零售、组合商品、修饰符、套餐子项都共用商品资料体系,商品数量增长会更快。大商户不一定订单多,但只要商品资料历史积累多,报表任务就会把这个问题放大。

4. 根因不是 IN,而是"报表维度加载没有边界"

很多人看到大 IN 后,会直接把结论写成:

text 复制代码
IN 太大,拆分即可。

这个结论只对了一半。

IN 太大是表象,更深一层的问题是:报表维度加载没有边界。

它混在一起处理了三件事:

text 复制代码
当天有销售事实的商品。
历史上存在但当天没有销售的商品。
订单里出现过但商品资料可能已删除或隐藏的商品。

这三类商品的报表语义完全不同。

类型 是否一定要查 说明
有销售或退单事实的商品 报表事实必须展示
已删除但历史订单用过的商品 需要历史回显和差异补齐
没有任何业务事实的未售商品 不一定 取决于产品口径

如果产品口径要求"商品日报只展示有销售或退单事实的商品",那报表根本不应该加载商户历史全部商品。

如果产品口径要求"即使未售商品也要生成 0 行",那可以保留全量加载,但必须承认这是一种业务口径成本:商品越多,报表任务越重。

所以这个问题不能只在技术层面拍脑袋。它需要先分成两个层级:

text 复制代码
技术治理:不改变口径,先把单条 SQL 风险降下来。
业务治理:确认报表展示口径,再决定是否减少加载范围。

5. 为什么不建议第一步就改成按需查询

从工程直觉看,最优雅的方案似乎是:

text 复制代码
只查当天订单涉及的商品。
不查未售商品。

但我不建议第一步就这么改。

原因是它会改变报表口径。

比如旧报表可能有一个隐含行为:每天为所有商品生成日报行,即使当天销量为 0。这个行为技术上很重,但它可能已经被某些客户、导出模板或历史对账流程依赖。

如果没有确认就直接按需查询,慢 SQL 可能消失了,但客户看到的报表行数也变了。

这类优化最危险的地方,不是代码改错,而是把"性能优化"做成了"业务口径变更"。

所以更稳的拆法是:

text 复制代码
Phase 0:IN 分批,保持口径不变。
Phase 1:列裁剪,保持口径不变。
Phase 2:产品确认后,报表按需加载商品资料。
Phase 3:重算、灰度、观测和任务窗口治理。

先把生产风险压住,再谈更彻底的模型优化。

6. 方案一:IN 分批,控制单条 SQL 体积

最小风险方案是给商品资料批量查询加分批。

伪代码大概是这样:

java 复制代码
public Map<Long, ProductProfile> listProfileMap(Long merchantId, Collection<Long> profileIds) {
    if (profileIds == null || profileIds.isEmpty()) {
        return Collections.emptyMap();
    }

    List<Long> distinctIds = profileIds.stream()
        .filter(Objects::nonNull)
        .distinct()
        .collect(Collectors.toList());

    if (distinctIds.size() > 500) {
        log.warn("Large product profile query partitioned, idCount={}", distinctIds.size());
    }

    Map<Long, ProductProfile> result = new LinkedHashMap<>();
    for (List<Long> batch : partition(distinctIds, 500)) {
        List<ProductProfile> profileList = repository.listByMerchantAndIds(merchantId, batch);
        for (ProductProfile item : profileList) {
            result.putIfAbsent(item.getId(), item);
        }
    }
    return result;
}

这里的重点不是 500 这个数字本身,而是要有一个上限。

没有上限时,调用方传 50 个 ID 和 5000 个 ID,对底层查询方法来说是一样的。系统没有任何地方会提醒你"这已经不是普通批量查询了"。

分批以后,单条 SQL 从:

text 复制代码
1 条 SQL,几千个 ID

变成:

text 复制代码
多条 SQL,每条最多 500 个 ID

这不是终极优化,因为总查询量还在。但它能先解决几个生产风险:

text 复制代码
单条 SQL 文本可控。
数据库解析压力可控。
单次网络包可控。
慢 SQL 形态可控。
其他复用批量方法的调用方也能受益。

这里还有一个边界:分批必须继续保留租户条件。

不要为了复用 listByIds 之类的通用方法,把查询退化成:

sql 复制代码
WHERE id IN (...)

在 SaaS 系统里,商品、订单、库存、报表这类商户数据查询必须始终带租户条件:

sql 复制代码
WHERE merchant_id = ?
  AND id IN (...)

性能优化不能破坏数据隔离。

7. 方案二:列裁剪,别把整行商品资料拖出来

第二个低风险优化是列裁剪。

报表生成通常只需要商品资料的一部分字段:

text 复制代码
商品名称
商品编码
商品分类
商品类型
单位或规格展示字段

但旧查询经常是整行返回。

商品资料表越复杂,字段越多,全字段查询的成本越明显。尤其是一些长文本、图片地址、扩展配置、状态字段,在报表回填里可能根本用不到。

所以查询可以从:

sql 复制代码
SELECT *
FROM product_profile
WHERE merchant_id = ?
  AND id IN (...);

改成:

sql 复制代码
SELECT id,
       major_name,
       minor_name,
       product_code,
       product_group_id,
       type
FROM product_profile
WHERE merchant_id = ?
  AND id IN (...);

列裁剪的收益通常没有"减少 ID 数量"那么大,但它胜在风险低。

只要回填字段梳理清楚,它不会改变报表行数,也不会改变统计金额,只是减少 IO 和对象构建成本。

8. 方案三:按需加载,真正减少 ID 数量

更彻底的方案是按需加载。

报表生成前通常已经拿到了事实数据,比如:

text 复制代码
销售商品行
退单商品行
库存变动或库存快照

那么商品资料查询就不应该从"商户历史全量商品"开始,而应该从报表事实反推维度集合:

text 复制代码
报表需要展示哪些商品?
-> 收集这些商品对应的商品资料 ID
-> 批量查询商品资料
-> 回填展示字段

抽象成公式:

text 复制代码
需要加载的商品资料 = 有业务事实的商品资料 ∪ 历史回显补齐所需商品资料

这样才能从根上减少 IN 列表长度。

但这一步必须和产品确认口径:

text 复制代码
无销售、无退单、只有历史存在的商品,要不要出现在商品日报?

如果答案是"不需要",按需加载就是最优方案。

如果答案是"需要",那按需加载就不能直接上,否则报表行数会变。

9. 方案四:改 Cron 只能缓解,不能治本

还有一种常见建议是改调度时间。

比如库存日报和商品日报相隔 15 分钟都打到商品资料表,那就把其中一个任务错开。

这个方案不是没用,它能缓解同一窗口数据库压力。但它不能解决单条 SQL 过大的根因。

如果一个任务本身会生成几千 ID 的 IN,你把它从 01:30 挪到 02:00,它仍然是一条几千 ID 的 IN

所以我会把调度调整归类为运维缓解,而不是代码主方案。

合理的顺序应该是:

text 复制代码
先让单个任务的 SQL 形态健康。
再根据实际耗时调整任务窗口。
最后把任务窗口纳入持续观测。

10. 方案五:缓存不是第一优先级

商品资料读多写少,看起来很适合缓存。

但报表任务里的缓存要谨慎。

第一,报表任务通常是批量冷启动场景。缓存未必已经预热。

第二,商品资料涉及多租户、删除状态、历史回显和截止时间。如果缓存键设计粗糙,很容易出现数据不一致。

第三,缓存能减少数据库读取次数,但不能回答"为什么要加载这么多商品资料"。

所以缓存可以作为后续增强,不适合作为第一阶段主方案。

我更倾向先做:

text 复制代码
分批上限
列裁剪
按需加载
任务观测

等这些基础治理做好,再判断缓存是否必要。

11. 报表任务治理:不要只治理一条 SQL

这次问题真正应该沉淀的,不是"某个查询方法加分批",而是报表任务治理清单。

至少要把以下维度纳入设计:

维度 要回答的问题
时间 任务按服务器时区还是商户时区触发
业务日 报表日边界按哪个时区计算
任务窗口 同类重任务是否集中触发
分批 商户分批、数据分批、SQL 分批是否都有上限
口径 未售商品、退单-only 商品、已删商品是否展示
重算 近几天自动重算,历史数据如何手动回填
对账 优化前后关键指标是否一致
观测 慢 SQL、任务耗时、批次数、失败原因是否可追踪

报表系统最怕两种状态:

text 复制代码
计算逻辑散在各个 Service 里,没人知道最终口径。
任务每天都跑,但没人知道它跑了多久、查了多少、失败过几次。

IN 只是一个信号。它提醒我们:某个报表任务的输入规模已经超过了当初默认实现的承载范围。

12. 验证不能只看"不慢了"

报表优化的验证标准不能只有"慢 SQL 消失"。

至少要有四类验证。

第一,SQL 形态验证。

text 复制代码
单条 IN 数量是否小于阈值。
是否仍然带租户条件。
是否做了列裁剪。
是否没有新增全表扫描。

第二,报表结果验证。

text 复制代码
同一商户、同一报表日,优化前后销量一致。
销售额、成本、毛利等核心金额一致。
商品行数是否符合预期口径。
已删商品历史回显是否正常。

第三,任务窗口验证。

text 复制代码
商品日报任务是否能稳定完成。
库存日报任务是否能稳定完成。
周报、月报在周一或月初是否叠加放大。
连续任务是否互相挤压。

第四,可观测性验证。

text 复制代码
大批量查询是否有 warn 日志。
日志里是否能看到商户、ID 数量、批次数。
任务开始和结束是否可关联。
失败时是否知道是超时、锁等待、SQL 慢还是数据异常。

如果只看到慢 SQL 下降,却没有对比报表结果,那这次优化是不完整的。

报表系统不只是要快,更要能解释。

13. 一个我会接受的落地顺序

如果让我设计这次优化,我会按下面顺序落地。

Phase 0:保护底层批量查询

先给商品资料批量查询补上分批能力。

目标是:

text 复制代码
任何调用方都不能再把无限 ID 拼进一条 SQL。

这一步不改变业务口径,适合快速上线。

Phase 1:报表链路列裁剪

梳理报表实际需要哪些商品字段,只查必要列。

目标是:

text 复制代码
同样的 ID 数量下,减少 IO、网络和对象构建成本。

Phase 2:报表按需加载商品资料

基于销售、退单、库存等事实集合反推商品资料 ID。

目标是:

text 复制代码
从根上减少报表维度加载规模。

但这一步必须先确认"未售商品是否展示"的业务口径。

Phase 3:任务窗口与重算治理

把报表任务的调度、重算、对账、日志和慢 SQL 观测纳入统一规范。

目标是:

text 复制代码
下次不是靠人肉猜测哪个任务在 06:30 触发。

14. 常见反模式

这类问题里,我会特别避免几种做法。

第一,只改慢 SQL 阈值。

慢 SQL 阈值是观测口径,不是性能优化。

第二,只加索引。

如果查询条件已经是 merchant_id + id,索引可能不是主要矛盾。真正的问题是传入规模没有边界。

第三,直接按需查询但不确认口径。

性能好了,报表行数变了,客户对账发现历史数据不一致,问题会更大。

第四,改 Cron 后宣布优化完成。

错峰只能缓解峰值,不会让单条 SQL 更健康。

第五,缓存兜底一切。

缓存会引入一致性、失效和历史口径问题。没有先治理查询边界和报表口径,缓存只是把复杂度藏起来。

15. 小结

一条 product_profile 超大 IN SQL,表面是数据库问题,背后其实是报表任务治理问题。

这次排查给我的启发是:

text 复制代码
先还原时间,不要被服务器时区误导。
再追调用链,不要只盯着 SQL 本身。
先做不改口径的技术止血,再做按需加载的模型优化。
报表优化必须同时验证性能和口径。
大批量查询方法必须有上限,不能把调用方的规模风险直接传给数据库。

很多后端优化不是把一条 SQL 写得更漂亮,而是把它为什么出现、在哪个任务里出现、是否符合业务口径、是否可观测,全部查清楚。

这样才算真正把问题关掉。

相关推荐
知无不研2 小时前
std::function在使用时遇到的问题
c++·算法·st·function
薛定e的猫咪2 小时前
(IEEE Transactions 2025)自适应元强化学习动态柔性作业车间调度框架
网络·人工智能·算法
码场老菜鸟2 小时前
C++ 指针的深度理解与应用
java·c++·算法
fpcc3 小时前
数据结构和算法—最短路径的算法
数据结构·算法
茉莉玫瑰花茶3 小时前
RAG 数据检索
人工智能·算法·机器学习
speop3 小时前
hell-gpu| TASK01-2
linux·运维·算法
leoZ2314 小时前
AI+前端提效-12 AI辅助前端性能优化与监控:从开发到线上全流程提效
前端·人工智能·神经网络·自然语言处理·性能优化·keras·知识图谱
Navigator_Z4 小时前
LeetCode //C - 1223. Dice Roll Simulation
c语言·算法·leetcode
171320330计算机毕设编程4 小时前
2027计算机毕设五大方向对比&选题推荐
java·ide·python·算法·django·php·推荐算法