把 SQL 从 2700 次降到 4 次:一个 Java 后端对存量报表的 N+1 手术

把 SQL 从 2700 次降到 4 次:一个 Java 后端对存量报表的 N+1 手术

面向读者的快速结论:报表慢往往不是"单条 SQL 写得差",而是循环里逐条查库。把 N 次单查换成 1 次批量 IN,再用内存 Map 做 O(1) 取值------业务逻辑一行不改,接口从"打开就卡"变秒开。本文用一个火电报表系统的真实改造讲透这套方法,文末附可复现 Demo。


一、事故现场:报表又卡了

"这个报表点开就转圈,几分钟出不来,领导催着要看数。"

这是我接手某火电节能分析模块时听到的第一句抱怨。四张报表------值际分析、全厂值际分析、日分析、全厂日分析------是运维每天看能耗的核心入口,但每张打开都要等很久。

第一反应当然是"查慢 SQL"。但把 Mapper 里的 SQL 一条条拎出来看,单条都不慢:索引齐全、返回行数可控、执行毫秒级。

那问题出在哪?

二、病根:循环里逐条查库(N+1)

看代码才发现,瓶颈根本不在 SQL 本身,而在调用方式。历史实现是典型的 N+1 循环:

java 复制代码
// 伪代码示意(非原码)
// 值际报表:外层 30天×3班 ≈ 90 个班组,内层 33 个测点
for (RotatingTimeDto shift : rotatingTimeDtos) {      // ~90 次
    for (PointDto point : realPoints) {               // ~33 次
        Double v = mapper.queryCountConsumptionD(     // 1 次 SQL
            shift.getStartTime(), shift.getEndTime(), point.getSaveId());
        // 塞进单元格...
    }
}
// 90 × 33 ≈ 3000 次 SQL

值际两张表:约 3000 次 SQL (90 班组 × 33 测点); 日分析两张表:约 1000 次 SQL(30 天 × 33 测点)。

每次查询都有完整的 DB 往返:连接、解析、执行、返回。单条 1ms,一千次就是秒级,三千次直接奔着分钟级去------用户体感就是"卡死"。

核心判断:单条 SQL 不慢 ≠ 接口不慢。循环次数才是真凶。

三、手术:批量 IN + 内存 Map 索引(四步法)

幸运的是,Mapper 层此前为其他报表沉淀过批量查询方法 ------IN saveIds 一次拉全量。所以这次改造零新增 SQL、零 DDL、纯代码层

四步模板:

第 1 步:收集真实测点 ID

遍历报表配置,跳过"计算列"和没有绑测点的行,只留真实测点的 saveId 集合。

java 复制代码
List<Long> saveIds = reportFormsDtos.stream()
    .filter(dto -> !isCalcColumn(dto))        // 跳过计算列
    .filter(dto -> dto.getReportPoint() != null
                && !"无测点".equals(dto.getReportPoint()))
    .map(dto -> dto.getReportPoint().getSaveId())
    .distinct()
    .collect(Collectors.toList());

第 2 步:一次批量查询

把整个时间范围 + 全部 saveIds 一次性 IN 查完:

java 复制代码
// 值际两张:1 次 SQL 拿全月班值
List<CountConsumptionDDto> all =
    mapper.queryCountConsumptionDBySaveIds(unitId, start, end, saveIds);

第 3 步:建内存 Map 索引

批量结果一次性灌进 Map<时间区间, Map<saveId, 值>>,原循环内 O(1) 取值,取代逐条查库:

java 复制代码
Map<String, Map<String, Double>> valueMap = new HashMap<>();
for (CountConsumptionDDto dto : all) {
    String key = dto.getStartTime() + "|" + dto.getEndTime();
    valueMap.computeIfAbsent(key, k -> new HashMap<>())
            .put(dto.getSaveId(), dto.getPointAvg());
}

// 原双重循环体内:O(1) 取值替代 SQL
Double v = valueMap.get(key) == null ? null
          : valueMap.get(key).get(saveId);

第 4 步:业务逻辑一行不动

动态表头读取、计算列公式、总计行、返回结构------全部保持原样。改的只是"数据怎么来",不是"数据怎么算"。

四、效果:SQL 次数从千级降到个位数

报表 改前 SQL 次数 改后 SQL 次数
节能值际分析 ~3000(班组×测点) 1
全厂节能值际分析 ~3000(班组×测点) 1
节能日分析 ~1000(天×测点) 2(日期列表 + 批量)
全厂节能日分析 ~1000(天×测点) 2(日期列表 + 批量)

接口从"打开就卡"变成秒开。同样的方法在同一模块其他报表上也复现过战绩:

场景 优化前 优化后
值际月负荷率 450 次 SQL / 40.7s 2 次 SQL / 亚秒级
值际指标分析 2700 次 SQL 4 次 SQL
指标统计分析 ~960 网络请求 / 20s ~96 请求 / 2s

