资讯动态

Java导出Excel实战:POI与EasyExcel选型及大数据量优化

发布时间:2026/10/16 14:25:06 来源:尧图企业网站定制
1. 工具选型POI和EasyExcel到底选哪个老实说Java导出Excel这个需求在项目里出现的频率高得惊人。我做后端开发的这些年几乎每个系统都要碰上几次——运营要导用户数据、财务要导对账单、业务要导订单明细。早些年大家普遍直接用Apache POI硬写后来阿里开源的EasyExcel火了起来两大阵营各自都有庞大的用户群。很多新手第一次接手导出功能时都会纠结其实选型没那么复杂关键看你面对什么场景。Apache POI是一套完整的Office文档操作库不仅能处理Excel还能操作Word和PowerPoint。在Excel领域它提供了两种核心模型HSSFWorkbook处理2003版本的xls格式XSSFWorkbook处理2007及以上版本的xlsx格式。POI的优势在于功能极全从简单的单元格写入到复杂的公式计算、数据透视表、图表生成全都支持而且社区活跃、资料丰富基本你能想到的Excel操作网上都能找到对应的代码。EasyExcel则是专门针对Excel读写场景做了深度优化的框架。它复用了POI中底层解析XML的能力但对使用方式做了完全的重构最大的特点是采用SAX模式逐行读取写入时通过文件流配合临时文件来降低内存占用。如果你的需求集中在“导出大量数据”“导入大量数据”这两个高频场景EasyExcel几乎是为它们量身定做的使用起来代码量也更少。我个人的经验是要做复杂报表模板、样式调整特别多、需要在单元格里做公式联动或者生成图表直接选POI。要做日常的业务数据导出、导入解析尤其是单表数据在十万行往上的用EasyExcel更稳。不过这里有一个很容易被忽略的细节——EasyExcel底层依然依赖POI的jar包所以如果你需要POI的某些高级特性其实也可以用EasyExcel搭配原生POI接口去操作Workbook对象两者并不是完全互斥的关系。提示如果你的项目还在维护老系统导出的是xls格式且要兼容老旧的Excel版本那基本只能在POI的HSSF模型里去处理。EasyExcel官方本身是偏向xlsx的虽然也能处理xls但性能和兼容性体验都差不少。2. 基础导出从零撸出一个最小可用版本很多人第一次写导出功能时遇到的最大障碍不是代码不会写而是被一堆概念绕晕了——Workbook是什么、Sheet是什么、Row是什么、Cell是什么、样式怎么设置、输出流怎么处理。其实用一条生活化的类比就能说清楚一个Excel文件就像一本多页的账本Workbook是整本账本Sheet是账本里的每一页纸Row是一页纸里的某一行Cell是这一行里的某个格子。你要做的操作无非是新建账本、加纸、在纸上写字、设定字体的粗细颜色、控制格子的边框。2.1 最简单的POI导出示例下面这份代码是POI导出最常见的入口适合刚接触的人先跑通流程import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; public class ExcelExportService { public void exportUserList(HttpServletResponse response) throws IOException { ListUserInfo userList queryUserListFromDb(); try (Workbook workbook new XSSFWorkbook()) { Sheet sheet workbook.createSheet(用户列表); // 创建标题行 String[] headers {序号, 姓名, 手机号, 注册日期}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); CellStyle headerStyle workbook.createCellStyle(); Font font workbook.createFont(); font.setBold(true); headerStyle.setFont(font); cell.setCellStyle(headerStyle); } // 写入数据行 for (int i 0; i userList.size(); i) { Row row sheet.createRow(i 1); UserInfo user userList.get(i); row.createCell(0).setCellValue(i 1); row.createCell(1).setCellValue(user.getName()); row.createCell(2).setCellValue(user.getPhone()); row.createCell(3).setCellValue(user.getRegisterDate().toString()); } // 自适应列宽 for (int i 0; i headers.length; i) { sheet.autoSizeColumn(i); } // 设置响应头 String fileName URLEncoder.encode(用户列表_ LocalDate.now(), StandardCharsets.UTF_8) .replace(, %20); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName .xlsx); workbook.write(response.getOutputStream()); } } private ListUserInfo queryUserListFromDb() { // 这里省略真实查库逻辑实际开发中一般通过MyBatis/JPA查询 return new ArrayList(); } }这段代码里值得注意的有几点。第一Workbook对象用了try-with-resources这是必须养成的习惯——XSSFWorkbook内部持有大量XML相关的对象和临时内存不关的话很容易造成内存泄漏。第二文件名做了URLEncoder编码处理这一步很多人容易漏导致下载的时候中文文件名变成一串乱码尤其在Chrome浏览器下特别明显。第三autoSizeColumn这个方法实测下来在大数据量场景并不好用因为要遍历每一列去计算最大宽度性能开销很大所以小数据量导出用用可以大数据量建议手动指定固定的列宽。2.2 EasyExcel的极简写法如果你决定用EasyExcel代码确实会简短不少import com.alibaba.excel.EasyExcel; import com.alibaba.excel.annotation.ExcelProperty; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.List; public class EasyExcelExportService { public void exportUserList(HttpServletResponse response) throws IOException { ListUserInfo userList queryUserListFromDb(); String fileName URLEncoder.encode(用户列表_ LocalDate.now(), StandardCharsets.UTF_8) .replace(, %20); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName .xlsx); EasyExcel.write(response.getOutputStream(), UserInfo.class) .sheet(用户列表) .doWrite(userList); } ExcelProperty(value 序号, index 0) private Integer index; ExcelProperty(value 姓名, index 1) private String name; ExcelProperty(value 手机号, index 2) private String phone; ExcelProperty(value 注册日期, index 3) private LocalDate registerDate; }看到区别了吗EasyExcel通过注解直接把实体类和Excel列对应起来不需要你手动去创建Row、Cell也不需要关心Workbook的细节。对于大部分CRUD系统里的导出需求这种方式效率最高代码也最容易维护。后续如果需要调整导出的列直接改实体类上的注解就行不用改动导出逻辑本身。不过有一点要提前提醒EasyExcel的实体类字段上如果带有ExcelIgnore注解或者字段名是index这种和注解里index字段冲突的情况很容踩坑。另外实体类里不要加一些奇奇怪怪的内部类或者复杂的继承结构解析时会多出很多你意想不到的列导出结果会和你预期的完全不一样。2.3 写出正确响应头的细节导出Excel到浏览器时响应头的设置属于典型的“看起来简单、做起来到处是坑”的部分。Content-Type要用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet这个MIME类型对应的是xlsx。如果是xls则用application/vnd.ms-excel。千万别用application/octet-stream这种万能类型用户下载后打开文件时系统容易识别错误。Content-Disposition我见过不少新手写成attachment;filenamexxx.xlsx数据是出来了但文件名只要带中文在部分浏览器上就会乱码。上面代码里的写法是标准的attachment; filename*UTF-8 URLEncoder.encode(文件名, UTF-8)这里有个细节URLEncoder编码后空格会变成号而在Content-Disposition里号是一个合法字符不会被浏览器转回空格最终你在下载框里看到的文件名就是带加号的。所以要在编码后手动把替换成%20。另外HttpServletResponse的getOutputStream()和getWriter()这两个方法只能调用一个如果你在过滤器中已经拿过Writer到Controller里再用OutputStream就会抛出IllegalStateException。我们项目里就遇到过全局Filter打印请求日志时调用了getWriter()导致后面所有导出接口全部报错排查了半天才定位到。遇到这种情况要么在Filter里不直接操作响应流要么在导出接口里绕开过滤器。3. 大数据量导出内存优化的几个核心手段很多人都遇到过这样的事——业务上要导出一份几十万行的数据代码写完一测JVM直接OutOfMemoryErrorGC日志里Full GC频繁得吓人。初学者以为是自己代码写得不对其实是用POI的XSSFWorkbook在硬扛这个类在构造时就把所有数据都保存在内存里了行数一多内存自然扛不住。3.1 为什么大数据量导出容易爆内存关键在于xlsx文件在底层是一个ZIP压缩包里面包含多个XML片段。POI的XSSFWorkbook会把整个工作簿解析成一棵巨大的对象树每一行、每个单元格都会创建对应的Java对象并且这些对象之间互相引用结构相当复杂。十行数据看不出问题十万行数据光对象创建的开销就是几百兆起步。而EasyExcel采用SAX模式按行解析XML处理完一行就释放一行所以流式的读写方式让内存占用可以降一到两个数量级。另外还有一个容易忽略的点POI写入时默认会开启autoSizeColumn的话会产生额外遍历这个前面也提过。还有压缩策略xlsx写入时需要对XML做压缩这个操作本身会消耗大量CPU和临时内存空间。所以大数据量导出时这几项叠加起来内存压力就更大了。3.2 分页查询配合流式写出实战中处理大数据量导出常规做法是分页查询配合EasyExcel的流式写出。核心思路是不要一次性把所有数据查出来放到List里而是按页查查一页写一页写完就丢。配合EasyExcel的WriteSheet代码可以这样组织public void exportLargeData(HttpServletResponse response) throws IOException { String fileName URLEncoder.encode(订单明细_ LocalDate.now(), StandardCharsets.UTF_8) .replace(, %20); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName .xlsx); try (ExcelWriter excelWriter EasyExcel.write(response.getOutputStream(), OrderInfo.class).build()) { WriteSheet writeSheet EasyExcel.writerSheet(订单明细).build(); int pageNo 1; int pageSize 5000; ListOrderInfo pageData; do { pageData queryPageFromDb(pageNo, pageSize); if (!pageData.isEmpty()) { excelWriter.write(pageData, writeSheet); } pageNo; } while (pageData.size() pageSize); } }这里有两个关键点值得展开说。第一queryPageFromDb内部必须要用稳定的排序字段做分页最好是主键ID排序不要用随机排序或者不排序。不排序的话翻页过程中可能出现数据重复或者漏掉的情况导出的结果会缺失。第二pageSize不建议设置得太大我常用5000到10000这个区间。设置大了虽然SQL查询次数少了但每次传到内存里的行数也多GC压力反而大。注意直接返回ListOrderInfo是常规做法但如果单行数据字段特别多比如每行有几十个列那5000条记录也意味着几十万个字段值的对象这种情况下建议把pageSize调小到2000左右观察一下JVM内存表现再决定。3.3 用SXSSFWorkbook做POI的大数据量方案如果你的项目已经重度依赖POI不想换框架也不愿意大规模改动现有代码那POI自带的SXSSFWorkbook是一个过渡方案。它的原理是维护一个滑动窗口只保留最近N行在内存中更早的行直接刷到磁盘临时文件里。使用方法如下try (SXSSFWorkbook workbook new SXSSFWorkbook(1000)) { Sheet sheet workbook.createSheet(订单明细); // 写入表头和数据行 // ... workbook.write(response.getOutputStream()); } finally { workbook.dispose(); // 清理临时文件 }构造函数里的1000就是窗口大小表示内存中最多保留1000行超过的话旧行会被移动到磁盘。用SXSSFWorkbook时注意一点workbook.dispose()必须调用否则临时文件会残留在系统临时目录里量大的时候磁盘空间会被慢慢吃光。这是API层面没在文档中特意强调但实际踩坑概率很高的事情。另外SXSSFWorkbook有一个明显的限制——因为它只保留了部分行在内存中所以你不能随机访问之前已经刷盘的行比如中途想修改第100行某个单元格的值这种操作是做不到的。所以SXSSFWorkbook适合纯粹的追加写模式不适合做修改和随机读取。3.4 导出过程中数据库连接管理大数据量导出另一个容易忽视的问题是数据库连接占用。如果按页查询默认情况下每一页查询都可能从连接池中取一个连接并返回。如果代码里对同一个连接做了多次查询而你的事务隔离级别设置不当在这个长时间导出的过程中数据库端可能积累大量事务锁。我建议导出功能尽量以只读事务的方式执行必要的时候设置语句超时同时注意在导出全流程结束后确保数据库连接及时释放避免连接池被耗尽。还有一点如果有定时任务形式的报表导出尽量不要和线上核心业务共用一个连接池配置。我们曾经遇到过一个报表任务每天晚上导出百万级数据把连接池占满导致白天几个核心接口偶发超时。后来把这些重任务单独放了一个连接池加上并发上限问题才消停。3.5 异步导出与前端体验设计数据量大了以后导出的耗时会显著增加HTTP请求长期挂在那里用户等得不耐烦也容易出现网关超时。这种情况下更适合异步导出的方案用户点击导出后端立刻返回一个“任务已创建”的标识前端通过轮询接口查询导出状态完成后提供一个下载链接。实现上可以基于一个简单的任务表导出任务ID、状态、文件路径、创建时间。后台线程池消费任务顺序执行导出并生成文件到本地磁盘或对象存储更新状态。前端用一个定时器每3秒查一次状态任务完成后把下载地址交给浏览器。文件保存的位置要考虑清理策略一直堆积在磁盘上会占用太多空间。我们通常的做法是文件保存三天三天后定时清理。如果需要审计留档再把文件同步到对象存储里本地只保留临时副本。4. 模板导出与样式处理做出能看的报表日常导出数据列表直接用EasyExcel就够了但有些场景必须走模板——比如统一格式的报销单、带有复杂公式的成绩单、已设计好样式和合并单元格的统计报表。这类需求如果用代码去拼样式工作量巨大且难以维护。更合理的思路是先用Excel做好模板文件放到项目的资源目录里导出时直接套模板填充数据。4.1 EasyExcel的模板填充EasyExcel对模板导出支持得很优雅用法很简洁EasyExcel.write(response.getOutputStream()) .withTemplate(templates/report_template.xlsx) .sheet() .doWrite(dataList);模板文件里用类似{.name}、{.phoneNo}这样的语法标记需要填充的位置。比如模板某处写了一个{date}导出时程序会在内存中用UserInfo对象的date字段值去替换它。这一步逻辑很直观。但模板填充有一些注意事项模板文件本身最好不要包含太多复杂的公式和条件样式因为每填充一个单元格Excel引擎都要重新计算公式效率会下降。如果你在模板里设置了数据验证下拉列表、批注这些高级内容导出时部分场景下会失效要提前跟业务确认这些内容是否要在导出文件中保留。模板文件的路径最好放在classpath下的固定目录里不要依赖操作系统绝对路径避免换环境部署时找不到文件。4.2 POI的模板填充POI同样可以做模板导出核心是先把模板读入Workbook找到对应的Sheet和Cell用setCellValue往里面填数据try (Workbook templateWorkbook new XSSFWorkbook(new FileInputStream(template.xlsx))) { Sheet sheet templateWorkbook.getSheetAt(0); // 假设模板第1行是标题数据从第2行开始 Row row sheet.createRow(1); row.createCell(0).setCellValue(张三); row.createCell(1).setCellValue(13800000000); templateWorkbook.write(response.getOutputStream()); }这种写法要求你事先知道模板每个单元格的坐标开发前最好先打开Excel文件记录一下表头在第几行数据从第几行第几列开始。为了减少维护成本可以把这些坐标定义成常量或者配置项不要散落在业务代码里。4.3 单元格样式别小看边框和字体我做过的导出功能里被测试和UI同事挑毛病最多的不是数据错了而是样式太丑——没有边框、字体大小不一、日期串成一串数字。POI里设置样式是比较啰嗦的但确实能精细控制每一个细节CellStyle style workbook.createCellStyle(); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); Font font workbook.createFont(); font.setFontHeightInPoints((short) 11); font.setFontName(微软雅黑); style.setFont(font);这里有个性能相关的小技巧当你有大量行需要设置同一套样式时不要每行都创建一个新的CellStyle尽量复用同一个样式对象。因为每个CellStyle在底层都要生成对应的样式记录创建几千个样式对象会显著拉大文件体积和内存占用。正确做法是在循环外创建好样式循环里只做cell.setCellStyle(sharedStyle)。日期格式的坑也值得一提。如果你直接setCellValue(new Date())导出的单元格在Excel里显示的是时间戳数字用户根本看不懂。要养成给时间列单独设置数据格式的习惯CellStyle dateStyle workbook.createCellStyle(); CreationHelper createHelper workbook.getCreationHelper(); dateStyle.setDataFormat(createHelper.createDataFormat().getFormat(yyyy-MM-dd HH:mm:ss)); cell.setCellStyle(dateStyle); cell.setCellValue(createHelper.createRichTextString(formattedTime));4.4 合并单元格和固定列宽报表场景中合并单元格是一个高频需求。比如表头跨两列、多行合并显示汇总信息。POI中合并单元格是这样用的// 合并从第0行第0列到第0行第1列 sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 1));但设置合并区域后合并区域里只有左上角那个单元格的值会被显示出来其他单元格的值会被隐藏。如果你先往多个单元格写了值再合并最后只会保留左上角的值其他值直接消失视觉上像数据丢失。所以要理顺顺序先合并再写值或者写值后确保其他单元格不写内容。固定列宽也属于细节体验的范畴。用autoSizeColumn虽然好用但在大数据量时性能不好还会因为中文宽度计算差异在某些字体下产生截断效果。更稳的方案是手动指定sheet.setColumnWidth(0, 8 * 256); // 第1列宽度为8个字符 sheet.setColumnWidth(1, 20 * 256); // 第2列宽度为20个字符宽度单位是1/256个字符宽这个计算方式初看有点奇怪但这是Excel底层约定20 * 256就代表20个标准字符的宽度。需要注意的是如果单元格内容特别长手动设置的宽度可能会导致内容显示不全必要时配合setWrapText(true)让文本自动换行。5. 常见问题与排查实录平时群里提问最多的基本就是下面这几个老问题我按排查优先级整理成了一张速查表导出功能出了问题从上到下照着排查一般都能解决。症状可能原因排查方向导出的文件在Excel里提示损坏或无法打开响应流里混入了非Excel内容比如日志、报错信息或者同时用了getWriter和getOutputStream先直接下载文件用文本编辑器打开看头部是否有HTML或乱码文件下载后中文文件名乱码Content-Disposition未做URLEncoder编码或编码后的加号没处理对照上文中的标准写法处理文件名大数据量导出一会儿就OOMXSSFWorkbook直接装载全量数据换EasyExcel或SXSSFWorkbook确认是否分页查询导出后数据列错位实体类字段顺序与Excel列不一致或临时调整过表头用ExcelProperty的index显式指定列顺序导出时间为纯数字单元格格式未设置为日期格式给日期字段设置对应DataFormat或者直接写成字符串前端下载时提示跨域或下载失败后端接口未配置相应CORS头或网关超时如果是大文件尽量走异步导出或调大网关超时时间模板文件找不到包路径不对或打包时未包含模板文件确认模板是否在resources目录下重新打包5.1 导出内容被日志污染有一次同事找我排查一个诡异问题用户从系统下载Excel后打开提示文件已损坏但在WPS里又能正常打开内容里还夹杂着一段报错日志。排查到最后发现是某次查询数据库异常时框架打印了很长的错误日志到控制台而当时的系统配置了控制台日志重定向到标准输出且服务端在设置响应头之前就调用了System.out.println日志内容混进了响应体。这个问题看起来很像偶然情况但在并发量高的系统里确实会偶发出现。建议在导出流程中关闭任何形式的调试输出遇到异常统一走异常处理器返回JSON而不是让错误信息直接输出到响应流里。5.2 数字类型超出Excel精度还有一个经常被忽视的问题如果你导出的数据里有超长数字比如19位数字的身份证号或者订单号直接写入单元格会变成科学计数法而且Excel的数值精度只有15位第16位开始可能变成0数据就悄悄变了。这个问题的根源在于Excel的数字类型本身是用双精度浮点数存储的。解决方案很简单把这类字段在实体类中定义为String类型或者在POI中调用cell.setCellValue(String.valueOf(id))。有人可能觉得改成字符串导出后用户没法直接对数字求和了但为了数据准确性这个代价值得。5.3 大文件下载到一半断掉网络环境不稳定时大文件下载到一半断掉在用户看来就是“又失败了”。这类问题其实和导出代码本身关系不大更多是网关超时和连接重置的问题。如果导出的文件经常超过几十兆我建议后端先导出到本地临时文件然后提供一个单独的文件下载接口由Nginx这类静态服务器负责下发文件这样传输性能和稳定性都会好很多。5.4 内存泄漏排查思路遇到导出后JVM内存持续不降先不要急着怀疑POI。我踩过的一个比较隐蔽的坑是在导出的实体类里放了一个byte[]大字段每个对象几十KBList里放了五万条这一下就有好几GB的无意义内存。后来在导出DTO上做了裁剪只保留要导出的列问题迎刃而解。另外一个排查技巧是导出功能加开关默认打印内存快照。可以用Runtime.getRuntime().totalMemory()和freeMemory()在导出前后各打一行日志用于快速定位导出过程中的内存峰值情况。在无法用监控系统的环境下这个方法特别管用。6. 几个多年以后我才想明白的建议最后分享几点在多个项目里实践下来、比单点技术更重要的经验。第一导出功能不要散落写在各个业务Service里。最好抽一个公共的导出服务统一处理响应头设置、文件名编码、异常拦截、异步任务管理。这样做的好处是后续要调整安全性比如加权限校验、加审计日志、改统一风格只改一个地方就行。我们项目里还专门维护了一个导出字段的元数据表配置了每个业务模块导出的列、顺序、格式后续新增导出需求基本不用写新代码。第二Excel列顺序和字段名称的变更最好通过单元测试守住。我们吃过一次亏DBA调整了某张表的字段顺序结果导出数据的列位置全乱了运营导出的数据对不上号后来在全公司的导出模块上统一加了列顺序校验断言字段顺序有变动时测试直接红。第三记得考虑浏览器兼容性。不同浏览器对Content-Disposition的处理有差异IE和Edge老版本不太认filename*UTF-8这种格式需要兼容处理。虽然现在用旧版浏览器的人少了但企业客户的高版本浏览器环境仍然可能有奇葩配置遇到反馈时第一时间核实浏览器版本能大幅缩短排查路径。第四导出的数据量别只顾着性能还要思考业务边界。我曾经见过一个导出功能把某平台全部用户的手机号、地址信息一次性导出来没有任何脱敏控制等发现时信息已通过即时通讯工具流出去了。后来我们所有的导出任务都加上了数据可见范围校验导出操作要做审计记录超过一定行数的导出必须走异步任务审批流程。当你能熟练导出Excel的时候也要想清楚这份Excel里装的数据是不是所有人都应该能拿到的。导出Excel这个功能看起来只是CRUD系统里的一个配角但细节和边界相当多。写完这篇文章之后回头看每一次踩坑和被用户吐槽其实都变成了今天能快速定位问题的直觉。技术选型没有绝对的正确答案关键是用你手上的工具把每一个导出需求处理得稳、准、明白。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