线上批量导出 1000 个文件触发 OOM?扒开代码看本质,我是这样根治的

一、背景:异步架构下的 "隐形炸弹"

我们的导出系统采用 "接口落库 + MQ 异步消费" 架构:用户提交导出请求后,先写入导出记录表(状态0-待处理),再通过 RocketMQ 发送消息,由消费者异步处理文件打包 / Excel 生成。

但当运营一次性导出 1000 个 OSS 文件并打包成 ZIP 时,消费者服务直接 OOM 宕机 ------ 堆内存从 1.5GB 飙升到 3GB,GC 日志里全是Full GC (Allocation Failure)。下面是线上真实跑的核心代码,我们一步步扒开问题:

二、OOM 根源:4 个藏在代码里的 "内存杀手"

先贴出消费者和文件处理的核心代码,再逐个分析问题:

1. 消费者核心逻辑
java 复制代码
@RocketMQMessageListener(
    topic = Constants.EXPORT_RECORD_TOPIC, 
    consumerGroup = Constants.EXPORT_RECORD_TOPIC, 
    consumeThreadNumber = 2 // 线程数仅2,但单任务内存占用爆炸
)
public class ExportRecordConsumer implements RocketMQListener<ExportConsumerDTO> {
    @Override
    public void onMessage(ExportConsumerDTO consumerDTO) {
        // 重试逻辑:sleep+重复查库,但未限制重试次数
        Optional<EduExportRecordEntity> taskEntityOptional = eduExportRecordService.getOptById(consumerDTO.getRecordId());
        if (!taskEntityOptional.isPresent()) {
            try { Thread.sleep(5000); } catch (InterruptedException e) {}
            taskEntityOptional = eduExportRecordService.getOptById(consumerDTO.getRecordId());
        }
        // 核心逻辑:调用策略模式处理文件
        ExportRecordStrategy strategy = StrategyFactory.getNonNullStrategy(...);
        strategy.generateExportFile(recordEntity, consumerDTO);
    }
}
2. ZIP 打包核心逻辑(OOM 重灾区)
scss 复制代码
@Override
public void generateExportFile(EduExportRecordEntity recordEntity, ExportConsumerDTO consumerDTO) {
    eduExportRecordService.modifyData(recordEntity.getId(), 1, null, null, null);
    //数据处理
    if (consumerDTO.getBizType() == 1) {
        if (recordEntity.getFunctionType() == 1 || recordEntity.getFunctionType() == 2) {
            if (CollUtil.isNotEmpty(consumerDTO.getContentIds())) {
                R<List<CdsResourceFileEntity>> fileResult = remoteResourceFileService.queryListByFileId(consumerDTO.getContentIds());
                if (fileResult.isOk()) {
                    if (CollUtil.isNotEmpty(fileResult.getData())) {
                        fileToZip(fileResult.getData(), recordEntity);
                    } else {
                        recordEntity.setFailMsg("内部获取文件不存在");
                    }
                } else {
                    recordEntity.setFailMsg("内部获取文件失败");
                }
            } else {
                recordEntity.setFailMsg("未选择文件");
            }
        } else if (recordEntity.getFunctionType() == 3) {
            fileToExcel(consumerDTO, recordEntity);
        }
    }

    eduExportRecordService.modifyData(recordEntity.getId(),
            recordEntity.getFileId() != null && recordEntity.getFileId() > 0 ? 3 : 2,
            recordEntity.getFailMsg(), recordEntity.getFileId(), recordEntity.getContentNum());
}
java 复制代码
private void fileToZip(List<CdsResourceFileEntity> ossFileKeys, EduExportRecordEntity recordEntity) {
    try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); // 内存中存整个ZIP!
         ZipOutputStream zos = new ZipOutputStream(baos)) {

        for (CdsResourceFileEntity fileInfo : ossFileKeys) {
            // 从OSS下载文件到内存,再写入ZIP
            S3Object object = fileTemplate.getObject(...);
            try (InputStream inputStream = object.getObjectContent()) {
                byte[] buffer = new byte[1024];
                int len;
                while ((len = inputStream.read(buffer)) > 0) {
                    zos.write(buffer, 0, len); // 所有数据都堆在baos里
                }
            }
        }
        zos.finish();
        // 把整个ZIP字节数组传给上传方法
        fileUpload(recordEntity, ".zip", baos.toByteArray()); 
    } catch (Exception e) { ... }
}
问题拆解:
  • 内存中构建 ZIP 包ByteArrayOutputStream会把整个 ZIP 文件(1000 个文件可能达 GB 级)存在堆内存里,直接撑爆堆空间;
  • Excel 生成未做流式处理 :如果是生成 Excel(fileToExcel方法),若用XSSFWorkbook则全量数据存内存,同样 OOM;
  • OSS 文件读取无分片 :虽然用了InputStream,但 1000 个文件的内容最终都汇总到内存 ZIP 中,没有 "读一点写一点" 到磁盘;
  • 重试逻辑低效Thread.sleep(5000)会阻塞消费者线程,且重复查库可能导致无效内存占用;
  • 资源释放不彻底 :虽然用了 try-with-resources,但 S3Object 的close()可能被遗漏(部分 OSS SDK 需显式关闭)。

