ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

POI与EasyExcel深度对比:底层原理、性能实测与复杂表头处理实战

POI与EasyExcel深度对比:底层原理、性能实测与复杂表头处理实战 去年年中我们团队做一个数据中台项目技术选型讨论会上出现了很有意思的一幕后端组里有三个老开发坚持用Apache POI理由是“老牌劲旅更稳社区更新也勤”另外两个新来的同事用EasyExcel写过一阵子说“POI写三四十行的活儿EasyExcel十行内搞定性能还更好”。谁也说服不了谁最后这个任务落到我头上——把两个库从里到外各写一遍用实际项目数据做对比给出可执行的结论。那段时间我把POI的官方文档翻了个底朝天又把EasyExcel的issue区逛了好几遍结合后来生产环境里遇到的复杂表头导入、单元格锁定、冻结列、下拉框需求总算对这两个库的边界有了比较清晰的认知。这篇就把对比结果和实操经验完整写出来给正在做技术选型、或者想从POI迁到EasyExcel的朋友一个参考。1. 两者的底层逻辑差异API设计背后的IO模型选择1.1 POI的三种工作模式Apache POI不是只有一种用法它内部其实分了三个层次HSSF管.xls老格式XSSF管.xlsx新格式SXSSF是在XSSF基础上做的流式版本。很多人说POI内存占用高其实说的是XSSF——它会尝试把整个工作簿的DOM结构一次性装进内存。XSSF的机制可以这样理解它把Excel文件当XML解析每个单元格、每行、每个样式都会变成一个Java对象挂在内存树里。一个小文件没事一旦遇到单sheet五万行这种量级内存占用就肉眼可见地往上飙。SXSSF的解决思路是滑动窗口只保留最近N行在内存里之前的行刷到磁盘临时文件。但SXSSF有个限制它基本只能用来写不能读而且对单元格样式的访问能力也打了折扣。POI底层还有一个SAX事件解析模式eventmodel适合用来读超级大的文件。但它用起来极其别扭你得自己处理XML事件回调、自己拼行数据、自己处理单元格类型转换。我记得第一次看eventmodel的示例代码光理解startElement那一段就花了一个下午。这也是后来EasyExcel能火起来的重要原因——EasyExcel本质上是把这一套底层操作封装成易用的API普通业务开发不需要再碰那些底层细节。1.2 EasyExcel站在哪一层EasyExcel是阿里巴巴开源的项目官方定位是“快速、简洁、解决大文件内存溢出的Java处理Excel工具”。它读文件用的是真正的SAX模式也就是边读边解析边回调不把整个文件装进内存写文件则基于POI的SXSSF封装了一层流式写出能力。所以你看到很多评测说EasyExcel内存表现好本质上它是站在POI的肩膀上把POI最累赘的部分包了一层符合直觉的API。这里有个常见的认知误区EasyExcel并“不是”POI的替代品而是POI的上层封装。它内部依然依赖POI做底层的文件格式解析。我们项目里有一个跑批任务用EasyExcel导出一个十几万行的报表JVM堆内存控制在512MB以内很轻松而早期用XSSF写同样数据堆内存在两三GB的机器上都出现过OOM。原因就是底层IO模型不一样不是API写法的差异。理解这一层之后你对后面所有的对比就有一个判断基准凡是POI能做的底层操作比如复杂单元格保护、自定义批注、数据校验EasyExcel理论上也能通过暴露底层POI对象的方式做到只是有些需要绕路。而凡是需要高频、高并发、大数据量的场景直接生写POI大概率是在给自己找麻烦。2. API设计差距同一份导出需求两种写法的体感对比2.1 POI导出代码长什么样为了对比真实体感我们用一个最常见的需求做例子导出学生成绩单字段有学号、姓名、语文、数学、英语外加一行标题标题加粗、居中、背景色。用POI XSSF的写法大致是这样Workbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(成绩单); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setAlignment(HorizontalAlignment.CENTER); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); Row headerRow sheet.createRow(0); String[] headers {学号, 姓名, 语文, 数学, 英语}; for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } for (int i 0; i students.size(); i) { Row row sheet.createRow(i 1); row.createCell(0).setCellValue(students.get(i).getStudentId()); row.createCell(1).setCellValue(students.get(i).getName()); row.createCell(2).setCellValue(students.get(i).getChineseScore()); row.createCell(3).setCellValue(students.get(i).getMathScore()); row.createCell(4).setCellValue(students.get(i).getEnglishScore()); } // 还有列宽、自适应、输出流处理省略这段代码很简单但已经能看出POI的风格所有概念都是对象你要主动管理Workbook、Sheet、Row、Cell、CellStyle、Font。行数多了以后重复代码量很惊人。而且有个细节每新建一个CellStylePOI内部就会为它分配样式记录如果你在循环里创建样式分分钟搞出几万个样式记录文件打开都卡。必须把样式提到循环外。这些坑不写一次大导出根本体会不到。2.2 EasyExcel导出代码长什么样同样需求EasyExcel的写法是用注解定义表头字段Data public class StudentScoreExcel { ExcelProperty(学号) private String studentId; ExcelProperty(姓名) private String name; ExcelProperty(语文) private Integer chineseScore; ExcelProperty(数学) private Integer mathScore; ExcelProperty(英语) private Integer englishScore; }导出EasyExcel.write(outputStream, StudentScoreExcel.class) .sheet(成绩单) .doWrite(students);没了就这么多。没有Row、Cell这些底层概念没有手动创建样式表头样式也有默认处理。如果想自定义表头样式用HeadStyle、ContentStyle这类注解或者在WriteHandler里手动改但那是进阶用法不是必须的。这种反差带来的开发效率提升在大列表字段的场景下尤其明显。我们团队做过一个统计同样实现一个双sheet导出、每个sheet二十个字段、带合计行的需求POI写了两百多行EasyExcel加注解加监听器六十多行。这不是说POI写不了而是它的抽象层次太低把大量时间花在了Excel基础结构的操作上。2.3 两种写法的启发从这个体感差异里我得到的启发是EasyExcel真正解决的问题不是“功能缺失”而是“让大部分场景不再需要关心功能细节”。它通过注解系统把表头结构、格式、合并关系这些元数据从业务代码里剥离出来让代码量和心智负担大幅下降。但这不代表EasyExcel是万能的。它把“表头”通过注解映射到Java对象属性上这个设计有个隐性约束表头结构必须相对规整能映射到一行或一组的字段。对于那种复杂到极致的表头比如三行嵌套、跨行合并、对角线分割的表头注解表达不了就得回到POI层面手动构建。后面第四章我会专门讲这个。3. 性能分水岭大数据量导出谁先撑不住3.1 POI大数据量为什么会OOM用XSSF写入十万行数据问题并不出在“写”这个动作而是出在“写完前这十万行全部在内存里”。XSSF是DOM模型行数据在内存中构建成对象树最后才序列化成XML写入文件。十万行、每行二十个字段意味着内存里有上百万个Cell对象每个Cell还有样式、字体、颜色引用。在只有默认样式的情况下内存也不可避免地要占用几百MB到上GB。生产环境更糟的是如果导出接口并发调用每个请求都会新建一个Workbook内存直接乘上并发数。我们早期有个报表接口单次导出五万行压测到五个并发就OOM了。后来查堆栈发现不是数据库查询的问题就是Workbook对象占的堆太多GC都来不及回收。3.2 EasyExcel的流式压力测试换成EasyExcel之后同一套业务代码我们做了压测记录。导出十万行、二十列的数据实测峰值内存大约在110MB左右。这个数字对Java后端来说非常友好。原因是EasyExcel的写模式基于SXSSF数据是分批刷到临时文件再最终流式写出的内存里同一时间只保留一个窗口的行数。我后来看过EasyExcel的WriteSheet实现它的窗口大小默认是100行也就是说内存里最多同时存在约100行数据的完整状态。这个数字可以通过ExcelWriter的inline参数或者底层SXSSFWorkbook的windowSize调整但默认值在大多数场景下已经够用了。3.3 峰值内存对比数据举一组我们压测的大概数据仅供参考环境是JDK8、4GB堆、CentOS、导出格式xlsx实现方式数据量峰值堆内存导出耗时POI XSSF5万行×20列约820MB约9秒POI SXSSF5万行×20列约130MB约5.5秒EasyExcel5万行×20列约96MB约4.8秒SXSSF能明显降低内存但EasyExcel在它之上还有一些优化例如对常见数据类型做了缓存、减少了对象创建、批写入策略更积极。单独看耗时差距不算大但在并发场景下内存占用直接决定了你需不需要扩容。这也是很多公司把老项目从POI迁移到EasyExcel的最直接理由。3.4 导入场景的RuntimeException陷阱读入大文件的对比更明显。POI的XSSF读取一个十万行的文件同样是把整个工作簿解析进内存。EasyExcel则是一行一行地SAX读取每读一行回调你的监听器一次。但这里有个大坑在EasyExcel的监听器里抛出异常并不会像普通代码那样方便地回滚或中断。我遇到过的问题是导入文件有数据格式错误我希望能把错误行单独收集而不是整个导入失败。如果直接在监听器里抛异常文件解析会被中断连已处理的行数据也会丢失。正确做法是监听器内部收集错误信息最后在doAfterAllAnalysed里统一判断有没有错误、决定是否提交。这块很多初用EasyExcel的朋友都会踩我建议第一次写导入逻辑时就把错误收集机制设计好别指望事后补。这个在后面第四章的表头导入部分还会细说。4. 细节功能PK围绕热搜提问的实战对比4.1 复杂表头导入注解表达不了的时候怎么办很多人搜“easyexcel复杂的表头导入”这个热搜背后是一个实际问题EasyExcel的ExcelProperty在设计上支持多级表头只要传入字符串数组即可。比如ExcelProperty({成绩, 语文}) private Integer chineseScore; ExcelProperty({成绩, 数学}) private Integer mathScore;生成的表头就是两行“成绩”合并两列“语文”“数学”分别作为第二级。这种两级表头非常简单。但如果你遇到三行嵌套、列跨度不规则、表头里还有对角线或者特别的分组EasyExcel的注解系统就表达不了了。这种情况下我的建议是分两步走先把文件读取用EasyExcel的invokeHeadMap回调把表头行的每个单元内容抓出来手动解析成你自己的表头结构或者直接用原生POI的XSSFSheet读取表头区域做合并单元格判断。业务代码确实会多一点但复杂表头的本质是“表格结构不规则”任何工具都不可能在不写逻辑的情况下自动理解。举个实际例子我们导入过一张“项目计划表”前三行都是表头第一行大标题跨整张表第二行才是字段分类第三行是具体字段字段列在第二行和第三行还有跨行合并。用EasyExcel的话监听器里invokeHeadMap返回的Map其实就能拿到每行表头的内容我据此按行号拼装出一个“字段名→列索引”的映射再在invoke里按映射取数。这个方案不需要POI的复杂解析也能处理大部分不规则表头。4.2 锁定单元格protectsheet的正确打开方式热搜词里有“easyexcel sheet.protectsheet(“”)设置了就锁定了全局style.setlocked(false)”这描述的就是很多人在做在线填报模板时踩的坑。POI和EasyExcel底层都一样Excel的单元格锁定默认是开启的。也就是说只要你对Sheet调用了protectSheet哪怕你没有主动设置任何锁定的单元格所有单元格全部会被锁定用户哪个格子都点不了。网上这个搜索姿势说明很多人已经发现了原因要在设置protectSheet前先用CellStyle.setLocked(false)把允许编辑的单元格样式设置好。但这里有一个特别隐蔽的坑EasyExcel的设计理念下你不太容易直接拿到每一个单元格的样式对象。EasyExcel里的正确做法有两种。第一种是写完之后用底层对象再设置我一般这样操作ExcelWriter writer EasyExcel.write(outputStream).build(); WriteSheet writeSheet EasyExcel.writerSheet(模板).build(); writer.write(dataList, writeSheet); // 写入完成后获取底层POI的Sheet对象 Sheet sheet writer.writeContext().writeSheetHolder().getSheet(); for (int i 1; i dataList.size(); i) { Row row sheet.getRow(i); if (row ! null) { for (Cell cell : row) { CellStyle style cell.getCellStyle(); style.setLocked(false); cell.setCellStyle(style); } } } sheet.protectSheet(password); writer.finish();第二种是在自定义WriteHandler里在afterCellDispose回调中对单元格做处理。这个回调时机是每个单元格写完样式后触发此时你有Cell对象可以安全地获取或替换CellStyle。需要注意CellStyle对象在POI里是共享的多个单元格可能引用同一个样式实例。你直接改这个样式会影响到所有引用它的单元格。所以在上面的代码里我是对每个单元格取当前样式、改锁定位、再setCellStyle回去这样每个单元格实际上拿到了一个新的样式副本不会互相污染。还有一个方案这也是我在生产环境最终用的不轻易整体protectSheet而是只对需要保护的sheet做局部控制。如果整个sheet的编辑规则本身就是“除了这些列其他都不能动”那就所有列都设置锁定后再保护如果规则是“就锁定某几列其他都能动”那就反过来给那几列的样式设置锁定其他列都设置setLocked(false)再保护。逻辑上都不复杂关键是别漏掉任何一行尤其是空行和最后一行之外的行。4.3 冻结列一个参数的事冻结列属于POI和EasyExcel都支持很好的功能。POI的写法是sheet.createFreezePane(3, 0); // 冻结前3列 sheet.createFreezePane(0, 1); // 冻结首行 sheet.createFreezePane(2, 1); // 同时冻结前2列和首行EasyExcel不直接提供冻结API但因为它底层就是POI的Sheet所以拿到底层对象调同样的方法即可EasyExcel.write(outputStream, YourClass.class) .sheet(模板) .doWrite(dataList); // 不能用这种方式直接拿sheet正确做法是使用ExcelWriter手动构建 ExcelWriter writer EasyExcel.write(outputStream, YourClass.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(模板).build(); writer.write(dataList, writeSheet); Sheet sheet writer.writeContext().writeSheetHolder().getSheet(); sheet.createFreezePane(2, 1); writer.finish();这个顺序很重要冻结操作一定要在writer.write()之后执行因为此时Sheet才会有真正的行列数据。如果你在writer()之前拿sheet那只是一个空壳设置了冻结也没意义。这也是我在代码里写成“先write数据再拿sheet设冻结”的原因。4.4 下拉框EasyExcel的短板与绕行方案“easyexcel支持下拉框复选吗”这个问题不少人搜过。直接说结论EasyExcel本身没有提供下拉框的API它不是菜单里的“数据验证”。要实现下拉框你需要借助POI的DataValidation机制。POI做数据验证有一套标准流程大概是// 创建数据验证的区域 CellRangeAddressList addressList new CellRangeAddressList(startRow, endRow, startCol, endCol); DVConstraint constraint DVConstraint.createExplicitListConstraint(new String[]{选项A, 选项B, 选项C}); DataValidation validation new XSSFDataValidation(constraint, addressList); sheet.addValidationData(validation);如果要在EasyExcel导出后加下拉框思路是一样的先EasyExcel.write()写出业务数据再用底层Sheet对象调用addValidationData。这里最大的坑是CellRangeAddressList的行号范围要和实际数据行数匹配一旦数据行数动态变化下拉框区域也会错位。我一般是先算出dataList.size()再生成区域。至于“复选”Excel原生的单元格下拉框默认是单选。如果你需要的是“一个单元格里能勾选多个选项”这种交互原生下拉框做不到得用复选框控件之类的方案那就超出POI/EasyExcel的范畴了。很多人在这个问题里真正想要的效果其实是“下拉列表选项可以多级联动”那是切片器或者数据级联的范畴需要在数据验证上做级联规则实现复杂度会显著上升。建议在项目管理阶段就把这个需求拆清楚别指望一个导出工具全包。5. 选型建议我的取舍原则5.1 无条件选EasyExcel的情况先说结论大部分业务系统的日常导入导出我觉得可以直接选EasyExcel。理由很实在代码量小内存友好社区活跃官方文档比较全。尤其是涉及定时任务、大批量导入导出、数据同步报表这类场景用EasyExcel能少写很多“不小心就会OOM”的代码。它还有一堆内置的小功能比如自定义转换器、动态头、模板填充、分批查询写Excel等这些都是导出场景的刚需。比如分页查询导出百万行数据EasyExcel可以配合PageHelper边查边写数据库内存和Excel内存都保持很低。5.2 必须用POI的情况有几类场景建议直接选POI不要用EasyExcel硬顶。一类是强依赖底层精细操作的场景例如需要遍历工作簿中所有区域、读取合并单元格的边界、对已有Excel做结构修改、做复杂公式重算等。这些EasyExcel没有直接API虽然可以通过底层对象绕但绕来绕去还不如直接用POI。另一类是需要在Excel里做数据透视、复杂的图表、跨sheet公式联动、批注、签名、宏等高级Office功能。POI对这些的支持虽然不算完美但比EasyExcel全面很多。EasyExcel的设计哲学是“快速的导入导出”它不是Office自动化引擎。另外如果你要炫炫地做一个“在线Excel编辑器”的后端接口多用户并发编辑同一个文件那也不是EasyExcel的定位。这种需求压根不应该用POI/EasyExcel而是用GcExcel、Aspose.Cells这类商业库或者自研表格引擎。5.3 折中方案混用最后分享一个我现在的实战习惯在一个项目里两个库同时存在。常规列表导入导出用EasyExcel代码清爽、性能稳遇到那种要锁定部分单元格、复杂表头、数据验证多选之类花式需求的报表我再单独开一个POI的工具类只负责处理那部分特殊逻辑。说白了POI是瑞士军刀什么都能干但入门门槛高EasyExcel是顺手的菜刀日常切菜快得很真要做精细雕花还得再拿起军刀。大多数团队不需要在两者之间做一个非黑即白的选择工程师要明白“这周需求是用菜刀还是用军刀”就够了。在切换工具的时候切记不要追求“一行代码替换”。EasyExcel的API设计和POI完全不同迁移不是改个类名的事需要重新组织数据模型和表头逻辑。但我们实际迁完一个模块后代码量通常能减少一半以上线上OOM报警也基本消失了这个收益是很直观的。
返回列表