在Spring Boot项目中做Excel导出,如果数据量只有几千条,直接用Apache POI或EasyExcel一次性写出通常没什么问题。但当数据规模达到几十万甚至上百万行时,任何把全部数据装进内存再生成文件的方案都会变得非常危险。轻则GC频繁、接口响应变慢,重则直接抛出OutOfMemoryError导致服务不可用。真正可行的思路是让导出过程流式化,数据边查边写,控制单批内存占用。

一次性加载方案为什么在百万级场景下会失败
传统Excel导出代码通常先调用Mapper的selectList或者其他全表查询方法,把数据库记录加载成Java对象列表,然后把列表传给EasyExcel的write方法。这个写法在几千条数据时最直观,但它隐含一个前提:所有的行数据必须同时存在于JVM堆内存中。假设一条记录展开后占用200字节,100万条就是200MB起步,再加上对象头、集合扩容、字符串常量和临时对象,实际占用可能达到几百MB甚至1GB以上。
更麻烦的是,EasyExcel虽然自身比POI节省内存,但如果你传入的是一个完整的List,它仍然需要把这个列表作为数据源进行遍历。真正让EasyExcel内存模型占优的是它的写引擎采用了流式处理,先写出XML,再压缩成xlsx,但数据源本身的超大集合没有被释放。换句话说,问题不在EasyExcel,而在调用方把压力集中到了堆内存上。
下面这段代码就是典型的不适合百万级导出的写法。它通过orderMapper.selectAll()一次性取出全部订单,然后整体写出。当orderList容量过大时,JVM需要一次性分配连续内存,触发Full GC的概率极高,甚至直接OOM。因此,百万级导出的第一步不是调参数,而是改变数据读取模型:分批加载、分批写出。
// 反例:一次性加载全部数据
public void exportAllOrders(HttpServletResponse response) throws IOException {
List<Order> orderList = orderMapper.selectAll(); // 100万条全部进内存
EasyExcel.write(response.getOutputStream(), Order.class)
.sheet("订单数据")
.doWrite(orderList);
}
分批查询与同一Sheet多次写入是核心方案
EasyExcel提供了ExcelWriter和WriteSheet两个核心对象。与一次性doWrite不同,我们可以先创建ExcelWriter,再创建WriteSheet,然后在循环里多次调用write方法,每次只写入一批数据,最后调用finish完成文件收尾。这个过程中,内存里只需要保留当前批次的数据,上一批写完就可以被GC回收。
分批查询数据库通常有两种方式:一种是使用LIMIT偏移量分页,另一种是使用递增主键或游标分段。LIMIT方式实现简单,但偏移量越大越慢,因为数据库需要跳过前面所有行。例如LIMIT 900000, 10000时,MySQL仍可能扫描前90万行。百万级导出如果使用深分页,后半段查询会明显变慢。更推荐的方式是记录上一批最大ID,然后每次查询WHERE id > #{lastId} ORDER BY id LIMIT 10000。这种基于索引的游标分页能保持查询效率稳定。
下面是分批查询加多次写入的完整示例。假设订单表有自增主键id,每次查询5000或10000条,循环处理,直到查不到数据。代码中使用@Transactional时需要特别注意,长事务会占用数据库连接和锁资源,导出任务不适合放在普通事务里,建议使用只读连接并关闭事务,或者用独立线程池处理。
public void exportLargeOrders(OutputStream outputStream) {
ExcelWriter writer = null;
try {
writer = EasyExcel.write(outputStream, Order.class).build();
WriteSheet sheet = EasyExcel.writerSheet("订单数据").build();
int batchSize = 10000;
Long lastId = 0L;
while (true) {
List<Order> batch = orderMapper.selectByGtId(lastId, batchSize);
if (batch == null || batch.isEmpty()) {
break;
}
writer.write(batch, sheet);
lastId = batch.get(batch.size() - 1).getId();
// 本轮对象可被回收,同时清理引用
batch.clear();
}
} finally {
if (writer != null) {
writer.finish();
}
}
}
这个写法仍要考虑两个细节。第一,batch.clear()只是清理List引用,并不能立即释放内存,真正的回收时机由GC决定,但至少不会让所有批次都停留在堆里。第二,如果查询字段中包含大字段,比如订单备注、JSON扩展信息,应尽量在导出SQL中只选择必要字段,避免把不需要的大文本拉出来。
如果导出过程本身很耗时,HTTP请求不可能一直等待。用户点击导出后,浏览器或网关可能会在几十秒后断开连接。所以百万级导出通常需要改造为异步任务:请求进入后先创建一个任务,后台线程负责生成文件并存储到对象存储或本地目录,前端通过轮询任务状态获取下载地址。这样既解决了连接超时,也避免用户重复点击触发多个导出任务。
异步导出与进度反馈的工程化设计
异步导出的接口可以拆成两个:提交导出任务和查询导出进度。提交接口只校验参数、生成任务ID、放入队列或线程池,然后立刻返回。导出服务使用ThreadPoolExecutor或者Spring的@Async执行,但要注意线程池的容量和拒绝策略。百万级文件生成可能持续几分钟,不能使用默认的单线程池无限制排队,否则任务堆积会影响其他业务。
进度信息可以放在Redis里,键为任务ID,值包含状态、已处理行数、文件路径和错误信息。批处理循环中每写完一批就更新已处理行数。前端定时轮询任务状态,当状态变为完成时展示下载链接。为了避免前端频繁请求,轮询间隔可以设为3到5秒,并给任务设置过期时间,比如24小时。
下面是一个简化版的任务提交与进度更新代码。任务提交后,真正导出逻辑在线程池中执行,通过progressService.update把当前状态写入Redis。注意,任务执行中不能使用HttpServletResponse,因为HTTP请求已经返回,必须把文件写入本地磁盘或OSS。
@Service
public class ExportTaskService {
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());
public String submitExportTask(ExportQuery query) {
String taskId = UUID.randomUUID().toString();
progressService.init(taskId, "RUNNING", 0);
executor.execute(() -> executeExport(taskId, query));
return taskId;
}
private void executeExport(String taskId, ExportQuery query) {
String filePath = "/data/export/" + taskId + ".xlsx";
try (OutputStream out = Files.newOutputStream(Paths.get(filePath))) {
ExcelWriter writer = EasyExcel.write(out, Order.class).build();
WriteSheet sheet = EasyExcel.writerSheet("订单数据").build();
long lastId = 0L;
int total = 0;
while (true) {
List<Order> batch = orderMapper.selectByGtId(lastId, 10000);
if (batch.isEmpty()) {
break;
}
writer.write(batch, sheet);
total += batch.size();
lastId = batch.get(batch.size() - 1).getId();
progressService.update(taskId, "RUNNING", total);
}
writer.finish();
progressService.finish(taskId, filePath);
} catch (Exception e) {
progressService.fail(taskId, e.getMessage());
}
}
}
工程化时还需要考虑文件清理和任务幂等。临时文件会占用磁盘空间,可以使用定时任务清理超过24小时的导出文件。相同参数的导出任务可以复用缓存结果,但必须确保数据时效性满足业务要求。如果用户频繁提交相同条件,最好在Redis中设置去重标记,避免重复生成同一个大文件。
导出细节优化与常见坑点
百万级数据导出不只是内存问题,很多细节处理不当也会拖慢速度或导致文件打不开。首先是数据类型和格式。日期字段如果直接导出为数字或时间戳,业务用户根本没法看,需要在实体字段上加@DateTimeFormat或者自定义转换器。数字精度也容易出问题,特别是订单金额、经纬度、手机号等,要避免手机号被Excel显示成科学计数法,可以考虑输出为字符串并设置文本格式。
样式方面,EasyExcel支持通过WriteCellStyle设置表头背景、字体、边框,也可以使用HorizontalCellStyleStrategy为表头和数据区设置不同样式。但要记住,每增加一种复杂样式都会增加CPU计算和XML体积,百万行数据不建议做逐行颜色判断。如果必须突出某些行,应在业务侧先标记再做条件样式,或者只给关键列设置格式。
动态表头在导出场景中很常见。如果列不固定,不能直接依赖实体类注解,可以使用List<List<String>>构造表头,数据则对应List<List<Object>>。这种无模型写法的灵活性更高,但需要自己保证每行列数和表头一致,否则Excel会出现错位或空列。多Sheet导出也是类似,通过创建多个WriteSheet对象分别写入即可。
最后还要关注JVM参数和数据库连接。导出任务线程池如果与其他业务共用堆内存,应该为导出任务设置独立的JVM参数或考虑独立服务。数据库查询的fetchSize在MySQL中并非完全生效,但对Oracle等数据库可以减少网络往返。批次大小建议在5000到20000之间调优,太小会导致写入次数过多,太大又增加单批内存峰值。通过压测找到适合当前机器和表结构的批次值,比盲目照抄配置更可靠。
综合来看,Spring Boot整合EasyExcel做百万级导出,核心不是某个API的调用技巧,而是将一次性加载改成分批流式处理,把同步请求改成异步任务,再用进度反馈和细节调优保障整个链路稳定。掌握这套思路后,即使数据量继续增长,也可以通过横向扩展导出服务或增加分片导出进一步应对。
Spring BootEasyExcel百万级数据导出修改时间:2026-09-18 16:46:38