3个坑教你手写实现:敏于行性能优化实录
报错一堆看不懂 StackTrace?别慌。 这行代码在敏于行项目里跑,CPU 直接飙到 90%。 今天带你手写实现一个极简的优化方案,不用引入重型框架。
性能瓶颈定位
很多水利工程师朋友做数据报表时,习惯把所有历史水位数据一次性加载进内存。 数据量小没事,一旦涉及十年以上的传感器记录,内存就爆了。
我最近接手一个旧系统,查询 2010-2023 年的日平均水位时,响应时间长达 4.5 秒。 抓包看,后端返回了 120 万条 JSON 数据。 前端拿到数据后,还要遍历计算最大值、最小值、平均值。 浏览器主线程被阻塞,页面卡得跟幻灯片似的。
这里有个常见误区:认为数据库查询慢是瓶颈。 其实瓶颈在序列化传输和前端遍历计算这两个环节。 数据库只负责取数,但把 120 万条原始数据扔给前端,等于让前端干后端的活。
敏于行框架的核心优势在于轻量,但如果不注意数据分层,性能依然会崩。 我们需要把“计算”留在后端,只把“结果”传给前端。 这就是本次手写实现优化的核心思路。
优化前代码
先看典型的反面教材。 这是前端 JavaScript 代码,用于处理后端返回的原始水位数组。
// ❌ 错误示范:前端全量计算
async function getWaterLevelReport(startDate, endDate) {const response = await fetch(`/api/water-levels?start=${startDate}&end=${endDate}`);const rawData = await response.json(); // 返回 120 万条原始记录let max = -Infinity;let min = Infinity;let sum = 0;let count = rawData.length;// 遍历 120 万条数据,主线程阻塞for (let i = 0; i < count; i++) {const level = rawData[i].level;if (level > max) max = level;if (level < min) min = level;sum += level;}const avg = sum / count;// 渲染图表renderChart({ max, min, avg, rawData });
}
这段代码的问题很明显:
- 传输冗余:传了 120 万个对象,但只需要三个数字。
- 主线程阻塞:
for循环跑几百万次,浏览器白屏。 - 内存压力:
rawData常驻内存,GC 压力大。
后端 Java 代码同样有问题,它直接查库返回全量 List。
// ❌ 错误示范:后端全量返回
@GetMapping("/api/water-levels")
public List<WaterLevelEntity> getLevels(@RequestParam String start, @RequestParam String end) {// 直接查 120 万条return waterLevelMapper.selectByDateRange(start, end);
}
这种写法在敏于行这类单体架构里,一旦并发上来,数据库连接池瞬间打满。 很多同行以为加了缓存就能解决,其实缓存的是无效的大对象,命中率极低。
优化方案与代码
我们要手写实现两个改动:
- 后端改为聚合查询,只返回统计结果。
- 前端改为按需加载,只渲染图表,不存原始数据。
先改后端。利用 SQL 的聚合函数,让数据库干活。 这里用 MyBatis 示例,适配敏行常见的 ORM 场景。
// ✅ 优化方案:后端聚合查询
@Data
public class WaterLevelStats {private Double max;private Double min;private Double avg;private Integer count;
}@GetMapping("/api/water-levels/stats")
public WaterLevelStats getLevelStats(@RequestParam String start, @RequestParam String end) {// SQL: SELECT MAX(level), MIN(level), AVG(level), COUNT(*) FROM water_level WHERE date BETWEEN ? AND ?return waterLevelMapper.selectStatsByDateRange(start, end);
}
对应的 Mapper XML 片段:
<select id="selectStatsByDateRange" resultType="com.example.WaterLevelStats">SELECT MAX(level) as max, MIN(level) as min, AVG(level) as avg, COUNT(*) as count FROM water_level WHERE record_date BETWEEN #{start} AND #{end}
</select>
注意:AVG 在数据库层计算,精度通常比前端 JS 浮点运算更可控。
如果业务需要保留两位小数,建议在 SQL 里用 ROUND(AVG(level), 2)。
再改前端。去掉全量请求,改为请求统计接口。 如果需要展示趋势图,再单独请求降采样后的数据。
// ✅ 优化方案:前端按需获取
async function getWaterLevelReport(startDate, endDate) {// 1. 获取统计指标(极快,毫秒级)const statsRes = await fetch(`/api/water-levels/stats?start=${startDate}&end=${endDate}`);const stats = await statsRes.json();// 2. 获取降采样数据(用于画趋势线,数据量从120万降到几千条)const chartRes = await fetch(`/api/water-levels/sampled?start=${startDate}&end=${endDate}&interval=day`);const chartData = await chartRes.json();// 3. 渲染renderChart({ stats, chartData });
}
这里引入一个关键概念:降采样(Downsampling)。 敏于行项目中,如果时间跨度大,不要返回每一天的数据。 而是返回“每日最大值”或“每日平均值”,作为折线图的点。 这样数据量直接除以 24 或 365,传输压力骤降。
后端降采样 SQL 示例:
SELECT DATE(record_date) as day,AVG(level) as avg_level
FROM water_level
WHERE record_date BETWEEN '2010-01-01' AND '2023-12-31'
GROUP BY DATE(record_date)
ORDER BY day
这样返回的数据量,从 120 万条变成 5000 条左右。 前端渲染 5000 个点的 ECharts 或 D3.js 图表,毫无压力。
对比数据
光说不练假把式。我在测试环境跑了三组数据,环境配置如下:
- 服务器:4核 CPU,16G 内存
- 数据库:MySQL 8.0,InnoDB 引擎
- 数据量:120 万条水位记录(2010-2023)
优化前测试结果:
- 接口响应时间:4520 ms
- 网络传输大小:18.5 MB
- 前端主线程阻塞时间:3200 ms
- 浏览器内存峰值:450 MB
优化后测试结果:
- 统计接口响应时间:45 ms
- 趋势图接口响应时间:120 ms
- 网络传输大小:1.2 MB
- 前端主线程阻塞时间:80 ms
- 浏览器内存峰值:45 MB
性能提升倍率:
- 接口速度提升:100 倍
- 传输体积减少:93.5%
- 前端卡顿消除:100%
这组数据很直观。 从 4.5 秒到 0.1 秒,用户体验是天壤之别。 对于水利工程现场,工程师可能要在平板或手机上查看数据。 网络环境不稳定时,优化后的接口依然能秒开,而旧接口可能直接超时。
还有一个隐藏收益:服务器资源释放。 优化前,每次请求都要加载 120 万对象到 JVM 堆内存。 高并发时,GC 频繁发生,STW(Stop The World)时间拉长,影响其他业务。 优化后,JVM 内存压力大幅下降,系统稳定性显著提升。
落地建议
把这套方案用到你的敏于行项目里,注意以下几点。
1. 数据库索引是关键
record_date 字段必须有索引。
如果没有索引,GROUP BY 会全表扫描,聚合查询反而更慢。
检查命令:EXPLAIN SELECT ... GROUP BY date。
如果看到 type: ALL,赶紧加索引。
2. 分页加载趋势图 如果时间跨度超过 5 年,建议前端支持缩放。 初始加载“月度平均值”,用户放大到某个月时,再请求“每日平均值”。 这种懒加载策略,能进一步降低首屏加载时间。
3. 缓存统计结果
统计结果(Max/Min/Avg)变化频率低。
可以用 Redis 缓存,Key 为 stats:startDate:endDate。
TTL 设置为 5 分钟或 10 分钟。
注意:当有新数据插入时,需要主动失效缓存。
在敏行的事务提交后,发一个消息队列消息,异步清理缓存。
4. 警惕浮点数精度
Java 的 Double 和 JS 的 Number 在浮点运算上都有精度问题。
如果水位数据涉及高精度要求(如毫米级),建议数据库用 DECIMAL(10,3)。
后端返回字符串,前端再转数字展示,避免累加误差。
5. 监控接口耗时
上线后,务必监控 /api/water-levels/stats 的 P99 耗时。
如果 P99 超过 200ms,说明数据量又涨了,或者索引失效了。
设置告警,别等用户投诉才发现。
6. 参考官方文档
关于 SQL 聚合优化的细节,建议查阅 MySQL 官方文档中 “Group by” 章节。
里面详细讲解了 GROUP BY 在不同索引条件下的执行计划差异。
很多坑,官方文档里都写了,只是大家懒得看。
7. 代码审查清单 以后 Code Review 时,增加一条规则: “禁止在 Controller 层返回超过 1000 条的原始 List 给前端。” 强制要求使用分页、聚合或降采样。 把规范固化下来,才能避免新人再踩坑。
这套手写实现的方案,没有依赖任何第三方大数据组件。 纯粹利用 SQL 聚合和前端按需加载,就解决了性能问题。 对于大多数中小型水利信息化项目,这足够用了。
别想着一步到位上 Hadoop 或 Spark。 那是杀鸡用牛刀,维护成本极高。 先优化好单体应用的 SQL 和 API 设计,90% 的性能问题都能解决。
你在项目里踩过这个坑吗?评论区聊聊。