百万级数据导出 Excel(含图片)实战指南:避坑、优化与 EasyExcel 高性能用法

共计 4742 个字符,预计需要花费 12 分钟才能阅读完成。

适用场景:后台管理系统需要导出海量数据(百万行+),包含图片列(如用户头像、商品图),对性能、内存、安全性有较高要求。

一、问题背景

在一个需求中,需要将海量数据导出为 Excel,供财务使用。数据量达到百万级,若处理不当,容易造成

  • 内存溢出(OOM)
  • CPU飙高、响应超时
  • 图片插入导致文件异常大

二、导出瓶颈在哪?

导出 Excel 是典型的 IO 密集 + 内存敏感型任务,其主要性能瓶颈和风险有以下

瓶颈点 问题描述
数据查询 一次性加载所有数据 → OOM / 数据库崩溃
Excel 写入 默认使用 Apache POI 的 XSSFWorkbook 全内存操作
图片处理 图片不压缩直接插入,导致文件过大或崩溃
大文件输出 写文件到磁盘再下载,多余 IO 和响应阻塞
用户体验 大文件导出耗时长,用户以为系统卡死

三、整体技术解决方案

推荐架构组合

层级 技术
查询层 分页查询 / 游标流式查询(MyBatis、JPA)
写入层 EasyExcel(基于 SAX + 临时缓存)
图片处理 图片压缩 + byte[] 流式插入
输出层 HttpServletResponse 的 OutputStream
附加 异步导出 + Redis 状态标记 + 文件服务(MinIO)

四、EasyExcel 各个导出模式底层原理

1. 默认模式(内存模式)

