如何用Spring Boot整合EasyExcel实现百万级数据导出优化?

来源:JS脚本作者:高永康头衔:资深程序员
导读:本期聚焦于高永康创作的《如何用Spring Boot整合EasyExcel实现百万级数据导出优化?》,敬请观看详情。从数据库一次性读取上百万行数据再塞进Excel,服务内存会瞬间飙升甚至直接OOM,这是导出功能最典型的翻车场景。EasyExcel给出的思路是放弃DOM模式,采用SAX逐行解析与写入,配合分批查询能把内存占用压到很低。本文基于Spring Boot整合EasyExcel,拆解百万级数据导出从可行到好用的完整链路:先说明一次性加载方案为何不可取,再给出分批查询加同一Sheet多次写入的实现代码,并讨论深分页、游标和ID范围查询等细节。随后引入异步导出与进度反馈,避免HTTP连接超时和前端长时间等待。最后补充样式、日期格式、动态表头等工程化调优点。通过这套组合,普通单体服务也能稳定导出几十万到上百万条记录。

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

如何用Spring Boot整合EasyExcel实现百万级数据导出优化?

一次性加载方案为什么在百万级场景下会失败

传统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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0918/58887.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。