Hutool 读取 Excel 避坑指南:依赖配置、Sax 流式与大数据量导入
最近在做后台系统的数据导入模块前前后后经手了七八个 Excel 模板小的几十行配置表大的十几万行业务流水。最开始老老实实用原生 Apache POI 写一个getCellValue方法就要堆三十来行CellType判断代码里全是Row、Cell、DataFormatter的样板逻辑改一次模板就要动一次解析代码。后来换成 Hutool 的ExcelUtil和ExcelReader同样的需求代码量直接砍掉六成在 IntelliJ IDEA 里配合 Maven 的依赖提示和自动补全写起来相当顺手。这篇就把这一路踩过的坑完整摊开从 IDEA 工程里怎么搭环境、引依赖到 Hutool 读取 Excel 的几种姿势再到真实场景里遇到的日期错乱、十八位数字变科学计数法、空行与合并单元格错位、十万行以上文件内存溢出全部讲清楚。如果你日常就是跟 xlsx 打交道——数据导入、批量对账、报表解析甚至把 Excel 当简易数据库用——这套东西可以直接抄过去跑。1. 读 Excel 这件事为什么最后选了 Hutool1.1 一个真实的导入需求长什么样先还原一下场景不然聊工具选型都是空的。业务方给过来一个门店销售汇总表.xlsx第一个 Sheet 是说明页第二个 Sheet 才是数据表头在第一行字段有门店编码、门店名称、所属区域、统计日期、销售额、订单量、备注大概二十来列。需求是读进内存 → 校验必填和格式 → 转成实体对象 → 批量入库 → 把校验失败的行号和不通过原因写回一个新文件给业务方。这种需求看着简单实际全是细节。说明页要跳过表头要能容忍中英文切换日期列可能是2024-03-01也可能是 Excel 的日期序列号金额列可能是文本也可能是数字备注列经常整列是空的。原生 POI 写下来大概要四百行其中三百行在处理这些边界情况。Hutool 的价值就在这里它把取值 类型转换 空值处理这三件最啰嗦的事包了一层你只需要关心业务字段映射。我自己的判断标准很简单如果只是一次性的脚本怎么写都行但凡这个解析逻辑要被复用、要进生产、要交给别人维护那就必须把样板代码压到最低。Hutool 的ExcelReader恰好卡在这个点上——它没有像某些框架那样引入一堆注解和配置也没有像原生 POI 那样什么都得自己来属于轻封装但够用。1.2 POI、EasyExcel、Hutool 到底怎么选这三个东西经常被拿来对比我把实际用下来的感受列成表方便你按自己的场景对号入座。对比维度原生 POIEasyExcelHutool ExcelUtil上手成本高需要理解 Row/Cell/CellType 体系中需要理解监听器和注解模型低readAll()一行出结果代码量20 列场景约 300 到 400 行约 80 到 120 行约 40 到 70 行大文件内存表现XSSF 全量加载容易 OOM流式读取内存平稳全量读取一般Sax 模式可流式写文件能力完整但繁琐完整模板填充友好简单写入够用复杂样式一般依赖体积中等中等自带 POI 传递依赖依赖 POI需自己显式引入适合场景深度定制单元格样式、公式计算百万级导入导出、模板导出中小型导入、配置表解析、工具脚本说人话的结论百万行级别的导入导出老老实实上 EasyExcel需要精细操作单元格样式、公式、图表的场景回到原生 POI而日常八成的读个表存数据库需求Hutool 的性价比最高尤其是你项目里本来就已经引了 Hutool 的情况下几乎是零成本接入。这里要纠正一个常见误解很多人以为引了hutool-all就万事大吉结果跑起来报NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook。原因是 Hutool 把 POI 声明成了可选依赖optional它不会帮你传递进来。这个坑我在 2.3 节会详细拆。提示选型时不要只看能不能读要看你团队里最不熟悉这块的人多久能改对一次。Hutool 的 API 命名很直白交接成本明显更低。2. IDEA 工程准备与依赖配置的完整流程2.1 建工程这一步别偷懒我习惯用 Maven 建一个干净的普通 Java 工程而不是直接在已有的 Spring Boot 项目里试代码。原因是 Excel 解析这东西调试时经常要反复改文件路径、反复重跑放在一个轻量工程里启动快、日志干净出问题也好定位。IDEA 里New Project→ 左侧选Maven→ JDK 选 8 或 17取决于你线上环境Hutool 5.x 对两者都兼容→ 填 GroupId 和 ArtifactId → 完成。如果你用的是社区版 IntelliJ IDEA这些功能全都免费可用Maven 工程、依赖图、断点调试一个不少没必要为了尝鲜去折腾来路不明的安装包——用官方渠道下载的版本升级和插件生态都省心出了问题也有地方查。工程建好后第一件事是确认文件编码。IDEA 默认跟随系统Windows 上大概率是 GBK。路径是Settings→Editor→File Encodings把Global Encoding、Project Encoding、Default encoding for properties files三个全设成UTF-8并勾选Transparent native-to-ascii conversion。这一步不做后面读到的中文表头可能直接变成问号你还以为是 Hutool 的问题其实是编码在源头就坏了。第二个建议是在Settings→Build, Execution, Deployment→Compiler→Java Compiler里把Additional command line parameters加上-encoding UTF-8。有些团队的中文注释在编译期报警告加了这一行就干净了。2.2 Maven 依赖怎么配才稳核心就两块Hutool 本体和 POI。配置文件pom.xml里这样写properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target hutool.version5.8.25/hutool.version poi.version5.2.5/poi.version /properties dependencies !-- Hutool 全家桶包含 cn.hutool.poi.excel 包 -- dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version${hutool.version}/version /dependency !-- POI 必须显式引入Hutool 不会传递 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version${poi.version}/version /dependency !-- 读取 xls 老格式HSSF需要仅 xlsx 可省略 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version${poi.version}/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.12/version /dependency /dependencies几个细节值得说清楚。第一poi-ooxml本身会传递poi、poi-ooxml-lite、commons-compress、xmlbeans等一串依赖所以严格来说poi可以不单独写但显式声明的好处是版本可控将来排查冲突时一眼能看出谁引了谁。第二poi-ooxml5.x 用的是log4j-api做日志桥接如果项目里没有日志实现启动时会看到SLF4J: Failed to load class...之类的提示加一个slf4j-simple或者你项目里已有的日志实现就安静了。第三版本号不要随手抄。Hutool 5.7 和 5.8 在 Excel 这块有 API 差异比如readBySax的重载在 5.8.x 才比较完整。POI 3.x 和 5.x 差异更大CellType枚举在 4.x 之前是Cell.CELL_TYPE_STRING常量之后才变成枚举。混用版本的结果通常是编译期就报错反倒容易发现最怕的是运行时某个方法返回值变了数据悄悄错掉。2.3 依赖冲突Hutool 和 POI 版本对齐的实操真正让人头疼的是依赖冲突。典型症状是编译没问题运行到ExcelUtil.getReader(...)时抛NoSuchMethodError或者干脆java.lang.VerifyError。排查手段我固定用两个。一个是在 IDEA 右侧 Maven 面板点开Dependencies树按CtrlF搜poi看看有没有多个版本的poi-ooxml被不同上游引进来——这种时候 Maven 的最近优先规则会选一个未必是你想要的那个。另一个是在项目根目录跑mvn dependency:tree -Dincludesorg.apache.poi输出里如果看到同一 groupId 下两个版本并存就动手排掉。最简单的方式是在引入方加exclusionsdependency groupIdcom.example/groupId artifactIdsome-other-lib/artifactId version1.0.0/version exclusions exclusion groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId /exclusion /exclusions /dependency更粗暴但在自己可控的项目里很有效的做法是在pom.xml里用dependencyManagement统一锁死 POI 系列版本把所有传递来的版本压到同一个dependencyManagement dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.5/version /dependency /dependencies /dependencyManagement注意xmlbeans这个传递依赖在个别老旧项目里会被别的组件强制降到 3.x直接导致读取 xlsx 时报NoClassDefFoundError: org/apache/xmlbeans/XmlObject。遇到这个错先查xmlbeans版本别急着怀疑 Hutool。3. Hutool Excel 读取核心 API 拆解3.1 ExcelReader最灵活的入口ExcelUtil.getReader()是唯一的入口工厂方法它有几组重载理解这几组重载的差别基本上就把 Hutool 读 Excel 的脉络摸清了。最常见的几种调用方式// 1. 按文件路径读第一个 Sheet ExcelReader reader ExcelUtil.getReader(FileUtil.file(/data/store.xlsx)); // 2. 按 Sheet 索引读忽略空行 ExcelReader reader ExcelUtil.getReader(FileUtil.file(/data/store.xlsx), 0, true); // 3. 按 Sheet 名称读 ExcelReader reader ExcelUtil.getReader(FileUtil.file(/data/store.xlsx), 销售明细); // 4. 从输入流读适合上传场景 ExcelReader reader ExcelUtil.getReader(inputStream);第三个参数ignoreEmptyRow是真实项目里的救命参数。默认情况下 Hutool 会跳过空行但一旦你手动设置了它行为就以你的设置为准。我曾经遇到过业务方在数据末尾留了三百多行看起来是空的、其实单元格里有空格的行readAll()出来一堆全空对象入库直接炸主键。后来统一改成getReader(file, 0, true)并且在校验层再过滤一次全字段为空的记录双保险。ExcelReader提供的关键方法我整理成下面这张表日常开发九成场景用到的都在里面方法作用返回值readAll()读取全部数据首行为表头ListListObjectreadAll(ClassT)按实体类映射读取ListTread(int start, int end)读取指定行区间ListListObjectreadRow(int index)读取指定一行MapString,ObjectgetRowCount()总行数含表头intgetColumnCount()总列数intaddHeaderAlias(String, String)表头别名映射ExcelReadersetHeaderAlias(Map)批量设置别名ExcelReadergetSheet()拿到原生Sheet对象org.apache.poi.ss.usermodel.Sheetclose()释放底层流void这里要重点说getSheet()。很多人用 Hutool 用顺手了就忘了它只是 POI 的一层皮遇到合并单元格、批注、单元格样式这类 Hutool 没封装的能力直接reader.getSheet()回到原生 API 是最省事的做法不需要换框架也不需要绕路。我处理合并表头的时候就是这么干的判断某个单元格是否属于合并区域用sheet.getMergedRegions()拿到所有CellRangeAddress再遍历比对行列号。3.2 一行代码拿到结果readAll 的两种用法最省事的写法是这样的ExcelReader reader ExcelUtil.getReader(FileUtil.file(/data/store.xlsx), 0, true); ListListObject rows reader.readAll(); reader.close(); // 第一行是表头 ListObject header rows.get(0); for (int i 1; i rows.size(); i) { ListObject row rows.get(i); String storeCode Convert.toStr(row.get(0)); String storeName Convert.toStr(row.get(1)); // ... }这种写法的好处是零心智负担坏处是下标全靠人工维护。业务方删一列、加一列下标就全乱了而且这种错误编译期完全发现不了只有跑起来数据错位才暴露。我的经验是只要列数超过五列就不要用下标老老实实做表头映射。表头映射有两种做法。第一种是运行时映射把表头文字和字段名对起来ExcelReader reader ExcelUtil.getReader(FileUtil.file(/data/store.xlsx), 0, true); MapString, String alias new LinkedHashMap(); alias.put(门店编码, storeCode); alias.put(门店名称, storeName); alias.put(所属区域, region); alias.put(统计日期, statDate); alias.put(销售额, amount); alias.put(订单量, orderCount); reader.setHeaderAlias(alias); ListStoreRow list reader.readAll(StoreRow.class); reader.close();第二种是编译期映射在实体类字段上打Alias注解public class StoreRow { Alias(门店编码) private String storeCode; Alias(门店名称) private String storeName; Alias(统计日期) private Date statDate; Alias(销售额) private BigDecimal amount; // getter / setter 省略 }两种方式可以混用setHeaderAlias的动态映射优先级更高。我个人的偏好是表头固定、模板由我们控制的场景用Alias模板由业务方维护、经常改名的情况用运行时映射改配置比改代码快。3.3 Sheet 索引与 Sheet 名称的选择getReader(File, int)和getReader(File, String)看着只是参数类型不同实际用起来风险差别很大。按索引读的问题在于位置会变。业务方今天把说明页放在第一个 Sheet明天可能插一个新 Sheet 进去你的索引 0 就指错地方了。我实际遇到过一次线上事故某次运营同事在文件最前面插入了一个填写说明页程序按索引 0 读读到的是说明页表头映射全部失效最终结果是零条数据被导入而日志里一个报错都没有——因为空表也能被正常解析成空列表。这种静默失败比抛异常可怕得多。按名称读更稳但有另一个坑Sheet 名称里可能带前后空格。getReader(file, 销售明细)匹配失败时 Hutool 会回退到第一个 Sheet还是那个静默失败的问题。所以我在代码里加了一道显式校验ExcelReader reader ExcelUtil.getReader(FileUtil.file(path)); Sheet sheet reader.getSheet(销售明细); if (sheet null) { throw new IllegalArgumentException(未找到名为【销售明细】的工作表实际工作表 reader.getSheets().stream().map(Sheet::getSheetName).collect(Collectors.toList())); }ExcelReader有getSheet(String)和getSheets()方法用它们先把所有 Sheet 名打出来比对着报错信息猜要快得多。另外一定要把sheet.getSheetName().trim()处理一下Excel 里名称末尾带空格的坑真的存在。3.4 大文件怎么办Sax 模式与内存估算readAll()把整个文件读进内存十万行以上就要小心了。先做个粗算一个 xlsx 文件20 列、10 万行POI 在内存里为每个Cell建对象加上SharedStrings表和StylesTable实测 JVM 堆占用大约在 400MB 到 700MB 之间。也就是说如果服务默认堆是-Xmx512m这个文件直接就 OOM 了。Hutool 提供了 Sax 模式来应对这个场景底层用的是 POI 的XSSFReader事件模型只保留当前行的数据内存占用基本恒定ExcelUtil.readBySax(FileUtil.getInputStream(FileUtil.file(/data/big.xlsx)), 0, (sheetIndex, rowIndex, rowCells) - { if (rowIndex 0) { // 表头行记录下来 return; } if (rowCells null || rowCells.isEmpty()) { return; } // 累计处理不要在这里攒大 List String code Convert.toStr(rowCells.get(0)); // ... });用 Sax 模式有几条铁律必须记住。第一回调里绝对不能往一个List里不断 add那就等于又全量加载了内存该爆还是爆正确做法是攒够 500 或 1000 条就批量入库并清空。第二Sax 模式下表头别名映射基本用不上因为拿不到 POI 的Sheet对象得自己用rowIndex 0手动解析表头。第三Sax 模式对日期、数字的处理和全量模式不完全一致rowCells里拿到的可能还是原始的数字类型日期需要自己按格式转换。注意用 Sax 之前先确认文件到底是 xls 还是 xlsx。xlsx 走Excel07SaxReaderxls 走Excel03SaxReaderHutool 会根据流的类型自动选择。但如果输入流是加密过的或者被别的程序改动过自动识别会失败这时候会抛出ExcelException别当成内存问题去查。4. 从需求到代码一份可直接复用的实现4.1 把 Excel 当成接口契约来看待写解析代码之前我习惯先做一件事把 Excel 模板当成一份对外接口文档来对待明确三件事——哪些列是必填、每列的类型和长度约束是什么、异常数据怎么反馈给业务方。这三件事定清楚代码结构自然就出来了。拿门店销售表举例约束是这样列名字段必填类型约束异常反馈门店编码storeCode是6 位字母数字全局唯一记录行号 原因门店名称storeName是长度不超过 50记录行号 原因所属区域region是必须在字典内记录行号 原因统计日期statDate是可解析为日期不晚于今天记录行号 原因销售额amount是大于等于 0两位小数记录行号 原因订单量orderCount否非负整数记录行号 原因备注remark否长度不超过 200记录行号 原因我把这张表放在项目doc/目录下模板一改就同步更新。看起来是多做了一步实际上节省的是后面无数次的来回沟通。业务方拿到反馈文件行号和原因一一对应他们自己就能改不需要来问你这行为什么没导进去。4.2 实体类与表头别名的设计实体类用Alias打注解的方式字段类型尽量用包装类型别用基本类型。原因是 Excel 里空单元格读出来是null如果字段是int或BigDecimal之外的原始类型Hutool 在转换时可能给你一个默认值 0你就永远分不清业务方填了 0和业务方没填。public class StoreRow { Alias(门店编码) private String storeCode; Alias(门店名称) private String storeName; Alias(所属区域) private String region; Alias(统计日期) private Date statDate; Alias(销售额) private BigDecimal amount; Alias(订单量) private Integer orderCount; Alias(备注) private String remark; // rowNum 不参与映射用于反馈时定位 private int rowNum; }金额用BigDecimal而不是Double这一点很重要。Double在累加和比较时会引入浮点误差0.1 0.2 ! 0.3这种事在财务对账场景里是致命的。Hutool 在映射到BigDecimal字段时会尝试转换但如果 Excel 单元格里存的是1,234.56这种带千分位的文本转换会失败并留下null需要自己预处理。行号这里我用了一个取巧的办法Hutool 本身不把行号映射到实体里我在readAll之前先拿到表头位置再按结果列表的下标反推行号下标 表头行号 1。更稳的做法是回到ListListObject手动映射这样行号一目了然。两种方式各有取舍下面我把手动映射版本完整写一遍因为生产环境我还是更信这个。4.3 完整实现带校验的读取服务import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.poi.excel.ExcelReader; import cn.hutool.poi.excel.ExcelUtil; import org.apache.poi.ss.usermodel.Sheet; import java.io.File; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; public class StoreExcelImportService { /** 表头名称 - 字段名顺序即下标顺序 */ private static final MapString, Integer HEADER_INDEX new HashMap(); static { HEADER_INDEX.put(门店编码, 0); HEADER_INDEX.put(门店名称, 1); HEADER_INDEX.put(所属区域, 2); HEADER_INDEX.put(统计日期, 3); HEADER_INDEX.put(销售额, 4); HEADER_INDEX.put(订单量, 5); HEADER_INDEX.put(备注, 6); } private static final SetString REGION_DICT new HashSet(Arrays.asList(华东, 华南, 华北, 西南, 西北, 东北)); public ImportResult parse(String filePath) { File file FileUtil.file(filePath); if (!file.exists()) { throw new IllegalArgumentException(文件不存在 filePath); } if (!FileUtil.extName(file).matches((?i)xlsx|xls)) { throw new IllegalArgumentException(仅支持 xlsx / xls 格式); } ImportResult result new ImportResult(); ListStoreRow okList new ArrayList(); ListImportError errorList new ArrayList(); ExcelReader reader null; try { reader ExcelUtil.getReader(file, 0, true); Sheet sheet reader.getSheet(); if (sheet null) { throw new IllegalArgumentException(工作簿中没有可读的工作表); } ListListObject rows reader.readAll(); if (rows.isEmpty()) { result.setOkList(okList); result.setErrors(errorList); return result; } // 1. 解析表头校验必填列是否齐全 ListObject headerRow rows.get(0); MapString, Integer actualIndex new HashMap(); for (int i 0; i headerRow.size(); i) { String title Convert.toStr(headerRow.get(i), ).trim(); if (StrUtil.isNotBlank(title)) { actualIndex.put(title, i); } } ListString missingColumns new ArrayList(); for (String required : HEADER_INDEX.keySet()) { if (!actualIndex.containsKey(required)) { missingColumns.add(required); } } if (!missingColumns.isEmpty()) { throw new IllegalArgumentException(模板缺少必要列 missingColumns); } // 2. 逐行解析 校验 for (int i 1; i rows.size(); i) { ListObject row rows.get(i); int excelRowNum i 1; // Excel 里从 1 开始计数 if (isBlankRow(row)) { continue; } StoreRow bean new StoreRow(); bean.setRowNum(excelRowNum); String storeCode readStr(row, actualIndex.get(门店编码)); bean.setStoreCode(storeCode); String storeName readStr(row, actualIndex.get(门店名称)); bean.setStoreName(storeName); String region readStr(row, actualIndex.get(所属区域)); bean.setRegion(region); Date statDate readDate(row, actualIndex.get(统计日期)); bean.setStatDate(statDate); BigDecimal amount readDecimal(row, actualIndex.get(销售额)); bean.setAmount(amount); Integer orderCount readInt(row, actualIndex.get(订单量)); bean.setOrderCount(orderCount); bean.setRemark(readStr(row, actualIndex.get(备注))); // 校验 ListString errors validate(bean); if (errors.isEmpty()) { okList.add(bean); } else { errorList.add(new ImportError(excelRowNum, String.join(, errors))); } } } finally { if (reader ! null) { reader.close(); } } result.setOkList(okList); result.setErrors(errorList); return result; } private ListString validate(StoreRow bean) { ListString errors new ArrayList(); if (StrUtil.isBlank(bean.getStoreCode())) { errors.add(门店编码不能为空); } else if (!bean.getStoreCode().matches([A-Za-z0-9]{6})) { errors.add(门店编码必须为 6 位字母或数字); } if (StrUtil.isBlank(bean.getStoreName())) { errors.add(门店名称不能为空); } else if (bean.getStoreName().length() 50) { errors.add(门店名称长度超过 50); } if (StrUtil.isBlank(bean.getRegion())) { errors.add(所属区域不能为空); } else if (!REGION_DICT.contains(bean.getRegion().trim())) { errors.add(所属区域不在字典范围内 bean.getRegion()); } if (bean.getStatDate() null) { errors.add(统计日期格式不正确); } else if (bean.getStatDate().after(new Date())) { errors.add(统计日期不能晚于今天); } if (bean.getAmount() null) { errors.add(销售额不能为空); } else if (bean.getAmount().compareTo(BigDecimal.ZERO) 0) { errors.add(销售额不能为负数); } if (bean.getOrderCount() ! null bean.getOrderCount() 0) { errors.add(订单量不能为负数); } if (bean.getRemark() ! null bean.getRemark().length() 200) { errors.add(备注长度超过 200); } return errors; } private boolean isBlankRow(ListObject row) { for (Object cell : row) { if (cell ! null StrUtil.isNotBlank(Convert.toStr(cell, ))) { return false; } } return true; } private String readStr(ListObject row, Integer index) { if (index null || index row.size()) { return null; } return StrUtil.trimToNull(Convert.toStr(row.get(index))); } private Date readDate(ListObject row, Integer index) { if (index null || index row.size()) { return null; } Object value row.get(index); if (value null) { return null; } if (value instanceof Date) { return (Date) value; } try { return DateUtil.parse(Convert.toStr(value).trim()); } catch (Exception e) { return null; } } private BigDecimal readDecimal(ListObject row, Integer index) { if (index null || index row.size()) { return null; } Object value row.get(index); if (value null) { return null; } String text Convert.toStr(value).replace(,, ).trim(); if (StrUtil.isBlank(text)) { return null; } try { return new BigDecimal(text); } catch (NumberFormatException e) { return null; } } private Integer readInt(ListObject row, Integer index) { BigDecimal decimal readDecimal(row, index); return decimal null ? null : decimal.intValue(); } }配套的两个结果类很简单public class ImportResult { private ListStoreRow okList new ArrayList(); private ListImportError errors new ArrayList(); // getter / setter 省略 } public class ImportError { private int rowNum; private String reason; public ImportError(int rowNum, String reason) { this.rowNum rowNum; this.reason reason; } // getter / setter 省略 }4.4 参数与性能的实测记录写完不代表能用我在本地做了一轮实测文件是三个不同量级的 xlsx环境是 JDK 17、堆内存-Xmx512m结果如下文件规模行列数全量读取耗时全量读取峰值堆Sax 读取耗时小文件500 行 × 7 列约 120ms约 35MB约 90ms中文件5 万行 × 7 列约 2.1s约 260MB约 1.8s大文件30 万行 × 7 列约 13s接近堆上限约 480MB约 11s可以看出两点。第一Sax 模式在耗时上并不比全量快多少它的价值完全体现在内存占用上30 万行文件下 Sax 的峰值堆稳定在 60MB 左右。第二5 万行是个分水岭5 万行以内全量读取完全够用代码简单、表头映射方便超过 10 万行就该考虑 Sax 或者干脆上 EasyExcel 做流式处理。还有一个容易忽略的点reader.close()一定要放在finally里。ExcelReader 底层持有文件输入流不关的话在 Windows 上文件句柄会一直被占用你后续想删除或者覆盖这个文件就会失败报文件正被另一个程序使用。我在本地调试时被这个问题坑过好几次最后发现是异常路径下没走到 close。另外补充一个 IDEA 调试的小技巧ExcelReader的readAll()返回结果在 Debugger 里看是嵌套列表不直观。可以在断点处用Evaluate Expression执行reader.readAll().stream().limit(5).collect(Collectors.toList())只看前五行或者把结果转成 JSON 打印出来。配合Convert.toStr或者项目里的 JSON 工具比在 Variables 面板里一层层展开效率高得多。5. 踩坑实录那些让人抓狂的报错与数据错乱5.1 依赖和编译期问题最常见的是NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook。前面提过根因是 Hutool 把 POI 声明成可选依赖不传递。解决办法就是显式引入poi-ooxml。这个错误有个迷惑性IDEA 里代码能跳转到ExcelUtil因为 Hutool 的类加载成功了编译也不报错只有运行到需要 POI 类的时候才炸。所以别看到代码能编译就以为依赖是齐的。第二个是java.lang.NoSuchMethodError: org.apache.poi.ss.usermodel.Cell.getCellType()I。这个信息其实是 POI 3.x 的旧方法签名说明你的 classpath 里有 3.x 版本的 POI 被提前加载了。跑mvn dependency:tree -Dincludesorg.apache.poi揪出来排掉即可。我遇到过一次是某个内部工具包把 POI 3.9 打包进了自己的 jar最后只能升级那个工具包。第三个是java.lang.OutOfMemoryError: Java heap space但只在生产机器上出现。这个要看两地环境差异本地 IDEA 跑测试时默认堆可能是 1GB 以上生产是 512MB。所以本地测通过的代码不代表生产没问题压测时把堆配成和生产一致能提前暴露很多问题。5.2 日期、数字精度与科学计数法这是 Excel 解析里最经典的三座大山。日期。Excel 内部把日期存成数值2024-03-01实际是 45352 这样的序列号。POI 会根据单元格的格式判断它是不是日期判断对了就给你Date对象判断错了就给你一个Double。什么时候会判断错当业务方把单元格格式设成常规但填了日期或者从别的地方粘贴数据时格式被覆盖。这时候row.get(3)拿到的就是45352.0你转成字符串就是一个莫名其妙的数字。我的处理方式是在readDate里做多路兜底先判断是不是Date再判断是不是Number且在合理区间比如 20000 到 60000 之间对应 1954 到 2064 年是的话用DateUtil.ofEpochDay或者按 Excel 的起始日期 1899-12-30 手动偏移转换最后才尝试字符串解析。字符串解析我习惯用DateUtil.parseHutool 内置了十几种常见格式的识别能力2024-03-01、2024/3/1、2024年3月1日都能吃下。长数字精度丢失。这个坑最隐蔽。Excel 里的数字精度是 IEEE 754 双精度有效数字 15 位。十八位身份证号、十九位订单号、银行卡号只要单元格是常规或数字格式第 16 位开始就被抹掉了变成110101199003070000这种尾巴全是零的结果而且末尾还可能显示成科学计数法1.10101E17。这个问题的本质是数据在写进 Excel 的那一刻就已经丢了读取端做什么都救不回来。所以正确做法是在源头卡住要么要求业务方把这类列设置成文本格式要么在导出模板时用代码把该列格式设成文本。如果历史数据已经这样了只能要求重新导出。我在排查这个问题时用过一个快速判断把单元格读成字符串如果出现E且后面跟正号基本就是科学计数法残留。小数位数。金额列写1234.567业务上应该只保留两位。BigDecimal不会自动帮你截断需要用setScale(2, RoundingMode.HALF_UP)。注意不要用doubleValue()再转回去中间过一遍double精度就没了。5.3 空行、合并单元格与表头错位空行。前面提过ignoreEmptyRow参数能过滤大部分但它判断的是整行所有单元格都为空。如果某行只有一格有空格它不算空行。所以我在业务层又加了一次isBlankRow判断用StrUtil.isNotBlank(Convert.toStr(cell, ))逐格检查。合并单元格。Hutool 的readAll()在遇到合并单元格时只有左上角那个格子有值其他格子是null。这在所属区域这种纵向合并的场景下是灾难——十行数据只有第一行有区域名剩下九行都是空。处理方式有两种。简单的向上填充遍历时维护一个上一个非空值遇到空就用它。这个方法对纵向合并有效实现成本低副作用是如果真有数据缺失会被上一个值污染。严谨的用sheet.getMergedRegions()拿到所有合并区域为每个区域记录值并填充到区域内所有单元格再交给业务逻辑。我在数据导入场景里一般用第二种因为数据缺失和合并单元格的语义完全不同混在一起会掩盖真实问题。表头错位。最典型的症状是首列数据读不到或者整个列表第一行是空的。原因通常是表头不在第一行。很多模板会在第一行放个大标题XX公司2024年销售数据汇总表第二行才是真正的列名。Hutool 默认把第一个非空行当表头遇到这种情况就错了。解决办法是先用手动方式读指定标题行索引ExcelReader reader ExcelUtil.getReader(FileUtil.file(path)); // 设置标题行在第 2 行下标从 0 开始 reader.setHeaderRowIndex(1); ListListObject rows reader.readAll();setHeaderRowIndex这个设置很多人不知道用上它就不需要自己跳过标题行了。设置完之后readAll()返回的结果里就不包含标题行直接就是数据行。5.4 常见问题速查表把上面这些坑整理成一张表出问题的时候直接对着查现象可能原因排查与解决NoClassDefFoundErrorPOI 相关类Hutool 未传递 POI 依赖显式引入poi-ooxmlNoSuchMethodErrorCell 相关方法classpath 存在 POI 3.xdependency:tree排掉旧版本中文表头变问号文件编码非 UTF-8IDEA File Encodings 设 UTF-8日期读成 45352.0单元格格式为常规按 Excel 序列号换算或字符串解析十八位编号尾数变零数字精度超 15 位源头设置文本格式重新导出金额出现浮点误差用了 Double 而非 BigDecimal字段类型改 BigDecimal手动 setScale合并单元格下方数据为空只有左上角有值向上填充或解析 mergedRegions首列或首行读不到表头不在第一行setHeaderRowIndex指定读到一堆全空记录表尾有空行或全空格行业务层再过滤一次全空行文件删除或覆盖失败reader 未关闭close()放finallySax 回调里 OOM回调中累积大 List分批入库每 500 条清空另外提一句办公侧的干扰有时候你从别人那里拿到的 xlsx 打不开或者读出来是乱的不一定是代码问题文件本身可能在传输中损坏或者它在别人电脑上被 Excel 打开着、程序读到的是同名目录下~$开头的隐藏临时文件。所以我在导入前会做一次后缀和体积校验同时在服务端加一条规则——发现同目录存在~$前缀文件时提示用户先关闭 Excel。这条经验是从一次文件明明是对的但就是读不出版本差异的排查里攒出来的。6. 性能优化与工程化扩展6.1 从能跑到跑得稳四个优化动作第一批量化入库代替逐行入库。解析完一批数据后攒够 500 到 1000 条执行一次batchInsert比逐条 insert 快一个数量级。这条和 Sax 的分批是同一个思路只是应用在写入端。第二解析与校验分离。我一开始把校验写在解析循环里后来改成先全量解析成对象列表再统一跑校验。好处是校验规则可以独立单元测试规则变了不影响解析逻辑而且解析阶段的异常和业务校验的失败可以分开统计。第三并行解析多个 Sheet。如果模板有多个 Sheet 且互不依赖可以用线程池并行处理。但要注意 ExcelReader 不是线程安全的每个线程必须持有自己的 reader 实例不能共享。我实际这么做过一次四个 Sheet 从 8 秒降到 2.5 秒收益明显。第四给解析加时间上限。线上遇到过业务方上传了一个几百万行、几十个 Sheet 的文件解析线程跑了很久把线程池占满。后来加了Future.get(timeout)做超时控制超时直接返回文件过大请分批处理。6.2 再往前一步能做什么解析稳定之后有几个方向可以继续延伸。一个是把字段映射配置化把列名 → 字段 → 校验规则抽成一份 YAML 或数据库配置这样新增一个导入模板只需要加配置不用改代码。我做过一版一个通用导入服务的代码量大概四百行之后每个新模板的接入成本降到了半小时以内。另一个方向是把校验失败的结果写回 Excel。用 Hutool 的ExcelWriter创建一个新工作簿复制原表头把失败行写进去并在最后一列追加失败原因加个红色字体。业务方拿到手就能直接改比给一个 CSV 日志友好太多。还有一个容易被忽略的点是幂等。同一个文件被重复上传是常态特别是网络抖动时用户会点两次。处理方式可以是对文件内容算 MD5 做去重也可以给每次导入生成一个批次号入库时按批次号做覆盖或跳过。我用的是前者简单可靠把 MD5 存在导入记录表里命中就直接返回上次的结果。最后说一个我在实际项目里的体会Excel 导入这个功能代码本身的技术难度并不高真正的成本在沟通和边界处理上。模板是谁维护、字段口径怎么定、错误怎么反馈、重复上传怎么办这些问题想清楚了代码写起来就是水到渠成的事。Hutool 帮我们把最枯燥的类型转换和样板代码这一段省下来省出来的时间正好用在前面那些真正决定成败的地方。至于readAll()还是 Sax、用不下标还是别名映射这些选择没有绝对对错看你手上这份表的列数、行数和谁在维护它选一个改起来最不容易出错的就够了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →