ARTICLE DETAIL

资讯详情

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

Java处理百万级Excel导入导出:EasyExcel流式实践与避坑指南

Java处理百万级Excel导入导出:EasyExcel流式实践与避坑指南 最近接了个活儿运营部门拿着一个Excel模板找过来300多列数据量少说也得上百万行要求在管理系统里既能导入也能导出。我接过这个需求的时候就知道这又是一次Java处理Excel的老生常谈但它背后牵扯出来的东西不少——选哪个库、怎么设计表头映射、大文件会不会内存溢出、导入失败怎么提示、并发场景下临时文件怎么处理每一样都能让人踩几个坑。这篇文章就记录一下我在Java里实现Excel导入导出功能的完整思路和落地代码。用的核心方案是阿里开源的EasyExcel配合Apache POI做一些底层补充。写这篇文章的目标读者就是那些已经能写Java增删改查、但还没怎么碰过Excel处理这块的开发者同时也聊点真实的业务场景比如大数据量导出、复杂表头导入这类高并发项目里躲不开的问题希望能帮大家少走点弯路。1. 方案选型为什么EasyExcel成了主流选择1.1 POI、EasyExcel、Faxtable怎么选很多刚接触Java操作Excel的人第一反应是去用Apache POI网上教程多功能也确实全。POI是Apache基金会提供的Java操作Microsoft Office格式的类库读写xls和xlsx都能搞定自由度非常高——你可以控制到单元格级别甚至可以操作公式、图表、条件格式。但POI有一个绕不开的痛点慢。特别是xlsx格式底层是基于XML的打包结构POI的XSSFWorkbook会把整个工作簿加载进内存一个10万行的文件就能吃掉几百MB堆内存项目部署在2G内存的服务器上基本就等着OOM了。虽然POI后面也出了SXSSFWorkbook专门应对大数据量写入但它只是解决了写的问题读的场景依然要靠自己手动分页解析操作起来很累。EasyExcel是阿里开源的一个轻量级Excel处理框架核心卖点有两个一是写入时不再创建完整的Workbook对象而是逐行解析成Model二是读取时通过回调Listener的方式边读边处理把内存占用降了一个数量级。实测下来同样10万条数据的导出POI可能要500MB内存EasyExcel基本能控制在几十MB以内。那Faxtable呢它是另一个开源工具特点是封装更彻底直接用一行代码完成列表转Excel但代价是灵活度低——比如它不太方便处理合并单元格、自定义样式、多级表头这些复杂场景。我一般只在内部小工具里用Faxtable对外交付或者需要精细控制格式的场景还是走EasyExcel。1.2 先搞清楚业务场景再动手写代码之前先做一轮需求拆解。Excel导入导出看着是个通用功能但不同业务场景对它的要求天差地别。我有一次接手一个数据迁移项目甲方要求支持模板下载、批量导入、错误数据回写还要在导入日志里区分“校验失败”和“入库失败”。这跟只做一个“文件上传-解析-入库”的简单功能完全不是一个复杂度。还有个场景是报表导出要求导出的文件打开时直接带筛选、冻结窗格、列宽自适应这种就得在样式层面下功夫。所以技术选型之前先回答这么几个问题单次导入导出的数据量级是多少1万行以内随便搞10万行以上必须考虑流式处理。文件格式是xls还是xlsxxls是老格式最多支持65536行底层是二进制结构xlsx没有行数限制但需要更复杂的解析。表头是固定不变的还是动态变化的有些业务字段是配置化的比如自定义表单表头每次都不一样这时候就不能用注解静态映射了得动态构建表头。导入失败后需要怎样的反馈是逐条报错还是统一给一个错误清单有没有并发导出场景多个用户同时点导出服务器的内存和磁盘能扛住吗这些问题梳理清楚了再动手写工具类才不会返工。2. 项目结构与核心依赖准备2.1 引入依赖的细节如果只是用EasyExcelMaven里加一个依赖就够了dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version /dependency注意EasyExcel 3.x内部已经传递依赖了poi的3.17版本一般不需要单独再加POI。但如果你的项目里其他模块也用到了POI而且版本比这个高一定要小心冲突。常见的报错是NoSuchMethodError或者ClassNotFoundException这种时候可以在依赖里排除掉EasyExcel传递进来的poi统一使用你项目里的版本dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version exclusions exclusion groupIdorg.apache.poi/groupId artifactIdpoi/artifactId /exclusion exclusion groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId /exclusion /exclusions /dependency还有一个细节EasyExcel 3.x要求JDK 8如果你的项目还在用JDK 7就只能用EasyExcel 2.x的版本API上有差别这个后面再说。2.2 定义导入导出的数据模型EasyExcel的核心思路是注解驱动。你写一个普通Java类字段上添加ExcelProperty注解指定列名和顺序框架就自动帮你完成Excel行和Java对象之间的转换。我在实际项目里一般是单独建一个dto包专门放这类Excel模型。注意这个模型最好不要直接复用数据库的Entity因为Entity里的字段通常比Excel列多而且有些字段比如createTime、updateTime在导入时是前端不会给的复用了反而容易出问题。举个典型的例子public class UserExcelDTO { ExcelProperty(value 姓名, index 0) private String name; ExcelProperty(value 手机号, index 1) private String phone; ExcelProperty(value 邮箱, index 2) private String email; ExcelProperty(value 入职日期, index 3) DateTimeFormat(yyyy-MM-dd) private Date hireDate; ExcelProperty(value 薪资, index 4) NumberFormat(#.##) private BigDecimal salary; // getter / setter 略 }这里有几件小事值得说一说。第一index这个属性不是必填的但如果Excel里的列顺序是固定的我强烈建议你写上。不写的话EasyExcel会按照字段声明的顺序去匹配万一哪次调整了字段顺序导入的数据就全错位了而且不容易排查。第二DateTimeFormat和NumberFormat这两个注解很关键。Excel里的日期在底层存储的是数字直接映射到Java的Date会报类型转换异常。加上DateTimeFormat(yyyy-MM-dd)之后EasyExcel会按照指定格式帮你解析字符串格式的日期。同理数字格式化可以处理Excel里那种带千分位分隔符的数字文本。第三导入时有些列在Excel里是有的但你不想导入到数据库里去这种字段不要写在DTO里就行了。反过来有些列是程序内部填充的比如“数据来源”固定写“人工导入”这个字段也不要写在DTO里在业务代码里set进去就行。2.3 动态表头的设计思路固定表头用注解就够了但实际业务里有一种场景表头是动态的。比如用户自定义了一套表单今天他配了5个字段明天可能配8个你不可能为每种组合都写一个DTO类。这种场景下有两种方案。方案一完全动态读取。不写DTO直接用MapInteger, String接收每一行的数据Integer是列索引String是单元格的值。读取时用NoModelDataListener重写invoke方法参数是MapInteger, String。方案二动态表头动态列。导出时先根据配置构建表头集合ListListString再通过EasyExcel.writer().head(headList)动态设置最后用ListListString写数据行。这种方式能处理任意列数的Excel但代价是丢失了类型转换能力所有值都以字符串形式处理。我在项目里如果遇到动态列一般会对字段类型做个记录比如配置表里存了“这个字段是日期类型”或者“这个字段是数字类型”然后在写入Excel之前先做一次类型转换把Date格式化成字符串把BigDecimal保留两位小数。这样虽然多了一道转换但保证了Excel里的数据格式是友好的。这种方式也可以解决热搜词里提到的“easyexcel复杂的表头导入”——复杂表头本质上就是动态列和合并单元格的组合。EasyExcel读取复杂表头时ExcelProperty的value属性可以传一个字符串数组数组里的每个元素表示一级表头顺序从外到内。比如“个人信息→姓名”这种两级表头就写成ExcelProperty(value {个人信息, 姓名}, index 0) private String name;3. 查询级大数据导出实现3.1 导出流程的基本写法先看一个最简单的导出接口实现。Controller接收响应对象然后调用EasyExcel.write写入数据。GetMapping(/export) public void export(RequestParam(required false) String keyword, HttpServletResponse response) throws IOException { // 设置响应头 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); String fileName URLEncoder.encode(用户数据_ System.currentTimeMillis(), UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename fileName .xlsx); // 查询数据 ListUserExcelDTO list userService.queryForExport(keyword); // 写入Excel EasyExcel.write(response.getOutputStream(), UserExcelDTO.class) .sheet(用户数据) .doWrite(list); }这里有几个容易踩的坑。响应头里的Content-Typexlsx格式必须写成application/vnd.openxmlformats-officedocument.spreadsheetml.sheet写成application/vnd.ms-excel是旧版xls的格式前端下载下来虽然也能打开但有些浏览器会提示格式错误。文件名如果含有中文必须经过URLEncoder.encode处理否则前端拿到的文件名是乱码。注意URLEncoder编码后空格会变成而HTTP头里会被解析成空格所以要再replaceAll(\\, %20)替换一下。还有一个问题是ListUserExcelDTO一次性全查出来在数据量大时内存扛不住。这个问题下一节专门说。3.2 自定义样式处理让导出的报表更好用框架默认导出的Excel是没有样式的打开后所有单元格都是默认字体、默认对齐方式表头和数据行混在一起领导看了多半不满意。所以要手动设置样式。EasyExcel的样式配置是通过WriteCellStyle和WriteSheet来做的。下面是我在项目里经常用的一套配置表头加灰底背景、加粗字体数据行居中显示WriteCellStyle headWriteCellStyle new WriteCellStyle(); headWriteCellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headWriteCellStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND); headWriteCellStyle.setWrapped(true); WriteFont headWriteFont new WriteFont(); headWriteFont.setFontHeightInPoints((short) 11); headWriteFont.setBold(true); headWriteCellStyle.setWriteFont(headWriteFont); WriteCellStyle contentWriteCellStyle new WriteCellStyle(); contentWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER); contentWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); contentWriteCellStyle.setWrapped(true); HorizontalCellStyleStrategy horizontalCellStyleStrategy new HorizontalCellStyleStrategy(headWriteCellStyle, contentWriteCellStyle); EasyExcel.write(response.getOutputStream(), UserExcelDTO.class) .registerWriteHandler(horizontalCellStyleStrategy) .sheet(用户数据) .doWrite(list);有一个细节值得注意。大数据量导出时给每个单元格都设置样式会显著增加写入耗时和文件体积。实测10万行数据设置居中和自动换行后导出耗时从不到2秒增加到了8秒多文件体积也大了将近一倍。所以样式要适度能用行级样式解决的问题就不要跑到单元格级别。3.3 大数据量导出的流式处理与分页回到百万行导出这个话题。如果一次性把百万条数据查出来放进内存再传给EasyExcel那不管用什么框架都会挂。正确的思路是分页查询流式写入。EasyExcel的ExcelWriter支持多次write我们可以分批查询数据每查一批就写一批最终调用finish完成文件输出。ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), UserExcelDTO.class) .registerWriteHandler(horizontalCellStyleStrategy) .build(); WriteSheet writeSheet EasyExcel.writerSheet(用户数据).build(); int pageNum 1; int pageSize 5000; while (true) { ListUserExcelDTO pageData userService.queryPageForExport(keyword, pageNum, pageSize); if (pageData.isEmpty()) { break; } excelWriter.write(pageData, writeSheet); pageNum; } } finally { if (excelWriter ! null) { excelWriter.finish(); } }这段代码里有几个关键点。第一ExcelWriter用完必须在finally里调用finish()否则输出流不会被关闭用户下载的文件会不完整。第二pageSize不宜过大也不宜过小。5000是一个比较稳妥的经验值每次查询需要的内存大约在几MB级别整个导出过程的内存曲线是平稳的不会出现峰值。如果你一次查5万条内存还是有风险。第三分页查询本身也有讲究。用LIMIT offset, size做深分页数据量到后期会越来越慢因为数据库需要扫描前面的所有记录。更优的方案是用游标思想比如WHERE id ?lastId ORDER BY id ASC LIMIT 5000每批记录最后一条的id作为下一批的起始条件。这样在索引有效的情况下查询速度基本稳定。我说一下实测数据吧。本地环境2C4GMySQL里造了100万条用户数据用MyBatis-Plus分页查询配合EasyExcel流式写入导出到本地文件整个过程耗时约25秒JVM堆内存使用峰值不到400MB完全没有OOM风险。这个数据在线上的2C4G小服务器上同样成立。4. 导入功能的完整链路4.1 监听器模式边读边处理的导入实现导入比导出复杂因为要处理格式错误、类型转换、业务校验、数据库入库、失败回写等问题。EasyExcel读取时用ReadListener回调每读一行就调用一次invoke方法。默认每读100行会调用一次doAfterAllAnalysed表示这一批读完了。先看一个最基础的导入接口PostMapping(/import) public Result importExcel(MultipartFile file) throws IOException { EasyExcel.read(file.getInputStream(), UserExcelDTO.class, new UserImportListener(userService)) .sheet() .doRead(); return Result.success(); }关键是UserImportListener怎么写。我一般这么写public class UserImportListener implements ReadListenerUserExcelDTO { private final UserService userService; private final ListUserExcelDTO cache new ArrayList(); private static final int BATCH_COUNT 500; public UserImportListener(UserService userService) { this.userService userService; } Override public void invoke(UserExcelDTO data, AnalysisContext context) { cache.add(data); if (cache.size() BATCH_COUNT) { saveData(); cache.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { saveData(); } private void saveData() { if (!cache.isEmpty()) { userService.batchSave(cache); } } }这里我把每500条数据做一次批量入库。这样做有两个原因一是数据库批量插入的效率远高于单条插入500条一次的批量insert在MySQL上几乎是毫秒级二是如果一次性把所有数据都攒到List里导入10万条数据就会浪费不少内存违背了EasyExcel的初衷。4.2 导入数据的校验与错误回写让用户知道错在哪上面只是最简单的“把Excel里的数据存进数据库”。真实业务里导入的数据可能是脏的手机号格式不对、邮箱重复、必填字段为空……如果什么都不管直接入库后面运维查数据的时候一定会骂你。我的做法是分两步校验。第一步在invoke方法里做数据格式校验比如手机号正则、日期格式、字段长度。第二步在批量入库前做唯一性校验比如手机号不能和数据库里已有的记录重复。如果校验不通过要收集错误信息并且让前端能下载一个“导入错误说明”。这里有一个体验上的问题如果用户导入了一个1万行的Excel其中有500行数据有问题你总不能把500个错误都一长条抛给前端。我的做法是把错误信息写进一个临时文件入库完成后返回给前端一个错误文件下载地址。比如这样private int successCount 0; private final ListString errorRows new ArrayList(); Override public void invoke(UserExcelDTO data, AnalysisContext context) { String errorMsg validateData(data); if (errorMsg ! null) { errorRows.add(第 (context.readRowHolder().getRowIndex() 1) 行 errorMsg); return; } cache.add(data); if (cache.size() BATCH_COUNT) { saveData(); cache.clear(); } }最后在doAfterAllAnalysed里如果errorRows不为空就生成一个错误报告文件把错误行信息写进去。这种情况下接口的返回值就不能只是一个Result.success()了我一般会返回一个包含成功条数、失败条数、错误文件地址的Map对象。前端拿到这个对象之后如果发现失败条数大于0就弹窗提示用户“导入完成但有X条数据失败点击下载错误报告”。4.3 复杂表头导入的处理技巧热搜词里那个“easyexcel复杂的表头导入”其实是很多人面试或者实际开发中遇到过的场景。什么叫复杂表头最常见的是两级表头比如Excel第一行是“个人信息”和“联系方式”两个大类第二行才是具体的“姓名”“手机号”这些明细。EasyExcel对复杂表头的支持核心就在于ExcelProperty的value支持数组。数组的顺序和数量决定了表头的层级。public class EmployeeExcelDTO { ExcelProperty(value {基本信息, 姓名}, index 0) private String name; ExcelProperty(value {基本信息, 性别}, index 1) private String gender; ExcelProperty(value {基本信息, 年龄}, index 2) private Integer age; ExcelProperty(value {联系方式, 手机号}, index 3) private String phone; ExcelProperty(value {联系方式, 邮箱}, index 4) private String email; }这样EasyExcel在解析时会自动把第一行的“基本信息”“联系方式”作为一级表头把第二行的具体字段作为二级表头数据从第三行开始读取。如果你遇到的复杂表头是“第1行合并单元格、第2行也是合并单元格、第3行才是表头”那需要配置headRowNumber(3)来告诉EasyExcel从第几行开始读数据EasyExcel.read(inputStream, EmployeeExcelDTO.class, listener) .sheet() .headRowNumber(3) .doRead();读取时复杂表头里那些非数据行会被自动跳过不需要你手动处理。这块EasyExcel封装得比POI好用太多POI你得自己去遍历XSSFSheet的行列判断单元格是不是合并区域代码写起来又臭又长。5. 常见问题与排查技巧实录5.1 内存溢出导入大文件时JVM直接OOM这是我做Excel导入导出以来遇到次数最多的一类问题。现象很明显前端上传一个几十MB的Excel后台接口直接报java.lang.OutOfMemoryError: Java heap space。原因有几种用了POI的XSSFWorkbook去读取大文件整个Excel被完整加载到内存。自己写代码时一次性把Excel的所有行读成了List。ReadListener里的cache没有做批量清空导致数据越积越多。排查思路先看是导入还是导出时OOM再根据堆转储信息看是什么对象占用了内存。如果是XSSFWorkbook改成EasyExcel如果是List检查是否每批次清空了。我自己的习惯是导入接口入库前用ExcelReader一次性构建读取时通过ReadListener逐行处理每500条批量入库并clear()导出接口用ExcelWriter分页write。做到这两点百万行级别的文件在2G堆内存下基本没问题。5.2 日期格式错乱导出后Excel里全是数字Excel里日期本质是一个序列号比如2024-01-01在Excel内部存储为45292。如果你在DTO上没加DateTimeFormat导出的文件打开后日期列会显示成数字。解决办法字段上加DateTimeFormat(yyyy-MM-dd HH:mm:ss)如果只需要年月日就写yyyy-MM-dd。还有一种情况导入时明明Excel里显示的是“2024/1/1”但EasyExcel解析出来是2024-01-01 00:00:00这是因为Excel单元格格式本身带了时间。这种可以在DateTimeFormat里用yyyy-MM-dd多余的时分秒会被截掉。5.3 并发导出时临时文件堆积导致磁盘爆满EasyExcel在写入时如果数据量大会使用临时文件来存储中间状态。框架默认临时目录是java.io.tmpdir如果系统默认的临时目录空间有限而且多个用户同时触发大导出很有可能会把临时目录撑爆。一个比较稳妥的处理方式在应用启动时配置JVM参数把临时目录指向一个空间充足的磁盘分区-Djava.io.tmpdir/data/tmp/easyexcel定期清理这个目录下的easyexcel_*.tmp文件。如果应用部署在容器里注意这个目录对应的是容器内的路径要确认磁盘挂载方式。5.4 xls和xlsx的兼容性EasyExcel对xls格式的支持不太理想它主要是为xlsx设计的。如果业务上用户可能会上传老版的xls文件建议在项目里加一个转换逻辑。我一般是先用FileTypeUtil判断文件后缀如果是xls先用一份POI代码把xls转成xlsx的字节流再交给EasyExcel读取。转换的代码可以参考POI的WorkbookFactory读取完用XSSFWorkbook重新写一遍。但这里有个性能问题转换过程本身也会占用内存大文件不建议这么搞。更优雅的方案是要求前端统一上传xlsx格式毕竟xls格式微软自己都已经不推荐了。还有一个兼容性问题是Excel 2003的xls格式只支持65536行超过这个行数根本无法写入。如果你要导出10万行数据只能输出xlsx。5.5 表头有合并单元格时数据错位有些导出的Excel表头是合并单元格比如第一行“问卷调查结果”横跨了整个表格。EasyExcel读取时会跳过合并区域但如果你没正确配置headRowNumber第2行这种本应作为数据行的内容可能被当成表头。我的经验是导入前让用户下载标准模板模板里表头层级是统一的然后在读取时设置正确的headRowNumber。如果无法确定模板格式还有个笨办法就是读取前先预览前几行把表头行数动态算出来再设置。5.6 文件下载后打不开输出流提前关闭一个很隐蔽的问题EasyExcel.write如果传的是response.getOutputStream()而且代码里在finally中又调用了response.getOutputStream().close()会导致Excel写入不完整。因为EasyExcel内部管理着输出流你手动关闭了之后文件还没flush完。我的做法是导出接口里完全不要手动关闭response.getOutputStream()交给EasyExcel的write或finish方法去处理。如果是分页导出在finally里调用excelWriter.finish()这行代码本身就会完成所有写入并关闭IO资源。5.7 防疫一个排序问题导入顺序和Excel顺序不一致网上有很多人问为什么EasyExcel导入的数据顺序和Excel里的顺序不一致。这个跟数据库无关纯粹是List的插入顺序问题。EasyExcel默认按照index属性映射列如果你没写index它按字段声明顺序处理这通常是稳定的。但如果你用了HashMap去接收动态数据那顺序自然会乱。所以要么用带顺序的LinkedHashMap要么用ListString按索引访问。6. 从Excel到数据库的完整工程化落地最后再聊一个工程化的细节。导入导出单独写代码很简单难的是作为一个功能模块嵌入到完整项目里要和数据库事务、日志审计、权限控制、前端界面都配合好。我在项目里一般会分成四层Controller层接收请求、校验登录态、调用服务层。Service层处理业务逻辑比如导入数据的重复校验、导出数据的组装。Listener层EasyExcel读取时的数据处理与入库。DTO层定义Excel模型用注解映射表头。Service层里有一个点批量入库时要注意事务控制。如果500条数据中有一条主键冲突是整批回滚还是跳过这一条继续这个决策要提前做。我一般选择跳过冲突行并把冲突原因写进错误报告这样用户体验比“全部失败”好得多。还有一个细节是文件校验。上传Excel前先校验文件后缀、文件大小再读取文件头判断是不是真正的Excel文件。有些用户会改个后缀把CSV传上来直接按xlsx解析会报异常。这种情况下在接口入口先拦截返回一个清晰的提示比让用户看到一大段堆栈信息强。最后再分享一个小技巧。导出文件名里加上时间戳比如“用户数据_20250614_103502.xlsx”这样前端多次下载时不会产生同名文件互相覆盖的问题也方便对方归档。导入这边上传的文件最好也按日期分目录存储比如/upload/2025/06/14/xxxxx.xlsx以后出了问题还能追溯原始文件。这个题材其实还有不少扩展空间比如EasyExcel的模板填充功能用现有Excel做模板只填数据、动态表头的高阶玩法、异步导入的任务队列设计每一个拎出来都能单独写一篇。如果大家在实际项目中遇到了别的坑也欢迎随时交流。
返回列表