JQuickExcel 流式导出配置:从阈值、窗口到临时文件的工程取舍
tags: #JQuickExcel #JavaExcel #开源 #POI #Excel工具
更多示例: JQuick-Excel 官网示例 https://www.jquick.org/jquick-excel/#demo-formulas0005
简介
我在处理大批量 Excel 导出时,先区分两个问题:是否能正确生成工作簿,以及生成过程中是否能把堆内存、临时磁盘和响应时间控制在可预期范围内。前者由字段映射、样式、公式和业务数据决定;后者才是流式导出的工作范围。JQuickExcel 通过JQuickExcelConfig提供全局流式导出配置,导出处理器会依据数据量、开关和阈值选择XSSFWorkbook或 Apache POI 的SXSSFWorkbook。
本文只讨论项目 README-CN、pom.xml和现有测试代码能够确认的能力:setStreamingExportEnabled、setStreamingRowAccessWindowSize、setStreamingExportThreshold、setStreamingCompressTempFiles与setCellStyleCacheEnabled。我不会把流式导出写成万能开关,更不会将某台机器上的耗时或内存数字包装成官方能力承诺。任何性能数字都只是示例实验,必须在读者自己的数据、JDK、磁盘和部署参数下复测。
前言
普通 XSSF 导出会把工作簿结构和已创建的行保留在内存中。对于小报表,这种方式直观且便于后续处理;对于行数持续增长的明细报表,堆曲线往往随行数抬升。SXSSF 的思路不是不使用内存,而是只保留最近一个窗口内的行,将较早的行刷入临时文件,从而避免“已写过的每一行”长期占用堆。它降低的是导出阶段的峰值内存压力,不等于让 XML 序列化、ZIP 压缩、网络传输或浏览器打开文件不再消耗时间。
我通常不会按文件大小单独决定是否流式。一个文件体积不大但含有大量短行,仍可能让行对象和单元格对象积累;反过来,一个含少量超长文本的文件可能主要受磁盘和压缩影响。更可操作的入口是预估数据行数、列数、单元格平均长度、样式基数和目标临时目录空间。JQuickExcel 的自动选择以数据条数为依据,因此我会在业务查询后、创建导出处理器前,确保数据规模已经明确。
配置对象是单例。这个事实决定了我会在应用启动时设置稳定策略,而不是在并发 HTTP 请求中按租户来回修改阈值。若多个业务确实需要完全不同的工作簿策略,我会将它们隔离到不同进程、不同服务实例,或者让统一配置采用更保守的基线。否则一次请求修改全局单例,可能改变另一次正在创建处理器的结果,问题会非常难复现。
环境与依赖
当前项目的 Maven 坐标为io.github.paohaijiao:jquick-excel:3.6.0,README-CN 写明运行环境为 Java 8+,同时支持xls与xlsx。流式实现依赖项目的 Apache POI 依赖关系,使用者不需要为了调用JQuickExcelConfig额外声明一套 POI 版本。实际工程中我仍会通过依赖树确认没有其他组件强行覆盖 POI 版本,因为二进制不兼容会先表现为工作簿构建或写出异常,而不是性能问题。
<dependency><groupId>io.github.paohaijiao</groupId><artifactId>jquick-excel</artifactId><version>3.6.0</version></dependency>我会固定基准环境中的 JDK、-Xms、-Xmx、临时目录和输出目录。固定并不意味着所有生产环境都必须使用某一数值,而是为了使对照实验可解释。例如,若第一次用机械盘、第二次用本地 SSD,或者一次测试允许 JVM 扩容、另一次不允许,那么比较出来的是机器状态而不是窗口配置。容器部署时还要确认默认临时目录可写,并为其预留容量监控;流式模式把一部分内存压力转移到临时文件,磁盘不足同样会导致任务失败。
XML 继续用于描述 Excel 服务与 DSL。流式开关不属于 XML DSL,而属于 Java 全局配置;我不会为了启用流式复制一份映射逻辑。下面使用 README-CN 已展示的excels、excel和EXPORT WITH形式,字段映射仍然是业务定义的一部分。
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPEexcelsPUBLIC"-//PAOHAIJIAO//DTD API EXCEL 1.0//EN""classpath:paohaijiao/dtd/Jquick-excel.dtd"><excelsnamespace="com.github.paohaijiao.xml.service.JQuickExcelExportService"><excelname="exportExcel"returnClass="void"><![CDATA[ EXPORT WITH SHEET="Orders", HEADER=true, MAPPING={"orderNo":"订单号","customer":"客户","amount":"金额","createdAt":"创建时间"}, FORMAT={"createdAt":"yyyy-MM-dd","amount":"#,##0.00"} ]]></excel></excels>我会在导出前准备好真实的行集合。这里的数据仍是JQuickRow,不需要为流式模式创建另一种数据类型。需要注意的是,当前导出处理器根据传入列表的size()进行阈值判断;如果业务层先把百万条记录完整装入List,流式只能降低工作簿写入的行对象驻留,无法撤销查询阶段已经产生的对象成本。因此大导出还应配合数据访问层的分页、游标或任务拆分设计,这属于业务获取数据的责任,不应误归因给 Excel 配置。
代码示例
我一般将全局配置集中在启动初始化处。下面的值是一个可读的起点:数据量达到 5,000 行时尝试流式;窗口保留 100 行;临时文件压缩开启;样式缓存开启。它们不是推荐给所有机器的固定答案,尤其不代表任何官方性能结论。
importcom.github.paohaijiao.config.JQuickExcelConfig;publicfinalclassExcelRuntimeConfig{privateExcelRuntimeConfig(){}publicstaticvoidconfigure(){JQuickExcelConfig.getInstance().setStreamingExportEnabled(true).setStreamingExportThreshold(5_000).setStreamingRowAccessWindowSize(100).setStreamingCompressTempFiles(true).setCellStyleCacheEnabled(true);}}设置的顺序不会改变结果,链式调用仅仅利用每个 setter 返回当前配置对象的特性。setStreamingRowAccessWindowSize对非正数会抛出IllegalArgumentException,所以我不会把未校验的外部配置直接塞进去。setStreamingExportThreshold的行为也需要准确理解:阈值小于等于零表示关闭自动流式切换,并不是“从第一行开始强制流式”。要让框架走自动 SXSSF 路径,开关必须为true,且阈值必须为正数,并且数据量要达到该阈值。
下面给出通过 README-CN 中 XML 服务链路导出的示例。业务数据、输出流和 XML 定义照常使用;流式策略在创建处理器之前已经完成设置。示例使用 try-with-resources 关闭输出流,生产 HTTP 响应流的关闭责任应遵循 Web 框架约定。
importcom.github.paohaijiao.convert.JObjectConverter;importcom.github.paohaijiao.statement.JQuickRow;importcom.github.paohaijiao.xml.JQuickFactory;importcom.github.paohaijiao.xml.JQuickXmlFactory;importcom.github.paohaijiao.xml.parse.JQuickParseHandler;importcom.github.paohaijiao.xml.parse.excel.JQuickExcelExportXmlParseFactory;importjava.io.FileOutputStream;importjava.io.OutputStream;importjava.util.List;importjava.util.Map;publicclassStreamingExportExample{publicvoidexport(List<Map<String,Object>>source)throwsException{ExcelRuntimeConfig.configure();List<JQuickRow>rows=JQuickRow.toRows(JObjectConverter.convert(source));try(OutputStreamoutput=newFileOutputStream("orders.xlsx")){JQuickParseHandlerparser=newJQuickExcelExportXmlParseFactory(rows,output);JQuickFactoryfactory=newJQuickXmlFactory(parser,"jquick-excel.xml");JQuickExcelExportServiceservice=factory.createApi(JQuickExcelExportService.class);service.exportExcel("field","value");}}}如果我直接使用导出处理器,则在写出完成后特别关注临时文件清理。现有JExcelExportHandler提供dispose():当实际工作簿是SXSSFWorkbook时,它会调用 POI 的dispose()清理临时磁盘文件;非流式工作簿则不会执行清理动作。把它放进finally可以保证异常路径也尽量回收临时文件。
JExcelExportHandlerhandler=newJExcelExportHandler(model,rows);try(OutputStreamoutput=newFileOutputStream("orders.xlsx")){handler.getWorkBook().write(output);}finally{handler.dispose();}为了确认阈值行为,我会准备小于阈值、等于阈值和大于阈值的三组固定数据。例如阈值为 5,000 时,4,999 行应保留 XSSF 路径,5,000 行和 5,001 行才满足自动流式条件。这里测试的是配置分支,不是在宣称某个行数就是性能拐点。不同列数、不同公式和不同样式会让真正的容量边界完全不同。
原理说明
JQuickExcelConfig中的默认值可以从源码确认:流式开关默认开启,窗口默认 100,自动切换阈值默认 5,000,临时文件压缩默认开启。导出处理器构造时读取全局的流式偏好与窗口大小;进入exportData后,以数据列表大小调用shouldUseStreaming。这个判断首先检查开关,其次要求阈值大于零,最后判断数据量是否大于等于阈值。因此“我设置了窗口却没有看到临时文件”常见原因不是窗口没生效,而是数据量不足、阈值关闭,或开关被别处修改。
满足条件后,处理器以new SXSSFWorkbook(new XSSFWorkbook(), window)创建工作簿,并调用setCompressTempFiles设置临时文件压缩。窗口的含义是可在内存中随机访问的最近行数,而不是批量写入大小,也不是网络缓冲大小。旧行刷出后,不能再把它当作普通 XSSF 行随意修改。导出实现还会检查当前配置是否需要随机行访问;如果某些公式或功能组合要求回看行,处理器会禁用本次流式路径并回退为XSSFWorkbook。我把这一点写进验收项:只检查配置对象的值不够,必须用实际 DSL 与真实功能组合生成文件。
压缩临时文件是典型的 CPU 与磁盘取舍。启用压缩往往减少临时目录占用和磁盘写入量,但需要更多压缩计算;关闭压缩可能在 CPU 紧张、磁盘富余的机器上更合适。我的做法不是预设答案,而是固定数据和 JVM 参数,对两种模式分别执行多次,记录总耗时、临时目录峰值和 GC 情况。示例实验记录可以写为“10 万行、8 列、固定文本长度、JDK 17、某一堆参数下,比较压缩开关”;记录表中不得把结果标成 JQuickExcel 官方数据,发布前必须在目标服务器重新跑。
窗口同样要以工作负载决定。窗口过大时,堆中保留的最近行增多;窗口过小时,如果业务配置需要频繁访问刚写出的行,可能不兼容或失去便利。100 是项目默认值,也是我开始实验的基线。我的调参顺序是先确认能否实际走 SXSSF,再从默认窗口开始观察堆、GC 和临时盘,最后才尝试更小或更大的窗口。直接把窗口调成极小值并不能替代对公式、合并、样式和自动列宽行为的功能回归。
流式也不会减少源数据本身的占用。若控制器先把数据库的全部结果构造成List<Map<String,Object>>,这个列表的峰值已经发生在创建工作簿之前。对于特别大的任务,我会让数据查询和导出任务按业务边界分段,或至少避免在导出前保留同一数据的多份 DTO、Map 和JQuickRow副本。JQuickExcel 当前这一配置解决的是工作簿生成策略,不能替代应用层的数据生命周期治理。
注意事项
- 我先检查实际数据量是否达到正阈值,再判断“流式没有生效”。
setStreamingExportThreshold(0)不会强制流式,而会关闭自动切换。 - 流式配置是全局单例。我不会在并发请求中依据用户选择动态修改它;这样会让不同导出任务互相影响。
setStreamingRowAccessWindowSize必须大于零。将外部字符串转换为整数时,我会先在应用配置层完成校验。- 流式模式会用临时磁盘。部署时我会确认系统临时目录可写、容量足够,并在导出成功或失败后检查是否调用
dispose()清理临时文件。 - 复杂公式或需要随机访问的功能组合可能让处理器回退到 XSSF。我会以真实 XML DSL、真实样式和真实行数做一次功能验证,而不是只看配置日志。
- 开启临时文件压缩不是无成本优化。CPU、磁盘吞吐和临时目录空间都要纳入实验记录;不能只比较单次耗时。
- 样式缓存与流式处理解决不同问题。前者控制 POI
CellStyle的重复创建,后者控制导出行的内存驻留;大导出通常同时关注两者。 - 输出流必须按调用场景正确结束。文件流可由 try-with-resources 管理;Servlet 或框架响应流则应避免过早关闭,从而产生不完整的 xlsx 压缩包。
- 我不会用一次导出的堆使用量替代峰值内存。需要严谨对比时,会结合 GC 日志、JFR 或应用监控来观察过程中的峰值和暂停。
- 所有示例实验数值都必须注明“示例实验,需本机复测”。真实业务中的单元格长度、日期格式、样式种类、磁盘类型和并发度都会改变结论。
总结
流式导出的配置应被看作对内存、临时磁盘和处理能力的联合约束:正阈值才会触发自动切换,行窗口限定近期行驻留,临时文件压缩则在 CPU 与磁盘占用之间取舍。即使工作簿走入 SXSSF 路径,应用仍要避免先构造多份全量数据,并确认真实 DSL 不依赖随机访问;任务结束后还应检查临时文件是否按预期清理。
可作为起点的配置不等于容量承诺。每次调整阈值、窗口或压缩选项后,都应在本机以目标 JDK、代表性列结构、相近数据量和并发条件重复验证,记录文件完整性、总耗时、堆与 GC、临时目录峰值。复测结论只适用于当次机器和输入,发布到目标服务器前仍需再次复测,不能替代真实运行环境的证据。