五、两个差点翻车的坑(实战价值所在)

坑 1:批量 SQL 多带了一个过滤条件,可能漏数

老的单条 SQL 不带 unit_id 过滤,而现成的批量方法and unit_id = #{unitId} 。如果表里某些测点的 unit_id 与传入值不一致,改造后会悄悄漏数据------报表"看起来正常",数字却是错的。这种 bug 最阴险。

对策:新旧数据逐行对比验收 。拿同一个月,改造前后各查一次,逐行核对,重点盯"总计"行和计算列。发现漏数,就把批量 SQL 里的 unit_id 条件去掉,保持与原语义一致。

坑 2:"全厂"口径可能是历史遗留的硬编码

全厂两张报表里,查询参数传的是固定值,并不是字面意义的"1、2 号机合计"。优化时我选择保持原语义不动------性能改造只负责"快",不擅自"改口径",口径问题单独提出来确认。

存量改造三原则:不改业务逻辑、不加 DDL、新旧数据逐行对比验收。性能优化最怕"改快了但改错了",这三条是安全底线。

六、方法论沉淀:这套打法可以复制

  1. 识别 N+1:看到"循环里查库"先警觉------单条 SQL 再快,架不住循环上千次
  2. 四步模板:收集 ID → 一次批量 IN → 内存 Map O(1) 取值 → 业务逻辑不动
  3. 复用 > 重写:先翻 Mapper 层有没有现成批量方法,别急着造新 SQL
  4. 配置驱动:表头、测点一律从配置动态读取,禁止硬编码(这次能纯代码层改造,靠的就是当初"动态读取"的底子)
  5. 验收兜底:性能改造必须配"新旧数据对比",数字对不上一切免谈

七、可复现 Demo(仓库里跑得起来)

光讲方法论太空。我把这套逻辑做成了可运行的 FastAPI Demo ,放在开源仓库里------模拟火电测点时序数据,同一报表接口提供 n1(循环逐点)和 batch(批量)两种实现,内置查询计数器:

scss 复制代码
模式       查询次数    耗时(含模拟DB往返)
n1         1800 次    ~4.5s
batch      1 次       ~0.35s    ← 次数 1800x、耗时 ~13x

真实 HTTP 请求下 5 测点 5 天:batch 1 次 / 3.3ms vs n1 25 次 / 9.8ms------和文章里的"千级 → 个位数"完全同构。配套还有一个配置驱动的报表工具:一张报表 = 一个 YAML(表头/测点/计算列全声明式),新增报表零代码改动。

📦 仓库地址(方法论 + 三份脱敏 Case Study + 可运行代码): github.com/yang-shenxu...

clone 下来 pip install -r requirements.txt && python benchmark.py 就能复现上面的数字。

八、写在最后

这次改造给我最大的收获不是"会写批量 SQL",而是:存量系统的问题,往往不在你想的地方。单条 SQL 慢是显性的,循环次数多是隐性的;新代码可以按最佳实践写,老代码考验的是你能不能读懂它、然后在不动业务的前提下让它快起来。

而这套"批量取数 + 内存索引"的方法论,后来被我用在了同一批新报表的从 0 到 1 设计里------先想清楚怎么取数,再写业务逻辑。

如果这篇对你有启发,欢迎 Star 仓库。下一篇准备写《RAG 防幻觉实战:30 轮提示词迭代的 4 条铁律》------把 AI 问答里"一本正经胡说八道"的问题讲透。


关于作者:Java 后端工程师,主要工作在火电能源系统的报表、数据管道与 AI 落地。对"存量系统性能改造"和"AI 应用工程化交付"两个方向有实战积累。

相关推荐
2301_800074213 小时前
mysql DQL
数据库·sql·mysql
Sagittarius_A*6 小时前
【好靶场】SQL注入-时间盲注
数据库·sql
崖边看雾7 小时前
MySQL——SQL 经典练习题:学生选课成绩查询(附详细注释)
大数据·数据库·sql·mysql
2601_960356389 小时前
库存分析岗位秋招准备:SQL、Excel、WMS与供应链指标
数据库·sql·excel
山峰哥9 小时前
造价算量系统SQL优化,慢查询从9秒压到35毫秒‌
大数据·数据库·sql·编辑器·深度优先
StarRocks_labs9 小时前
StarRocks 如何查询 Paimon 半结构化数据?Variant、Shredding 与 SQL 实践
starrocks·sql·paimon·variant·shredding
姜太小白1 天前
【Oracle】记排查sh脚本调用SQL执行卡顿的排查总结
数据库·sql·oracle
镜舟科技1 天前
为什么 Text-to-SQL 总是停在 Demo?
数据库·sql·demo·text-to-sql·镜舟科技·mip·语义视图
大牧师1 天前
TypeORM 学习教程
数据库·sql·学习·node.js·orm·nest.js·typeorm