
上个月接手一个历史遗留的数据导入接口生产环境频繁报 OOM。翻代码一看读 40 万行 Excel 用的是 XSSFWorkbook一次性把所有 Sheet 和单元格都加载进内存不崩才怪。后来我把这块改成 Apache POI 的 SAX 事件模型解析内存从接近 2GB 掉到 150MB 左右。SAX 解析 Excel 的核心就是对 sheet XML 流的事件响应你会不停收到 startRow、cell、endRow 三个回调。如果不理解这三个回调的执行顺序解析结果很容易出现各种错位、丢数据。这篇内容适合被超大 Excel 搞崩过的后端开发、要写导入导出功能的人也适合那些用 EasyExcel 却好奇它为什么不吃内存的人。先给结论在 XSSFSheetXMLHandler 这套机制里回调顺序永远是 startRow 先触发然后是该行每个有数据的单元格依次触发 cell最后这一行结束触发 endRow。这个顺序是 XML 文档流的天然顺序不是线程异步也不是乱序的。下面我会从设计思路、执行细节、完整代码、问题排查四个层面把它讲透最后再聊聊什么时候该自己写 SAX、什么时候直接上 EasyExcel。1. 为什么要用 SAX 解析 Excel内存问题与事件模型1.1 UserModel 的痛点和 SAX 的设计思路很多同学第一次接触 POI 都是从 UserModel 开始的也就是我们最常用的 XSSFWorkbook、XSSFSheet、XSSFRow、XSSFCell 那一套。这套 API 用起来舒服但它有一个致命问题解析 xlsx 时会把整个工作簿读进内存构建成一个完整的对象树。xlsx 本身是一个 zip 包里面是一堆 XML 文件UserModel 相当于是把这些 XML 全部 DOM 解析了一遍每个单元格都会生成 XSSFCell 对象每个行会生成 XSSFRow 对象。我一个 40 万行、30 列的文件就是 1200 万个单元格对象再算上样式表、共享字符串表、列宽缓存这些零碎堆内存一下子就被打满了。SAX 的思路完全不同。它不是把 XML 一次性读进来而是像流水线一样在解析 XML 标签时“边读边触发”。读到row开始标签触发一次 startRow读到一个c单元格标签触发一次 cell读到/row结束标签触发一次 endRow。处理完这个元素内存里关于它的数据就可以丢掉了所以整个解析过程的内存峰值和文件行数不成正比。对于写导入接口这种场景我们往往只需要顺序扫描一遍数据根本不需要随机访问某个单元格SAX 天然就是最合适的选择。对比维度UserModelSAX 事件模型内存占用全量加载随行数线性暴涨流式解析峰值稳定访问方式可随机读取任意单元格只能顺序扫描开发难度API 直观上手快需要理解回调模型适用场景小文件、需频繁改单元格大文件导入、导出转换、数据清洗这里要补充一点SAX 不是 POI 自己发明的概念它本身是一种通用的 XML 解析方式。POI 在 SAX 之上封装了 XSSFSheetXMLHandler帮我们把 XML 的原始解析事件进一步翻译成 startRow、cell、endRow 这类“业务语言”让我们不用直接跟 XMLReader 和 Attributes 打交道。但既然是翻译我们就要搞清楚它翻译的规则否则看到结果时还是会一头雾水。1.2 Excel 的 XML 结构row 和 c 元素如何映射回调要理解执行顺序先要明白 Excel 在底层到底存了什么。一个 xlsx 文件解压后在 xl/worksheets/ 目录下会有若干个 sheet 的 XML 文件里面的核心结构大致长这样sheetData row r1 c rA1 tsv0/v/c c rB1v100/v/c /row /sheetData这段 XML 里row标签代表一行c标签代表一个单元格。XSSFSheetXMLHandler 在内部用 SAX 解析这段 XML 的时候遇到row的开始标签就会回调 SheetContentsHandler 的 startRow 方法遇到每个c标签就会回调 cell 方法遇到/row结束标签就会回调 endRow 方法。所以说不存在什么神秘逻辑你只要记住一个映射关系row开始 → startRowc→ cell/row结束 → endRow。可能有人会问XSSFSheetXMLHandler 是哪里来的它是 org.apache.poi.xssf.eventusermodel 包下的核心类专门负责“读 sheet 的 XML 并转成高层事件”。我们自己不需要关心 XMLReader 怎么处理命名空间、怎么跳过无关标签只需要实现一个 SheetContentsHandler把 startRow、cell、endRow 这几个方法写好。后面第 3 章的代码会演示完整的接入方式。2. startRow、cell、endRow 的执行顺序很多人第一步就搞反了2.1 三个回调到底按什么顺序触发先说参数这是第一个容易踩坑的地方。startRow 和 endRow 收到的参数是int rowNum注意这个行号是 0 开头的索引。也就是说Excel 里显示的第一行对应回调里的 rowNum 是 0Excel 里的第二行对应 rowNum 是 1。但 cell 回调里携带的cellReference却是真实引用比如C2它是 1 开头。这两个坐标系不一致非常容易让人写错逻辑比如你拿 rowNum 去数据库当主键结果发现所有行号都少了一行。然后是 cell 方法本身的三个参数String cellReference是单元格引用比如A2String formattedValue是格式化后的显示值XSSFComment comment是单元格批注不是 XML 注释也不是后面的行内注释这一点后面再说。以这一段 XML 为例row r2 c rA2 tsv0/v/c c rB2v1024/v/c /rowXSSFSheetXMLHandler 会触发的事件顺序就是startRow(1) cell(A2, 产品A, null) cell(B2, 1024, null) endRow(1)这里 startRow 的 rowNum 是 1是因为 Excel 行号 2 转成了 0 开头索引。cell 每秒里的表格内容可能是共享字符串查表后的真实文本也可能是数值格式化后的字符串这里的格式化结果由 DataFormatter 控制。最后 endRow 再告诉你这一行已经读完了现在是消费整行数据的最佳时机。2.2 空单元格不回调这是行数据错位的根源我在帮同事排查解析结果错位时发现十次有八次都是同一个原因他们直接在 cell 回调里用currentRow.add(formattedValue)往数组里塞数据。这个做法在“每一列都有值”的 Excel 里没问题但只要中间有一个空列后面所有的数据就整体左移了。原因在于 Excel 保存文件时不会为空白单元格生成c节点。比如你表格里 A、B、D 三列有值C 列是空的那 XML 里的c只有三个分别是 A、B、D。你在 cell 回调里 add 三次数组下标是 0、1、2但 D 列真实列索引是 3于是 D 列的数据被放到了 C 列的位置。行内数据一旦错位后面任何列映射都是错的而且这种错位很隐蔽不打印整行根本看不出来。正确的做法是以 cellReference 为准定位列号空出来的位置用 null 占位。比如解析到D2时通过列字母算出索引 3然后把值放进currentRow[3]中间 C 列的位置保持 null。完整代码在第 3 章会给出这里先记住一个原则永远不要用 add 顺序代替列位置。2.3 为什么要“收集-消费”而不是“边收边用”单个 cell 回调里的数据通常不具备业务意义比如你只拿到了一个单元格张三不知道它在哪一行第几列也没法和同行的其他单元格拼起来。所以常规模型是startRow 里清空当前行缓存cell 里填充缓存endRow 里把缓存中的整行数据交出去。这就是收集-消费模型。这套模型要求你在 endRow 里做真正的业务处理比如转换对象、写数据库、写文件。有个经验是不要在 startRow 或 cell 里做耗时操作。XMLReader 是同步阻塞解析的你在回调里睡 100 毫秒整个文件解析就慢 100 毫秒40 万行累积起来不是慢一点的问题是根本跑不完。正确姿势是攒够一批比如 1000 行在 endRow 里批量提交一次。另外还要注意一个现象endRow 触发的行号不一定连续。Excel 在删除行之后重新保存XML 里的行号可能跳号比如上一行解析完 rowNum10下一行直接是 rowNum15。这不是你的代码有问题而是文件本身保留了原来的行列结构。如果业务上需要连续的行号比如给订单排序建议使用自己的计数器不要依赖回调里的 rowNum。3. 实操手写一个基于 SAX 的 Excel 行级解析器3.1 环境准备与 Maven 依赖这里用 Apache POI 5.2.x 版本它在 Java 8 以上环境运行没有问题并且修复了不少 XML 安全漏洞。Maven 里加一个依赖就够了dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependencypoi-ooxml 会自动把 commons-compress、poi、log4j-api 等依赖带进来。如果你还要读批注、操作样式可能需要额外引入 something 吗其实不需要核心的 styles、sharedStrings 都包含在 poi-ooxml 里。实战中我建议顺手加一个 slf4j 的绑定方便打印日志但不加也能用 System.out 验证逻辑。3.2 核心代码SheetContentsHandler 实现与解析入口下面是一个可以直接跑的示例我尽量保持精简但又不省略关键细节。整体步骤是打开 OPCPackage → 用 XSSFReader 拿到样式表和共享字符串表 → 遍历所有 Sheet → 用 XMLReader 配合 XSSFSheetXMLHandler 逐行解析。import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.openxml4j.opc.PackageAccess; import org.apache.poi.util.XMLHelper; import org.apache.poi.xssf.eventusermodel.XSSFReader; import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler; import org.apache.poi.xssf.model.SharedStringsTable; import org.apache.poi.xssf.model.StylesTable; import org.apache.poi.xssf.usermodel.XSSFComment; import org.xml.sax.InputSource; import org.xml.sax.XMLReader; import java.io.InputStream; import java.util.ArrayList; import java.util.List; public class SAXSheetParser { public static void main(String[] args) throws Exception { String file /path/to/test.xlsx; try (OPCPackage pkg OPCPackage.open(file, PackageAccess.READ)) { XSSFReader reader new XSSFReader(pkg); SharedStringsTable sst reader.getSharedStringsTable(); StylesTable styles reader.getStylesTable(); XSSFReader.SheetIterator sheets (XSSFReader.SheetIterator) reader.getSheetsData(); while (sheets.hasNext()) { try (InputStream sheetStream sheets.next()) { String sheetName sheets.getSheetName(); System.out.println(Parsing sheet: sheetName); parseSheet(styles, sst, sheetStream); } } } } private static void parseSheet(StylesTable styles, SharedStringsTable sst, InputStream sheetStream) throws Exception { XMLReader xmlReader XMLHelper.newXMLReader(); XSSFSheetXMLHandler handler new XSSFSheetXMLHandler( styles, null, sst, new SimpleRowHandler(), new DataFormatter(), false ); xmlReader.setContentHandler(handler); xmlReader.parse(new InputSource(sheetStream)); } private static class SimpleRowHandler implements XSSFSheetXMLHandler.SheetContentsHandler { private final ListString currentRow new ArrayList(); private int currentRowIndex; Override public void startRow(int rowNum) { this.currentRowIndex rowNum; currentRow.clear(); } Override public void cell(String cellReference, String formattedValue, XSSFComment comment) { int col cellReference null ? currentRow.size() : columnIndex(cellReference); while (currentRow.size() col) { currentRow.add(null); } currentRow.set(col, formattedValue); } Override public void endRow(int rowNum) { if (currentRow.isEmpty()) { return; } // 这里把整行数据交出去实际项目中通常是转对象、写库、写CSV System.out.println(ROW[ currentRowIndex ] currentRow); } Override public void headerFooter(String text, boolean isHeader, String tagName) { // 页眉页脚一般不用处理 } private int columnIndex(String ref) { String letters ref.replaceAll(\\d, ); int index 0; for (char ch : letters.toCharArray()) { index index * 26 (ch - A 1); } return index - 1; } } }重点说明几处。第一XSSFSheetXMLHandler 构造函数的第六个参数是boolean formulasNotResults我传的是 false意思是希望拿到公式计算后的结果而不是公式文本。如果你的业务想要原始公式比如SUM(A1:A10)就改成 true。第二DataFormatter 负责把单元格的原始值转成显示字符串。默认情况下日期、百分比、科学计数法都会按照 Excel 样式格式化。如果你发现日期变成了45306这种序列号通常就是 DataFormatter 没有正确读取样式或者 locale 不对。可以显式传入new DataFormatter(Locale.CHINA)来规范输出。第三XSSFSheetXMLHandler 构造函数里的第二个参数传了 null那个位置的类型是 Comments表示不关心批注。如果你确实需要读取单元格批注需要额外构造 Comments 对象传入这里不过多展开。3.3 跑一下输出日志观察回调顺序拿一个简单测试文件跑上面的代码假设 Sheet1 有两行数据第一行是 A1产品ID、B1名称、C1数量、D1日期第二行是 A2A001、B2张三、C2 空、D22024-01-15。输出结果如下Parsing sheet: Sheet1 startRow(1) cell(A2, A001, null) cell(B2, 张三, null) cell(D2, 2024-01-15, null) endRow(1) ROW[1][A001, 张三, null, 2024-01-15]注意 cell 回调只有三次C2 没有触发因为文件里根本没有 C2 这个c节点。但由于我们在 cell 回调里根据D2计算了列索引 3所以currentRow里 C 列的位置依然是 null打印出来格式非常整齐。如果把 cell 回调改成currentRow.add这一行就会变成[A001, 张三, 2024-01-15]所有列都向左错一位后面处理就全乱了。再演示一下公式场景。假设 C3 是公式单元格公式是A3B3当formulasNotResultsfalse时cell 回调收到的 formattedValue 是计算结果比如300当formulasNotResultstrue时收到的就是A3B3。这两种需求在导入导出场景里都有根据业务选。4. 常见问题与排查技巧实录4.1 几个高频问题的速查表我在实际项目中整理过一张问题表基本覆盖了用 SAX 解析 Excel 时最容易遇到的坑。拿到结果不对先对照这张表定位。症状原因解决办法某一行解析出来全是空endRow 里数组为空该行没有任何c节点Excel 对完全空的行偶尔会保留空 rowendRow 里加判空逻辑直接跳过所有列整体左移一位直接用了 currentRow.add没有按 cellReference 定位列用函数把列字母转成索引空位用 null 占位日期变成一串数字比如 45306DataFormatter 没按日期样式格式化或 locale 不对显式传入 new DataFormatter(Locale.CHINA)拿到的是公式文本不是值formulasNotResults 参数传了 true改成 false解析到一半 OutOfMemory自己在回调里把行数据全攒在内存里或 sharedStrings 太大批量消费及时释放超大字符串表改用 EasyExcel 或拆分文件行号和 Excel 里显示的行号对不上rowNum 是 0 开头索引不是 Excel 的 1 开头行号需要真实行号时手动 1或用 cellReference 里的数字部分4.2 三个最容易踩的坑第一个坑是把 XSSFComment 当成普通注释。XSSFComment 在 XSSFSheetXMLHandler 里的作用是代表单元格批注也就是右键单元格里的“插入批注”功能。如果你在 cell 回调里看到 comment 参数想判断是否存在批注要通过comment ! null comment.getString() ! null来判断而不是直接打印 comment 对象。大部分解析场景根本不需要它忽略即可。第二个坑是在回调里做耗时 IO。很多人图省事直接在 endRow 里调数据库插入。前面说过回调是串行同步的每行都阻塞文件一大就完蛋。正确的做法是在内存里攒够一批达到阈值后批量提交。比如 List 满 1000 条就 flush 一次这样可以显著提升吞吐。实测同样 40 万行数据逐行 insert 和批量 insert 的耗时差距能到几十倍。第三个坑是忘记关闭资源。OPCPackage 打开了就要关InputStream 也要关。代码里用了 try-with-resources这是最基本的安全保障。如果出现“文件被占用”或者解析中途报错多半是资源没有正确释放。4.3 如何快速定位执行顺序问题如果你怀疑某个回调没触发、或者触发顺序不对最快的排查方式就是写一个最简单的 Handler把每个方法名和参数打出来然后找一个几行的小文件跑一遍public void startRow(int rowNum) { System.out.println(startRow: rowNum); } public void cell(String cellReference, String formattedValue, XSSFComment comment) { System.out.println(cell: cellReference formattedValue); } public void endRow(int rowNum) { System.out.println(endRow: rowNum); }跑完一眼就能看出 startRow → cell → endRow 的顺序以及每个单元格的引用和值。之前有个同学说“我的数据只有最后一列是错的”我让他这样打日志五分钟就定位到了是列定位逻辑里减 1 的问题。调试回调类代码最忌瞎猜把事件流打出来是最有效的手段。5. 什么时候自己写 SAX什么时候直接用 EasyExcel5.1 事件回调与 EasyExcel 的对应关系EasyExcel 是目前非常流行的 Excel 读写工具其实它底层就是基于 POI 的 SAX 模式做的二次封装。你在 EasyExcel 里写的 ReadListener核心的 invoke 方法其实就相当于我们在 endRow 之后收到了一行完整数据doAfterAllAnalysed 相当于所有 Sheet 解析完毕的收尾。EasyExcel 帮我们把空单元格占位、表头映射、类型转换这些脏活累活都处理掉了所以在大多数业务场景下直接用 EasyExcel 会省很多事。但这不代表理解底层没有意义。举个例子EasyExcel 在读复杂表头或者不规则布局时你需要理解它是按“整行”抛数据的不能在中途随意改变读取策略遇到解析异常时报错信息也会涉及底层回调链。我见过不少人用 EasyExcel 发现行数不对无从下手其实就是因为不知道底层的 SAX 回调顺序。知道 startRow/cell/endRow再去看 EasyExcel 的源码很多问题一眼就明白了。5.2 性能收益与选型建议我自己在项目里做过一次对照测试同样一个 40 万行、30 列的文件UserModel 方式的内存峰值接近 1.8GSAX 方式大约 120M 到 200M主要波动来自 sharedStrings 的大小。如果共享字符串表特别大比如几十万个长文本单元格SST 本身会占掉不少内存这时候即使 sheet 是流式读取内存也不会特别低。这种情况下可以考虑换 EasyExcel或者对 sharedStrings 做流式优化也可以考虑走 CSV 中间格式。选型上我的建议很简单小文件比如几千行UserModel 足够线上接口、海量导入、需要严格内存控制优先 EasyExcel如果不想引第三方依赖、需要操作样式、批注、公式原始值等底层细节那就自己写 SAX。自己写 SAX 的成本其实也不高核心就是今天讲的这三个回调把它理解透比盲目套用工具更可靠。最后再分享一个小技巧无论用哪种方式解析给每一行数据显式记录一个行号字段不要依赖 Excel 行号和 list 下标。我踩过最惨的一次坑是导出报表在中间系统转了一道行号全部错位排查两天才发现是列定位方式不对。从那以后我所有解析代码都坚持“cellReference 定位列 显式记录行号”后来切换到 EasyExcel 时这套思路也能平滑迁移。