30万数据导出从2分钟到15秒:一场与内存溢出的生死较量
摘要 :本文记录了TJ-PAS系统大数据导出功能的性能优化历程。面对30万数据导出时服务器直接OOM(内存溢出)的致命问题,我们历经三次架构迭代,最终通过Raw JDBC流式查询 + 零反射Map映射 + 智能样式策略的组合拳,将导出时间从120秒压缩至15秒 ,内存占用从GB级降至MB级。这是一场关于性能、内存和用户体验的极限挑战。
📖 故事背景:当用户点击"导出"按钮时
在TJ-PAS系统的生产基础数据查询页面,业务人员经常需要导出数十万条生产线工艺路线数据。最初的实现看似简单:
java
// V1.0 灾难性实现
@GetMapping("/exports")
public void exports(Map<String, Object> params, HttpServletResponse response) {
// 一次性查询所有数据到内存
List<ScJcScx> allData = scJcScxService.list(queryWrapper);
// 一次性写入Excel
EasyExcel.write(response.getOutputStream(), ScJcScxExcel.class)
.sheet("数据")
.doWrite(allData);
}
💥 第一次事故:服务器崩溃
当某天业务人员导出30万条数据时,监控告警疯狂响起:
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3236)
at com.alibaba.excel.write.ExcelBuilderImpl.addContent(ExcelBuilderImpl.java:91)
现场惨状:
- ⏱️ 导出耗时:超过2分钟仍在加载,前端超时断开连接
- 💾 内存占用:JVM堆内存瞬间飙升至4GB+,触发Full GC后仍无法回收
- 🔥 服务影响:同一服务器上其他模块响应变慢,甚至出现连锁雪崩
根本原因分析:
- 全量加载:30万条POJO对象全部驻留内存(每条约500字节,共150MB)
- EasyExcel内部缓冲:SXSSFWorkbook虽使用磁盘缓冲,但仍需维护滑动窗口(默认100行)
- 样式处理器回调 :
ExportStyleHandler.afterCellDispose被调用900万次(30万行×30列),每次创建CellStyle对象 - GC压力:大量临时对象导致频繁Full GC,STW(Stop-The-World)时间累计超过30秒
🚀 第一次优化:分页导出 + SXSSFWorkbook(2025年Q4)
技术方案
意识到"全量加载"是罪魁祸首后,我们引入了分页机制:
java
// V2.0 分页版
int pageNum = 1;
int pageSize = 10000;
while (true) {
IPage<ScJcScx> page = scJcScxService.page(new Page<>(pageNum, pageSize), queryWrapper);
if (page.getRecords().isEmpty()) break;
excelWriter.write(page.getRecords(), writeSheet);
pageNum++;
}
excelWriter.finish();
✅ 改进点
- 内存降低:每批仅加载1万条记录,峰值内存降至~500MB
- 稳定性提升:依然触发OOM
❌ 遗留问题
- 速度未改善 :仍需90-120秒 ,因为:
- MyBatis-Plus分页需要先执行
COUNT(*)查询(Oracle对大表COUNT极慢,耗时15-20秒) - 每次分页都是一次独立SQL查询,网络往返次数多(30万/1万=30次)
- POJO对象创建+反射赋值开销大(30万次BeanUtils.copyProperties)
- MyBatis-Plus分页需要先执行
用户反馈:"还是崩了,本地电脑性能好,但还是要等两分钟,能不能再快点?"
🔥 第二次优化:消除COUNT查询 + 增大分页(2026年初)
技术洞察
通过分析日志发现,COUNT查询占总耗时的20%:
【性能诊断】COUNT查询耗时: 18234ms
【性能诊断】第1页数据查询耗时: 3421ms
【性能诊断】第2页数据查询耗时: 3156ms
...
优化策略
核心思路:既然不需要精确总数,为何不采用"游标式"分页?
java
// V3.0 无COUNT版
int pageNum = 1;
int pageSize = 30000; // 增大批次
while (true) {
List<Map<String, Object>> pageData = dataProvider.provide(pageNum, pageSize);
if (pageData.isEmpty()) break;
// 转换为Excel行数据
List<List<Object>> rows = buildRows(pageData, fields);
excelWriter.write(rows, writeSheet);
if (pageData.size() < pageSize) break; // 最后一批
pageNum++;
}
✅ 改进点
- 消除COUNT:节省15-20秒
- 减少查询次数:从30次降至10次(pageSize从1万增至3万)
- 总耗时降至60-70秒
❌ 仍存在的瓶颈
通过精细化性能剖析(添加每个步骤的时间戳),我们发现新的热点:
========== 导出完成统计 ==========
总行数: 300000
总耗时: 65432ms (65.4秒)
--- 时间分布 ---
数据库查询总计: 35000ms (53.5%) ← 主要瓶颈
构建行数据总计: 18000ms (27.5%) ← 次要瓶颈
Excel写入总计: 8000ms (12.2%)
Writer初始化+finish: 4432ms (6.8%)
====================================
问题分析:
- MyBatis-Plus反射开销:每次查询都需要将ResultSet映射为POJO,涉及大量反射操作
- 字段名匹配低效 :
getValueFromMap方法中,每个字段需要3次containsKey检查(原始key、大写key、下划线key) - 样式处理器逐格回调 :
ExportStyleHandler.afterCellDispose仍被调用900万次
⚡ 第三次优化:终极方案 - Raw JDBC + 零反射 + 智能样式(2026年4月)
架构重构
这次我们决定彻底抛弃ORM框架,直接使用JDBC进行流式查询:
java
// V4.0 终极版 - Raw JDBC流式导出
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
// ★ 关键1: Oracle游标预取优化
DataSource ds = jdbcTemplate.getDataSource();
conn = DataSourceUtils.getConnection(ds);
ps = conn.prepareStatement(jdbcSql);
ps.setFetchSize(5000); // 每次网络往返预取5000行(替代默认10行)
rs = ps.executeQuery();
// ★ 关键2: 流式读取ResultSet,边读边写
EnhancedExportUtil.exportStreamMap(response,
(pageNum, pageSizeInner) -> {
List<Map<String, Object>> batch = new ArrayList<>(pageSizeInner);
int count = 0;
while (count < pageSizeInner && rs.next()) {
Map<String, Object> row = new HashMap<>(colCount);
for (int c = 0; c < colCount; c++) {
row.put(colNames[c], rs.getObject(c + 1));
}
batch.add(row);
count++;
}
return batch;
},
ScJcScxExcel.class, tid, customTableColumnsService::selectByUserAndTable,
"生产基础数据查询"
);
} finally {
// 确保资源释放
if (rs != null) rs.close();
if (ps != null) ps.close();
if (conn != null) DataSourceUtils.releaseConnection(conn, ds);
}
核心技术点详解
1️⃣ Oracle Fetch Size优化
问题 :Oracle JDBC驱动默认fetchSize=10,意味着每读取10行就要发起一次网络请求。对于30万数据,需要3万次网络往返!
解决 :设置ps.setFetchSize(5000),将网络往返次数降至60次,减少99.8%的网络开销。
java
// 性能对比
fetchSize=10: 300000/10 = 30000次网络请求 → 耗时~25秒
fetchSize=5000: 300000/5000 = 60次网络请求 → 耗时~0.5秒
2️⃣ 零反射Map映射
问题 :MyBatis-Plus将ResultSet映射为POJO时,需要通过反射调用setter方法,30万条×12个字段=360万次反射调用。
解决 :直接使用ResultSet.getObject()填充HashMap,完全避免反射。
java
// 旧方式:反射开销大
ScJcScx entity = new ScJcScx();
entity.setBjbm(rs.getString("BJBM")); // 反射调用setter
entity.setBjmc(rs.getString("BJMC"));
// ... 重复12次
// 新方式:零反射
Map<String, Object> row = new HashMap<>(12);
row.put("BJBM", rs.getObject(1)); // 直接取值
row.put("BJMC", rs.getObject(2));
// ... 循环12次
3️⃣ 预计算Lookup Keys
问题 :在buildRows中,每个字段需要从Map中查找值,原实现需要3次containsKey检查:
java
private static Object getValueFromMap(Map<String, Object> map, String field) {
if (map.containsKey(field)) return map.get(field); // 第1次
String upper = field.toUpperCase();
if (map.containsKey(upper)) return map.get(upper); // 第2次
String us = camelToUnderscore(field).toUpperCase();
if (map.containsKey(us)) return map.get(us); // 第3次
return null;
}
对于30万行×12字段,需要1080万次containsKey调用!
解决 :在第一页数据到达时,预计算每个字段的实际key格式,后续直接使用map.get(precomputedKey):
java
// 预计算(仅执行一次)
private static void calibrateLookupKeys(ExportPlan plan, Map<String, Object> firstRow) {
List<String> calibrated = new ArrayList<>(plan.fields.size());
for (String field : plan.fields) {
calibrated.add(findMapKey(firstRow, field)); // 找到实际key格式
}
plan.lookupKeys = calibrated;
}
// 快速读取(每行每字段仅1次get)
private static List<List<Object>> buildRowsFast(List<Map<String, Object>> dataList, ExportPlan plan) {
List<String> lookupKeys = plan.lookupKeys;
List<List<Object>> rows = new ArrayList<>(dataList.size());
for (Map<String, Object> row : dataList) {
List<Object> cells = new ArrayList<>(lookupKeys.size());
for (int i = 0; i < lookupKeys.size(); i++) {
cells.add(formatValue(row.get(lookupKeys.get(i)))); // 直接get,无containsKey
}
rows.add(cells);
}
return rows;
}
性能提升:从1080万次containsKey降至360万次get,减少66%的Map查找开销。
4️⃣ 智能样式策略
问题 :ExportStyleHandler.afterCellDispose在每个单元格写入时被调用,30万行×30列=900万次回调。每次回调中创建CellStyle、Font对象,导致:
- CPU密集:900万次方法调用
- 内存压力:大量临时CellStyle对象
解决:根据数据量动态选择样式策略:
java
private static final int ALTERNATING_ROW_THRESHOLD = 5000;
// 先查询第一页,判断数据量
List<Map<String, Object>> firstPageData = dataProvider.provide(1, STREAM_PAGE_SIZE);
if (firstPageData.size() >= ALTERNATING_ROW_THRESHOLD) {
// 大数据量:使用高性能样式(无隔行变色)
writerBuilder.registerWriteHandler(buildFastStyleStrategy());
} else {
// 小数据量:使用自定义样式(有隔行变色)
writerBuilder.registerWriteHandler(new ExportStyleHandler());
}
高性能样式策略 :使用EasyExcel原生HorizontalCellStyleStrategy,在Sheet级别一次性设置样式,完全消除逐格回调:
java
private static HorizontalCellStyleStrategy buildFastStyleStrategy() {
WriteCellStyle headStyle = new WriteCellStyle();
headStyle.setFillForegroundColor(IndexedColors.PALE_BLUE.getIndex());
// ... 设置表头样式
WriteCellStyle dataStyle = new WriteCellStyle();
dataStyle.setFillForegroundColor(IndexedColors.WHITE.getIndex());
// ... 设置数据行样式
return new HorizontalCellStyleStrategy(headStyle, dataStyle);
}
性能对比:
ExportStyleHandler(逐格回调): 900万次回调 → 耗时~25秒
HorizontalCellStyleStrategy: 0次回调 → 耗时~2秒
5️⃣ 子批次交替构建+写入
问题:原实现先构建整页的行数据(3万行),再一次性写入Excel。这导致峰值内存=3万行Map数据+3万行Excel行对象。
解决:将每页数据分为多个子批次(5000行),构建一批、写入一批,交替进行:
java
for (int offset = 0; offset < pageSize; offset += WRITE_SUB_BATCH_SIZE) {
int end = Math.min(offset + WRITE_SUB_BATCH_SIZE, pageSize);
List<Map<String, Object>> subData = pageData.subList(offset, end);
// 构建子批次行数据
List<List<Object>> subRows = buildRowsFast(subData, plan);
// 立即写入Excel
excelWriter.write(subRows, writeSheet);
}
内存优化 :峰值内存从pageData + 全量rows降至pageData + subBatch,降低83%。
📊 性能对比:三次优化的成果
| 指标 | V1.0(全量加载) | V2.0(分页) | V3.0(无COUNT) | V4.0(终极版) | 提升倍数 |
|---|---|---|---|---|---|
| 导出耗时 | >120秒(OOM) | 90-120秒 | 60-70秒 | 12-18秒 | 6.7x |
| 峰值内存 | 4GB+(OOM) | ~500MB | ~400MB | ~50MB | 80x |
| COUNT查询 | ✓(18秒) | ✓(18秒) | ✗ | ✗ | - |
| 网络往返 | N/A | 30次 | 10次 | 60次(但fetchSize优化) | - |
| 反射调用 | 360万次 | 360万次 | 360万次 | 0次 | ∞ |
| 样式回调 | 900万次 | 900万次 | 900万次 | 0次 | ∞ |
| 用户体验 | ❌ 崩溃 | ⚠️ 慢 | ⚠️ 较慢 | ✅ 快 | - |
详细性能剖析(V4.0)
========== 导出完成统计 ==========
文件名: 生产基础数据查询
总行数: 300000
总耗时: 15234ms (15.2秒)
--- 时间分布 ---
数据库查询总计: 6500ms (42.7%) ← JDBC fetchSize优化
构建行数据总计: 4200ms (27.6%) ← 零反射+预计算Key
Excel写入总计: 3100ms (20.3%) ← 智能样式策略
Writer初始化+finish: 1434ms (9.4%)
====================================
🎯 关键技术总结
1. 流式处理思想
核心原则:数据不应全量加载到内存,而应像水流一样,边读边处理边释放。
传统方式: 数据库 → 全量加载到内存 → 处理 → 输出
流式方式: 数据库 → [读一批 → 处理一批 → 输出一批 → 释放] × N次
2. 消除隐藏开销
很多性能问题隐藏在框架的"黑盒"中:
- MyBatis-Plus反射 :看似简单的
list()调用,背后是360万次反射 - JDBC默认fetchSize:看似正常的查询,背后是3万次网络往返
- EasyExcel样式回调:看似优雅的注解,背后是900万次方法调用
教训:不要盲目信任框架的默认行为,关键路径必须深入底层优化。
3. 空间换时间 vs 时间换空间
- 预计算Lookup Keys:用少量内存(几十字节的key缓存)换取66%的Map查找时间
- 智能样式策略:用牺牲隔行变色(视觉体验)换取80%的样式处理时间
权衡原则:在大数据场景下,优先保证性能和稳定性,适度牺牲非核心体验。
4. 精细化性能剖析
没有测量就没有优化。我们在每个关键步骤添加时间戳:
java
long planStart = System.currentTimeMillis();
ExportPlan plan = buildExportPlan(excelClass, columnConfigs);
log.info("【步骤1-构建计划】耗时={}ms", System.currentTimeMillis() - planStart);
long precomputeStart = System.currentTimeMillis();
precomputeLookupKeys(plan);
log.info("【步骤2-预计算Key】耗时={}ms", System.currentTimeMillis() - precomputeStart);
通过这些日志,我们精准定位了瓶颈所在,而不是凭感觉猜测。
💡 通用最佳实践
✅ 推荐做法
- 大数据导出必用流式:无论数据量多大,都应采用分页/游标式读取
- JDBC层优化fetchSize:Oracle设为5000-10000,MySQL设为1000-5000
- 避免COUNT查询:除非必须显示总数,否则采用"读到空为止"策略
- 零反射Map映射:对于纯导出场景,直接用Map比POJO更高效
- 智能样式策略 :大数据量时使用
HorizontalCellStyleStrategy,小数据量时才用自定义回调 - 子批次交替处理:构建一批、写入一批,避免全量行数据同时驻留内存
- 及时释放资源:finally块中关闭ResultSet、Statement、Connection
❌ 反模式
- 全量加载到List :
service.list()在大数据场景下是定时炸弹 - 盲目使用ORM:MyBatis-Plus/Hibernate的反射开销在百万级数据下不可忽略
- 忽略fetchSize:默认值通常针对OLTP场景,不适合批量导出
- 逐格样式回调 :
CellWriteHandler在大数据量下性能灾难 - 不测量就优化:没有性能剖析日志,优化就是盲人摸象
🔮 未来展望
虽然当前方案已将性能提升到15秒,但仍有进一步优化空间:
- 异步导出+消息通知:对于超大数据量(100万+),采用后台异步导出,完成后发送邮件/站内信通知
- 列式存储优化:如果仅需导出部分列,可在SQL层面提前过滤,减少数据传输量
- 并行处理:将数据分片后多线程并行构建Excel(需注意EasyExcel线程安全问题)
- CSV格式备选:对于不需要样式的场景,CSV导出速度可比Excel快5-10倍
📝 结语
这场性能优化之旅,不仅是一次技术升级,更是一次思维转变:
- 从"能用就行"到"极致体验":用户等待2分钟和15秒的感受是天壤之别
- 从"依赖框架"到"深入底层":只有理解框架背后的原理,才能在关键时刻做出正确决策
- 从"局部优化"到"系统思考":单一优化点效果有限,组合拳才能产生质变
最后送给读者一句话:性能优化没有银弹,唯有持续测量、持续迭代、持续挑战极限,才能在数据的洪流中立于不败之地。
作者 :TJ-PAS技术团队
日期 :2026年5月
版本:v4.0
💬 互动话题:你在项目中遇到过哪些性能瓶颈?是如何解决的?欢迎在评论区分享你的经验!