共计 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 的
Drawing
和XSSFPicture
处理图片 - 每张图片会建立锚点(anchor)与图像索引(index)
- 插入图片不重复引用会占用大量空间
六、异步导出架构设计
导出服务异步化方案:
- 用户点击导出,生成任务记录(Redis or DB)
- 使用@Async或 MQ 异步处理导出逻辑
- 写入 Excel → 上传文件服务(如 MinIO)
- 返回下载链接 → 用户轮询或通知完成
七、具体实现
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相关知识输出。感谢阅读!
正文完