30万数据导出从2分钟到15秒:一场与内存溢出的生死较量【宗申集团】

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后仍无法回收
  • 🔥 服务影响:同一服务器上其他模块响应变慢,甚至出现连锁雪崩

根本原因分析

  1. 全量加载:30万条POJO对象全部驻留内存(每条约500字节,共150MB)
  2. EasyExcel内部缓冲:SXSSFWorkbook虽使用磁盘缓冲,但仍需维护滑动窗口(默认100行)
  3. 样式处理器回调ExportStyleHandler.afterCellDispose被调用900万次(30万行×30列),每次创建CellStyle对象
  4. 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)

用户反馈:"还是崩了,本地电脑性能好,但还是要等两分钟,能不能再快点?"


🔥 第二次优化:消除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%)
====================================

问题分析

  1. MyBatis-Plus反射开销:每次查询都需要将ResultSet映射为POJO,涉及大量反射操作
  2. 字段名匹配低效getValueFromMap方法中,每个字段需要3次containsKey检查(原始key、大写key、下划线key)
  3. 样式处理器逐格回调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);

通过这些日志,我们精准定位了瓶颈所在,而不是凭感觉猜测。


💡 通用最佳实践

✅ 推荐做法

  1. 大数据导出必用流式:无论数据量多大,都应采用分页/游标式读取
  2. JDBC层优化fetchSize:Oracle设为5000-10000,MySQL设为1000-5000
  3. 避免COUNT查询:除非必须显示总数,否则采用"读到空为止"策略
  4. 零反射Map映射:对于纯导出场景,直接用Map比POJO更高效
  5. 智能样式策略 :大数据量时使用HorizontalCellStyleStrategy,小数据量时才用自定义回调
  6. 子批次交替处理:构建一批、写入一批,避免全量行数据同时驻留内存
  7. 及时释放资源:finally块中关闭ResultSet、Statement、Connection

❌ 反模式

  1. 全量加载到Listservice.list()在大数据场景下是定时炸弹
  2. 盲目使用ORM:MyBatis-Plus/Hibernate的反射开销在百万级数据下不可忽略
  3. 忽略fetchSize:默认值通常针对OLTP场景,不适合批量导出
  4. 逐格样式回调CellWriteHandler在大数据量下性能灾难
  5. 不测量就优化:没有性能剖析日志,优化就是盲人摸象

🔮 未来展望

虽然当前方案已将性能提升到15秒,但仍有进一步优化空间:

  1. 异步导出+消息通知:对于超大数据量(100万+),采用后台异步导出,完成后发送邮件/站内信通知
  2. 列式存储优化:如果仅需导出部分列,可在SQL层面提前过滤,减少数据传输量
  3. 并行处理:将数据分片后多线程并行构建Excel(需注意EasyExcel线程安全问题)
  4. CSV格式备选:对于不需要样式的场景,CSV导出速度可比Excel快5-10倍

📝 结语

这场性能优化之旅,不仅是一次技术升级,更是一次思维转变:

  • 从"能用就行"到"极致体验":用户等待2分钟和15秒的感受是天壤之别
  • 从"依赖框架"到"深入底层":只有理解框架背后的原理,才能在关键时刻做出正确决策
  • 从"局部优化"到"系统思考":单一优化点效果有限,组合拳才能产生质变

最后送给读者一句话:性能优化没有银弹,唯有持续测量、持续迭代、持续挑战极限,才能在数据的洪流中立于不败之地。


作者 :TJ-PAS技术团队
日期 :2026年5月
版本:v4.0

💬 互动话题:你在项目中遇到过哪些性能瓶颈?是如何解决的?欢迎在评论区分享你的经验!

相关推荐
武帝为此1 小时前
【软件开发日志介绍】
java·前端·数据库
likerhood1 小时前
Java 反射与注解的详细讲解
java·开发语言·数据库
asdfg12589631 小时前
从Java的设计模式看接口和实现---List与ArrayList
java·开发语言·设计模式·面向对象·面向接口
djk88881 小时前
.net swagger api 开启跨域 开启注释
java·前端·.net
秋91 小时前
java中对操作mysql8.0.46与MySQL9.7.0有什么区别,并举例说明
android·java·adb
神仙别闹2 小时前
基于Python实现一个C语言的编译器
java·c语言·python
冷小鱼2 小时前
JVM 深度调优实战:从 JDK 8 到 JDK 21 的演进与中间件落地
java·jvm·中间件
玛卡巴卡ldf2 小时前
【LeetCode 手撕算法】(回溯)全排列DFS、子集、电话号码字母组合 九键、组合总和、括号生成、单词搜索、分割回文数
java·算法·leetcode·力扣
极客先躯2 小时前
高级java每日一道面试题-2025年12月06日-实战篇[Dockerj]-如何配置 Docker 的镜像加速器?国内有哪些常用加速源?
java·docker·配置docker的镜像加速器·国内有哪些常用加速源·镜像加速器的本质与配置原理·镜像拉取流程对比·加速前后架构差异