EasyExcel.write().doWrite(dataList);
  • 数据全部保存在内存中(使用 poi XSSFWorkbook
  • 写百万行会直接 OOM(默认 JVM 最大堆 < 1G)
  • 不推荐大数据使用

2. 临时文件缓存模式(inMemory(false))

EasyExcel.write().inMemory(false).build();
  • 使用 temp 临时文件存储数据,防止内存飙升
  • 适合百万行以上的数据导出
  • 每次写入落入磁盘缓存,最终 flush 到输出流
  • 源码中使用 SXSSFWorkbook + 临时 zip 输出流

3. 分页写入(推荐终极方式)

ExcelWriter writer = EasyExcel.write(outputStream, DTO.class)
    .inMemory(false)
    .build();
WriteSheet sheet = EasyExcel.writerSheet("数据").build();
for (int i = 1; i <= pageCount; i++) {
    List<DTO> pageData = service.query(i, pageSize);
    writer.write(pageData, sheet);
}
writer.finish();
  • 每一页写入立即释放内存
  • 不保留全部数据引用
  • 避免整个 Excel 写完后才 flush,节省堆空间

五、图片插入机制与优化

方法一:使用 byte[] + 图片转换器

@ExcelProperty(value = "产品图", converter = ImageConverter.class)
private byte[] productImage;
  • 图片字节数据字段
  • 使用 EasyExcel 内置或自定义转换器处理

内部机制详解:

  • EasyExcel 使用 Apache POI 的 DrawingXSSFPicture 处理图片
  • 每张图片会建立锚点(anchor)与图像索引(index)
  • 插入图片不重复引用会占用大量空间

六、异步导出架构设计

导出服务异步化方案:

  1. 用户点击导出,生成任务记录(Redis or DB)
  2. 使用@Async或 MQ 异步处理导出逻辑
  3. 写入 Excel → 上传文件服务(如 MinIO)
  4. 返回下载链接 → 用户轮询或通知完成

七、具体实现

DTO示例

@Data
public class OrderExportDTO {
    @ExcelProperty("订单号")
    private String orderId;

    @ExcelProperty("用户名")
    private String userName;

    // 注意:图片字段使用 byte[],且必须指定 converter。
    @ExcelProperty(value = "商品图", converter = ImageConverter.class)
    private byte[] productImage;
}

图片转换器

public class ImageConverter implements Converter<byte[]> {

    @Override
    public Class<?> supportJavaTypeKey() {
        return byte[].class;
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.IMAGE;
    }

    @Override
    public byte[] convertToJavaData(CellData cellData, ExcelContentProperty property, GlobalConfiguration globalConfiguration) {
        // 不需要实现,导出用不到
        return new byte[0];
    }

    @Override
    public CellData<?> convertToExcelData(byte[] value, ExcelContentProperty property, GlobalConfiguration globalConfiguration) {
        return new CellData<>(value);
    }
}

导出任务状态管理器(可切换为 Redis)

@Component
public class ExportTaskManager {

    private final Map<String, ExportStatus> taskMap = new ConcurrentHashMap<>();

    public void initTask(String taskId) {
        taskMap.put(taskId, new ExportStatus("处理中", null));
    }

    public void success(String taskId, String filePath) {
        taskMap.put(taskId, new ExportStatus("完成", filePath));
    }

    public void fail(String taskId) {
        taskMap.put(taskId, new ExportStatus("失败", null));
    }

    public ExportStatus getStatus(String taskId) {
        return taskMap.getOrDefault(taskId, new ExportStatus("未知", null));
    }

    @Data
    @AllArgsConstructor
    public static class ExportStatus {
        private String status;
        private String downloadUrl;
    }
}

Controller:任务创建 + 状态查询 + 文件下载

@RestController
@RequestMapping("/export")
@RequiredArgsConstructor
public class ExportController {

    private final ExportService exportService;
    private final ExportTaskManager taskManager;

    @PostMapping("/order")
    public String createExportTask() {
        String taskId = UUID.randomUUID().toString();
        taskManager.initTask(taskId);
        exportService.exportAsync(taskId);
        return taskId;
    }

    @GetMapping("/status/{taskId}")
    public ExportTaskManager.ExportStatus getStatus(@PathVariable String taskId) {
        return taskManager.getStatus(taskId);
    }

    @GetMapping("/download/{taskId}")
    public void download(@PathVariable String taskId, HttpServletResponse response) throws IOException {
        ExportTaskManager.ExportStatus status = taskManager.getStatus(taskId);
        if (!"完成".equals(status.getStatus())) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        String path = status.getDownloadUrl();
        File file = new File(path);
        try (FileInputStream in = new FileInputStream(file);
             ServletOutputStream out = response.getOutputStream()) {
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(file.getName(), "UTF-8"));
            StreamUtils.copy(in, out);
        }
    }
}

异步导出服务(分页写入磁盘)

@Service
@RequiredArgsConstructor
public class ExportService {

private final ExportTaskManager taskManager;
private final OrderRepository orderRepository;

@Async("exportExecutor")
public void exportAsync(String taskId) {
    String filePath = "/tmp/export_" + taskId + ".xlsx";
    try (ExcelWriter writer = EasyExcel.write(filePath, OrderExportDTO.class)
            .inMemory(false)
            .build()) {

        WriteSheet sheet = EasyExcel.writerSheet("订单").build();
        int page = 1, pageSize = 3000;
        while (true) {
            List<OrderExportDTO> data = orderRepository.queryPage(page, pageSize);
            if (data.isEmpty()) break;
            writer.write(data, sheet);
            page++;
        }

        writer.finish();
        taskManager.success(taskId, filePath);
    } catch (Exception e) {
        taskManager.fail(taskId);
        log.error("导出任务失败: {}", taskId, e);
    }
}

}

总结

通过引入异步架构与 EasyExcel 分批写入机制,我们成功实现了:

  • 避免 OOM,支持百万级数据导出
  • 支持图片字段写入
  • 异步非阻塞架构,前后端体验分离

我是 李卷卷,专注Java相关知识输出。感谢阅读!

正文完
 0
admin
版权声明:本站原创文章,由 admin 于2025-05-31发表,共计4742字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)