系列:Java 实战笔记 · 文件与数据传输阅读时间:10 分钟
一、问题引入
运营同学反馈:“导出一份 80 万条的订单报表,页面转圈 3 分钟后直接崩溃,后台报 OutOfMemoryError: Java heap space。”
你打开代码一看:
List<Order> allOrders = orderMapper.selectAll(); // 一次性查全部ExcelWriter writer = ...;writer.write(allOrders); // 将所有数据加载到内存
典型的 “一次性加载全表 + 全量写入 Excel”,数据量一大必然 OOM。
二、反面案例:那些年我们踩过的坑
2.1 错误代码(完整版)
@GetMapping("/export/bad")public void exportBad(HttpServletResponse response) throws IOException {List<Order> orders = orderMapper.selectAll(); // 假设有 100 万条response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");response.setHeader("Content-Disposition", "attachment; filename=orders.xlsx");XSSFWorkbook workbook = new XSSFWorkbook();XSSFSheet sheet = workbook.createSheet("orders");Row header = sheet.createRow(0);header.createCell(0).setCellValue("订单号");header.createCell(1).setCellValue("金额");for (int i = 0; i < orders.size(); i++) {Row row = sheet.createRow(i + 1);row.createCell(0).setCellValue(orders.get(i).getOrderNo());row.createCell(1).setCellValue(orders.get(i).getAmount());}workbook.write(response.getOutputStream());workbook.close();}
2.2 踩坑实录
- 生产环境导出 50 万订单(原始数据约 500 MB),使用
XSSFWorkbook后,堆内存飙升至 4.5 GB,触发频繁 Full GC,应用响应缓慢,最终 OOM。若数据量达到 100 万,内存需求将超过 8 GB。。 即使勉强成功,响应时间超过 5 分钟,前端早已超时断开。
多个导出任务同时执行,应用直接 OOMKilled。
根本原因:XSSFWorkbook 将整个 Excel 文件(包括所有行、列、样式)全部存在内存。100 万行 ≈ 几 GB 内存。
三、正确方案:EasyExcel 流式导出 + 分页查询
3.1 核心思路
分页查询:每次只查询 2000 条,避免一次性加载全表。
流式写入:使用 EasyExcel 的
write方法,数据逐批写入响应流,内存占用恒定。游标或分页:利用
LIMIT offset, size或 游标(推荐游标,避免深分页)。
3.2 技术选型
| EasyExcel |
我们选择 EasyExcel 4.x,它是当前最成熟的 Excel 导出工具。
3.3 完整可运行代码
项目结构

① pom.xml(关键依赖)
<projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.4.5</version><relativePath/></parent><groupId>com.heyou</groupId><artifactId>heyou-examples</artifactId><version>1.0.0</version><packaging>pom</packaging><name>heyou-examples</name><modules><module>heyou-excel-export</module></modules><properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><spring-boot.version>3.4.5</spring-boot.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- EasyExcel --><dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>4.0.2</version></dependency><!-- MyBatis-Plus (Spring Boot 3 compatible starter) --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId><version>3.5.12</version></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-jsqlparser</artifactId><version>3.5.12</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>${spring-boot.version}</version></dependency></dependencies></dependencyManagement><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
② 实体类 Order
package com.heyou.excel.export.entity;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.math.BigDecimal;import java.time.LocalDateTime;@Data@TableName("biz_order")public class Order {private Long id;private String orderNo;private BigDecimal amount;private Integer status;private LocalDateTime createTime;}
③ Excel 导出 DTO(EasyExcel 注解)
package com.heyou.excel.export.dto;;import com.alibaba.excel.annotation.ExcelProperty;import com.alibaba.excel.annotation.write.style.ColumnWidth;import lombok.Data;@Datapublic class OrderExcelDto {@ExcelProperty("订单号")@ColumnWidth(20)private String orderNo;@ExcelProperty("金额")@ColumnWidth(15)private BigDecimal amount;@ExcelProperty("状态")@ColumnWidth(10)private String statusDesc;@ExcelProperty("创建时间")@ColumnWidth(20)private String createTimeStr;}
④ Mapper(MyBatis-Plus 分页)
package com.heyou.excel.export.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.excel.entity.Order;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface OrderMapper extends BaseMapper<Order> {}
⑤ Service(分页查询 + 转换)
package com.heyou.excel.export.service;import com.baomidou.mybatisplus.core.metadata.IPage;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.example.excel.dto.OrderExcelDto;import com.example.excel.entity.Order;import com.example.excel.mapper.OrderMapper;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Service;import java.util.ArrayList;import java.util.List;@Service@RequiredArgsConstructorpublic class OrderService {private final OrderMapper orderMapper;/*** 分页查询订单(模拟百万数据)* @param current 当前页* @param size 每页大小*/public IPage<Order> pageOrders(long current, long size) {Page<Order> page = new Page<>(current, size);return orderMapper.selectPage(page, null);}/*** 将 Order 转换为 Excel 导出 DTO*/public List<OrderExcelDto> convertToDto(List<Order> orders) {List<OrderExcelDto> list = new ArrayList<>();for (Order order : orders) {OrderExcelDto dto = new OrderExcelDto();dto.setOrderNo(order.getOrderNo());dto.setAmount(order.getAmount());dto.setStatusDesc(order.getStatus() == 1 ? "已完成" : "待支付");dto.setCreateTimeStr(order.getCreateTime().toString());list.add(dto);}return list;}}
⑥ Controller(核心导出接口)
package com.heyou.excel.export.controller;import com.alibaba.excel.EasyExcel;import com.alibaba.excel.write.metadata.WriteSheet;import com.baomidou.mybatisplus.core.metadata.IPage;import com.heyou.excel.export.dto.OrderExcelDto;import com.heyou.excel.export.entity.Order;import com.heyou.excel.export.service.OrderService;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.io.IOException;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import java.util.List;@RestController@RequestMapping("/export")@RequiredArgsConstructorpublic class ExportController {private final OrderService orderService;private static final int PAGE_SIZE = 2000; // 每批查询 2000 条@GetMapping("/excel")public void exportExcel(HttpServletResponse response) throws IOException {// 设置响应头response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("订单报表.xlsx", StandardCharsets.UTF_8));// 使用 EasyExcel 流式写入,不一次性加载所有数据到内存var writer = EasyExcel.write(response.getOutputStream(), OrderExcelDto.class).build();// 分页查询并逐批写入long currentPage = 1;IPage<Order> pageResult;do {pageResult = orderService.pageOrders(currentPage, PAGE_SIZE);List<Order> orders = pageResult.getRecords();if (orders.isEmpty()) {break;}List<OrderExcelDto> dtoList = orderService.convertToDto(orders);// 写入当前批数据writer.write(dtoList, new WriteSheet());currentPage++;} while (currentPage <= pageResult.getPages());writer.finish(); // 关闭流}}
⑦ 启动类 + 分页插件配置
package com.heyou.excel.export;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class HeyouExcelExportApplication {public static void main(String[] args) {SpringApplication.run(HeyouExcelExportApplication.class, args);}}
package com.heyou.excel.export.config;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationpublic class MybatisPlusConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}}
⑧ 模拟数据 SQL(插入 100 万条测试数据,自行调整)
-- `heyou-example`.biz_order definitionCREATE TABLE `biz_order` (`id` bigint NOT NULL AUTO_INCREMENT,`order_no` varchar(32) NOT NULL,`amount` decimal(10,2) NOT NULL,`status` tinyint DEFAULT '0',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),KEY `idx_create_time` (`create_time`)) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 插入 100 万条(使用存储过程或批量插入)3.4 测试验证
单元测试(模拟请求):
package com.heyou.excel.export;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.web.servlet.MockMvc;import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import static org.hamcrest.Matchers.containsString;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;@SpringBootTest@AutoConfigureMockMvcclass HeyouExcelExportApplicationTests {@Testvoid contextLoads() {}@Autowiredprivate MockMvc mockMvc;@Testvoid testExportExcel() throws Exception {String encodedFilename = URLEncoder.encode("订单报表.xlsx", StandardCharsets.UTF_8);mockMvc.perform(MockMvcRequestBuilders.get("/export/excel")).andExpect(status().isOk()).andExpect(header().string("Content-Disposition", containsString(encodedFilename)));// 手动检查输出文件是否正常打开}}
压测对比(使用 JMeter 或简单循环):
结论:内存占用恒定在 150~200 MB,不会 OOM。
四、进阶优化
异步导出:对于超大文件(> 50 MB),改为异步任务生成文件到 OSS,完成后回调通知下载。
限流保护:使用令牌桶限制同时导出的请求数,防止多个大导出耗尽数据库连接。
游标查询:使用 MyBatis 游标
Cursor替代分页,避免深分页性能问题(适合百万级以上顺序导出)。
五、疑问
为什么 100 万条订单(原始数据约 1GB)会导致 Heap 飙到 8G:
根本原因:XSSFWorkbook 的内存膨胀系数极高
1. 原始数据 vs Excel 对象模型的内存开销
| 膨胀 5~10 倍 | ||
| 5 GB ~ 10 GB |
2. 具体膨胀原因
每个单元格都是一个对象:100 万行 × 10 列 = 1000 万个
XSSFCell对象,每个对象开销约 50~100 字节 → 仅单元格就占用 500 MB ~ 1 GB。行对象(XSSFRow):每行一个对象,100 万个行对象 → 额外约 100 MB。
共享字符串表(Shared Strings Table):Excel 会将所有字符串存到一个全局表中,相同字符串只会存一份。但 100 万条订单号各不相同,字符串表会膨胀到 几百 MB。
样式、字体、列宽、行高等:即使没有显式设置,XSSFWorkbook 也会为每个单元格创建默认样式引用 → 又增加一层开销。
XML 解析缓存:XSSFWorkbook 基于 OOXML 格式,内存中会保留整个 XML 树的 DOM 结构,这部分开销也很大。
3. 真实压测数据(可复现)
我曾在 8GB 堆内存的机器上做过测试:
| 是 | |||
| ~9 GB |
结论:膨胀系数 ≈ 8~10 倍,所以 1GB 原始数据会导致 8~9GB 堆内存占用。
六、总结
核心三句话:
永远不要用
XSSFWorkbook导出大 Excel —— 它会把整个文件放内存。分页查询 + EasyExcel 流式写入 = 内存恒定,支持百万数据。
生产环境记得加导出限流和超时控制。
踩坑 Checklist:
✅ 响应头设置
Content-Disposition中文名要 URL 编码。✅ 导出结束后必须调用
writer.finish()释放资源。✅ 分页大小建议 1000~5000,过大会增加单次查询内存,过小会增加 IO 次数。
下一篇预告:大文件分片上传 + 断点续传(完整实战)
夜雨聆风