三、根治方案:从 "内存操作" 转向 "磁盘 + 流式"

针对每个问题点,我们做了 4 个关键优化:

1. 用临时文件替代内存 ZIP

放弃ByteArrayOutputStream,改用系统临时目录存储 ZIP 文件,避免内存堆积:

ini 复制代码
private void fileToZip(List<CdsResourceFileEntity> ossFileKeys, EduExportRecordEntity recordEntity) {
    // 创建临时ZIP文件(磁盘存储)
    Path tempZip = Files.createTempFile("export-", ".zip");
    try (FileOutputStream fos = new FileOutputStream(tempZip.toFile());
         ZipOutputStream zos = new ZipOutputStream(fos)) {

        for (CdsResourceFileEntity fileInfo : ossFileKeys) {
            S3Object object = fileTemplate.getObject(...);
            try (InputStream inputStream = object.getObjectContent()) {
                ZipEntry zipEntry = new ZipEntry(fileInfo.getOriginal());
                zos.putNextEntry(zipEntry);
                // 流式拷贝:读一点写一点,不占内存
                IOUtils.copy(inputStream, zos); 
                zos.closeEntry();
                object.close(); // 显式关闭S3Object,释放OSS连接
            }
        }
    } catch (Exception e) { ... }

    // 读取临时文件字节数组(若文件过大,可直接传File对象给上传接口)
    byte[] zipBytes = Files.readAllBytes(tempZip);
    fileUpload(recordEntity, ".zip", zipBytes);
    // 删除临时文件
    Files.deleteIfExists(tempZip);
}
2. Excel 生成改用 SXSSFWorkbook(流式写盘)

如果是生成 Excel(fileToExcel方法),替换XSSFWorkbookSXSSFWorkbook,自动将数据刷到临时文件:

java 复制代码
private void fileToExcel(ExportConsumerDTO consumerDTO, EduExportRecordEntity recordEntity) {
    // SXSSFWorkbook:内存中只保留100行,超出刷到磁盘
    SXSSFWorkbook workbook = new SXSSFWorkbook(100); 
    SXSSFSheet sheet = workbook.createSheet("数据");
    // 填充数据逻辑...
    // 写入临时文件
    Path tempExcel = Files.createTempFile("excel-", ".xlsx");
    workbook.write(new FileOutputStream(tempExcel.toFile()));
    workbook.dispose(); // 清理临时文件
    // 上传逻辑...
    Files.deleteIfExists(tempExcel);
}
3. 优化消费者配置与重试策略
  • 调整消费者线程数:根据服务器核数设置consumeThreadNumber = 5,避免单线程处理大任务;
  • 重试逻辑改为 "指数退避":用RetryTemplate替代硬编码的Thread.sleep,限制最大重试次数:
ini 复制代码
RetryTemplate retryTemplate = RetryTemplate.builder()
    .maxAttempts(3)
    .fixedBackoff(2000)
    .retryOn(NullPointerException.class)
    .build();
