JQuickExcel 性能基准设计:用可复现实验替代单次耗时结论
tags: #JQuickExcel #JavaExcel #开源 #POI #Excel工具
简介
Excel 导入导出的性能非常容易被一次本机运行误导。相同的行数,在不同列数、文本长度、日期格式、样式种类、磁盘、JDK、堆参数和并发条件下,结果都可能显著不同。对 JQuickExcel 而言,流式导出、OPCPackage 打开策略、批量导入和样式缓存还会分别影响不同资源维度。因此我设计基准时,先让输入、配置、输出路径和正确性检查可复现,再讨论耗时、内存和容量边界。
本文仅使用项目中已确认的配置与 API:JQuickExcelConfig的流式、导入和样式缓存配置,以及JExcelImportHandler#importDataInBatch。项目测试源码已有JQuickExcelBenchmark,它展示了对 XSSF 与流式导出、OPCPackage 开关以及不同输入规模进行测试的基本方向。本文不会引用或杜撰任何官方性能数值;出现的行数、堆大小和硬件仅用于描述示例实验方案,全部需要在本机和目标服务器复测。
前言
我认为性能基准的目标不是生成一个漂亮的“每秒多少行”,而是回答上线决策问题:某种真实文件在目标机器上会不会超时,是否可能 OOM,临时目录空间是否够用,样式是否会失控,导入后端是否能持续消费,以及异常时能否恢复。单一总耗时不能回答这些问题,因为它把数据准备、Excel 编解码、ZIP 压缩、磁盘写入、网络、数据库和 GC 都混在了一起。
因此我将场景分层。导出至少分为准备JQuickRow、创建工作簿、写出文件和清理临时资源;导入至少分为打开工作簿、解析映射与转换、批次消费、持久化和错误记录。需要评估数据库时,我会再拆成“纯解析基准”和“端到端基准”。纯解析能定位 Excel 处理成本,端到端反映实际用户等待时间;把两者混在同一张结果表中,只会让调优方向变得模糊。
我也不把一次Runtime.totalMemory() - freeMemory()当作峰值内存。它最多是某一时刻的近似。更严谨时我会记录 GC 日志、JFR 或平台监控,并观察堆、GC 暂停、CPU、临时目录和磁盘吞吐的时间线。即使只做轻量实验,也至少要在每个 case 运行多次,剔除首次类加载或文件系统缓存带来的偶然性,并报告中位数、最大值和失败信息。
环境与依赖
基准依赖与项目本身保持一致。当前pom.xml中已有 JQuickExcel、Apache POI 和 JUnit 测试依赖;我不会为了写一个性能文章引入 JMH 或其他库后却假装它是项目现有标准。若团队决定采用专门基准框架,可以单独设计和审查,但本文的最小方案直接复用测试目录中的 JUnit 或main入口。
<dependency><groupId>io.github.paohaijiao</groupId><artifactId>jquick-excel</artifactId><version>3.6.0</version></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><scope>test</scope></dependency>我会在每次执行前记录:Git 提交或构建版本、JDK 发行版和版本、操作系统、CPU、内存、磁盘类型、可用临时空间、JVM 参数、运行进程数、输入文件生成种子和输出目录。固定堆大小是为了可比,不是推荐某个绝对值。比如示例可固定-Xms2g -Xmx2g,但它只适用于拥有相应资源的实验机器。所有最终容量结论必须在实际部署规格复测。
基准 XML 应足够接近生产而又可控。我不会只测两列短字符串,因为真实报表常包含日期、金额、转换和样式。下面使用 README-CN 确认的导出 DSL,包含字段映射、格式和有限样式规则。
<?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="Benchmark", HEADER=true, MAPPING={"id":"编号","name":"名称","createdAt":"日期","amount":"金额","status":"状态"}, FORMAT={"createdAt":"yyyy-MM-dd","amount":"#,##0.00"}, STYLE={ROW 1:{bold:true,alignment:center},A2:E100:{borderBottom:thin}} ]]></excel></excels>对照实验必须复用同一份输入。项目测试中的数据生成使用固定随机种子,这是可借鉴的做法:同一规模下,XSSF 与流式模式、缓存开关或不同窗口配置应处理同一字段长度、日期分布和状态分布。若每次随机生成一份不同数据,表面比较的是配置,实际比较的可能是压缩率或文本长度差异。
代码示例
每一个导出 case 前,我会重置全局单例并设置明确配置,避免上一轮实验的单例状态泄漏到下一轮。resetDefault()的存在正是为了测试场景显式还原配置。下例只展示项目已存在的 setter,数字是实验参数,不是官方建议值。
importcom.github.paohaijiao.config.JQuickExcelConfig;JQuickExcelConfig.resetDefault();JQuickExcelConfig.getInstance().setStreamingExportEnabled(true).setStreamingExportThreshold(1_000).setStreamingRowAccessWindowSize(100).setStreamingCompressTempFiles(true).setCellStyleCacheEnabled(true).setBigFileImportEnabled(true).setImportBatchThreshold(20_000);计时边界需要先定义。若我评估用户请求的端到端等待时间,就从创建输出流前开始,到工作簿写出完成、流关闭且流式临时文件清理后结束;若只评估 JQuickExcel 的编码成本,则把查询和网络排除,并在报告中写清楚。以下是简化计时方式,实际报告还会保存输入规模和输出文件大小。
longstarted=System.nanoTime();JExcelExportHandlerhandler=newJExcelExportHandler(model,rows);try(OutputStreamoutput=newFileOutputStream(outputFile)){handler.getWorkBook().write(output);}finally{handler.dispose();}longelapsedMs=(System.nanoTime()-started)/1_000_000;longusedBytes=Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory();System.out.printf("rows=%d elapsedMs=%d usedMB=%.1f%n",rows.size(),elapsedMs,usedBytes/1024.0/1024.0);这一段的usedBytes只是一条观测值,不能命名为“峰值内存”。若我需要峰值,会通过 JVM 观测工具收集;若只是快速烟囱测试,则把这个字段命名为“结束时已用堆近似值”,避免误导读者。每个 case 输出不同文件名,防止覆盖、旧文件大小和文件系统缓存干扰结论。
导入测试同样要区分全量读取和批量消费。当前 API 的批量调用签名如下,消费者返回true继续、返回false停止。纯解析 case 可以在消费者内做确定的轻量校验;端到端 case 则同步调用实际或隔离的持久化层,并另行记录数据库指标。
try(InputStreaminput=newFileInputStream(inputFile)){JExcelImportHandlerhandler=newJExcelImportHandler(input);inttotal=handler.importDataInBatch(model,5_000,batch->{consumeForBenchmark(batch);returntrue;});System.out.println("readRows="+total);}我会设计最小矩阵而不是一次跑几十个组合。导出可先比较:关闭流式的对照组;启用流式、阈值为 1,000、窗口 100 的组;必要时再比较临时文件压缩开关。导入可先比较:普通构造路径与setBigFileImportEnabled(true);全量导入与importDataInBatch;批量页大小 1,000、2,000、5,000。样式实验固定数据规模,比较有限样式和高基数样式的风险,不把它和行数变化混合。
原理说明
项目测试JQuickExcelBenchmark已体现一个重要原则:按 100、1,000、5,000、10,000 等多个规模运行导出,并分别设置非流式和流式配置;导入夹具则覆盖 10,000、50,000、100,000 行,并对 OPCPackage 开关进行比较。这样的阶梯规模比“直接跑百万行”更有解释力。若 1 万到 5 万之间出现耗时或内存斜率突变,我会先分析这个区间的 GC、磁盘和功能组合,再决定是否继续增大规模。
基准数据至少要描述行数、列数、单元格内容类型、平均和最大文本长度、空值比例、日期数量、数值数量、样式组合数、公式、合并单元格、Sheet 数和输入输出文件大小。这些变量直接影响共享字符串、压缩率、样式创建和 POI 工作簿结构。只写“10 万行导出”没有足够信息,因为 10 万行两列短 ID 与 10 万行二十列长文本、带公式和合并区域的报表不是同一工作负载。
流式实验还必须确认实际路径。导出处理器先根据开关、正阈值和data.size()判断是否可用流式,但随后会检查是否需要随机行访问;需要时可能回退到XSSFWorkbook。因此测试报告不能只写“streaming=true”,应记录数据行数、阈值、窗口、压缩开关和所使用的 DSL 特性,并通过结果或调试证据确认没有在运行时回退。否则对照名义上是 SXSSF,实际上仍可能是 XSSF。
导入的setBigFileImportEnabled(true)会优先尝试OPCPackage.open再构建XSSFWorkbook,失败时回退。它改善的是打开工作簿的策略;importDataInBatch则限制解析后应用层持有的当前JQuickRow列表。基准中应分别变化这两个维度,不能在同一次变更中既开 OPCPackage、又改页大小、又换数据库批策略,然后声称某个单一因素带来提升。
样式缓存实验也应测量样式基数,而不是只测时间。有限的表头、日期、金额和状态样式应能给缓存提供可复用对象;若每行都有不同颜色或格式,缓存也无法改变业务规则制造的组合爆炸。项目配置类说明缓存用于规避 POI 的 64,000 CellStyle 上限风险,因此当工作负载存在动态样式时,我会把“能否正常生成并被 Excel 打开”作为与耗时同等级的结果指标。
示例实验的报告格式可以包含:环境快照、输入定义、配置快照、正确性结果、三次以上运行的耗时、结束时堆近似值或外部监控峰值、GC、临时盘峰值、输出文件大小和异常栈。所有数字必须标为“示例实验,需本机复测”,因为硬件、JDK、JVM 参数和模板稍有变化就会失效。这样的报告为团队提供复现实验的方法,而不是伪造通用吞吐结论。
注意事项
- 我会先验证生成文件的正确性:行数、表头、抽样数据、日期金额格式、公式和 Excel 打开能力;性能快但文件损坏没有价值。
- 每个 case 必须使用相同输入、相同 JVM 参数和相同输出介质,才能比较配置差异。
JQuickExcelConfig是单例。测试 case 之间调用resetDefault(),否则前一轮配置可能污染后一轮。- 计时边界必须写入报告。不要把数据查询时间与 Excel 写出时间混在一起却没有说明。
Runtime的已用堆只是近似观测值,不是峰值;需要可靠内存结论时使用 GC 日志、JFR 或监控系统。- 流式组应验证没有因随机访问需求回退到 XSSF,不能只根据开关值命名结果。
- 导入测试把 OPCPackage 开关、批量 API 和数据库消费拆开变化,避免一次改太多变量。
- 输出文件每次使用独立路径,并记录文件大小;旧文件覆盖和文件缓存会污染结果。
- 每个规模至少多次执行,观察中位数、最大值和失败率,而不是选取最好的一次。
- 所有性能数字仅是示例实验,必须在本机与目标部署环境复测,不能表述为 JQuickExcel 官方数据。
总结
可靠的基准先定义输入和正确性,再讨论耗时。导出要区分普通与流式路径、窗口和临时文件选项;导入要把工作簿打开、批量消费与数据库写入拆开;样式测试还要记录样式基数。每轮只改变一个变量,并把行数、表头、公式、格式、文件可打开性作为前置验收,避免把损坏文件的快速生成误判为优化。
报告应保留环境、JDK 与 JVM 参数、模板和输入版本、配置快照、重复运行结果、GC 与临时盘观测,并说明计时边界。示例数字必须在本机复测,且只能说明该机器、该数据和该并发下的表现;需要用于部署决策时,应在目标服务器重新执行相同实验,依据中位数、失败率和资源峰值确定容量边界。