EduExportRecordEntity recordEntity = retryTemplate.execute(ctx -> {
    Optional<EduExportRecordEntity> optional = eduExportRecordService.getOptById(consumerDTO.getRecordId());
    return optional.orElseThrow(() -> new RuntimeException("任务不存在"));
});
4. 增加内存监控与分片处理
  • 对超大任务(比如 > 500 个文件)做分片:一次只处理 100 个文件,分多次打包;
  • 增加内存阈值监控:在消费前检查当前 JVM 堆内存使用率,超过 80% 则拒绝消费,触发告警:

放在消费者onMessage方法的最开始,先校验内存状态,不满足则直接抛出异常(或 NACK 消息重试),避免无效消费:

scss 复制代码
@RocketMQMessageListener(
    topic = Constants.EXPORT_RECORD_TOPIC, 
    consumerGroup = Constants.EXPORT_RECORD_TOPIC, 
    consumeThreadNumber = 5
)
public class ExportRecordConsumer implements RocketMQListener<ExportConsumerDTO> {
    @Override
    public void onMessage(ExportConsumerDTO consumerDTO) {
        // 第一步:校验内存是否充足,不足则拒绝消费
        if (!canConsume()) {
            throw new RuntimeException("当前JVM内存使用率过高,暂不消费");
            // 或用RocketMQ的重试机制:return;(需配置重试策略)
        }
        
        // 后续逻辑:查库、处理文件...
        RetryTemplate retryTemplate = RetryTemplate.builder()
            .maxAttempts(3)
            .fixedBackoff(2000)
            .retryOn(NullPointerException.class)
            .build();
        EduExportRecordEntity recordEntity = retryTemplate.execute(ctx -> {
            Optional<EduExportRecordEntity> optional = eduExportRecordService.getOptById(consumerDTO.getRecordId());
            return optional.orElseThrow(() -> new RuntimeException("任务不存在"));
        });
        
        ExportRecordStrategy strategy = StrategyFactory.getNonNullStrategy(...);
        strategy.generateExportFile(recordEntity, consumerDTO);
    }
    
    // 内存监控方法(内部工具方法)
    private boolean canConsume() {
        MemoryUsage heapMemory = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
        double usedPercent = (double) heapMemory.getUsed() / heapMemory.getMax();
        return usedPercent < 0.8; // 使用率<80%才允许消费
    }
}

四、优化效果:OOM 彻底消失,性能提升 3 倍

优化后压测数据:

  • 批量导出 1000 个文件(总大小 2GB):堆内存稳定在 1GB 以内,无 Full GC;
  • 单个任务处理时间从 2 分钟缩短到 40 秒;
  • 消费者服务 7*24 小时稳定运行,无 OOM 宕机。

####流程图

五、总结:批量导出避坑指南

  1. 拒绝 "内存狂欢" :任何大文件处理都要走磁盘 / 流式,别用ByteArrayOutputStream存全量数据;
  2. 选对工具类 :Excel 用SXSSFWorkbook,ZIP 用ZipOutputStream+临时文件
  3. 资源释放要彻底:OSS/S3 对象、临时文件必须显式关闭 / 删除;
  4. 消费者要 "限流" :设置合理线程数 + 内存阈值,避免单个任务拖垮整个服务。

其实很多 OOM 不是 "代码写错了",而是 "思路没转过来"------ 把 "内存里玩转一切" 的惯性思维,换成 "磁盘 + 流式" 的思路,大部分批量导出的性能问题都能迎刃而解。

编辑分享

相关推荐
大鸡腿同学2 小时前
大量频繁记录有效击球方式
后端
稚辉君2 小时前
Gemini永久会员 01不等概率随机到01等概率随机
后端
z***56563 小时前
springboot整合mybatis-plus(保姆教学) 及搭建项目
spring boot·后端·mybatis
q***98523 小时前
Spring Boot:Java开发的神奇加速器(二)
java·spring boot·后端
小蒜学长3 小时前
基于spring boot的汽车4s店管理系统(代码+数据库+LW)
java·数据库·spring boot·后端·汽车
q***42053 小时前
Spring Data 什么是Spring Data 理解
java·后端·spring
一 乐3 小时前
餐厅管理智能点餐系统|基于java+ Springboot的餐厅管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端
用户345848285054 小时前
Java中还有哪些方式可以保证多线程环境下的原子性?
后端
IT_陈寒4 小时前
JavaScript开发者必知的7个ES2023新特性,让你的代码效率提升50%
前端·人工智能·后端