POI SAX解析Excel:startRow、cell、endRow回调顺序详解
2026/9/14 10:41:25 网站建设 项目流程

上个月接手一个历史遗留的数据导入接口,生产环境频繁报 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 r="1"> <c r="A1" t="s"><v>0</v></c> <c r="B1"><v>100</v></c> </row> </sheetData>

这段 XML 里,<row>标签代表一行,<c>标签代表一个单元格。XSSFSheetXMLHandler 在内部用 SAX 解析这段 XML 的时候,遇到<row>的开始标签,就会回调 SheetContentsHandler 的 startRow 方法;遇到每个<c>标签,就会回调 cell 方法;遇到</row>结束标签,就会回调 endRow 方法。所以说不存在什么神秘逻辑,你只要记住一个映射关系:<row>开始 → startRow,<c>→ 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 是 0;Excel 里的第二行,对应 rowNum 是 1。但 cell 回调里携带的cellReference却是真实引用,比如"C2",它是 1 开头。这两个坐标系不一致,非常容易让人写错逻辑,比如你拿 rowNum 去数据库当主键,结果发现所有行号都少了一行。

然后是 cell 方法本身的三个参数:String cellReference是单元格引用,比如"A2"String formattedValue是格式化后的显示值;XSSFComment comment是单元格批注,不是 XML 注释,也不是后面的行内注释,这一点后面再说。

以这一段 XML 为例:

<row r="2"> <c r="A2" t="s"><v>0</v></c> <c r="B2"><v>1024</v></c> </row>

XSSFSheetXMLHandler 会触发的事件顺序就是:

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 里的行号可能跳号,比如上一行解析完 rowNum=10,下一行直接是 rowNum=15。这不是你的代码有问题,而是文件本身保留了原来的行列结构。如果业务上需要连续的行号,比如给订单排序,建议使用自己的计数器,不要依赖回调里的 rowNum。

3. 实操:手写一个基于 SAX 的 Excel 行级解析器

3.1 环境准备与 Maven 依赖

这里用 Apache POI 5.2.x 版本,它在 Java 8 以上环境运行没有问题,并且修复了不少 XML 安全漏洞。Maven 里加一个依赖就够了:

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>

poi-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 List<String> 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=日期;第二行是 A2=A001、B2=张三、C2 空、D2=2024-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 是公式单元格,公式是=A3+B3,当formulasNotResults=false时,cell 回调收到的 formattedValue 是计算结果,比如300;当formulasNotResults=true时,收到的就是=A3+B3。这两种需求在导入导出场景里都有,根据业务选。

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,什么时候直接用 EasyExcel

5.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.8G,SAX 方式大约 120M 到 200M,主要波动来自 sharedStrings 的大小。如果共享字符串表特别大,比如几十万个长文本单元格,SST 本身会占掉不少内存,这时候即使 sheet 是流式读取,内存也不会特别低。这种情况下,可以考虑换 EasyExcel,或者对 sharedStrings 做流式优化,也可以考虑走 CSV 中间格式。

选型上我的建议很简单:小文件,比如几千行,UserModel 足够;线上接口、海量导入、需要严格内存控制,优先 EasyExcel;如果不想引第三方依赖、需要操作样式、批注、公式原始值等底层细节,那就自己写 SAX。自己写 SAX 的成本其实也不高,核心就是今天讲的这三个回调,把它理解透,比盲目套用工具更可靠。

最后再分享一个小技巧:无论用哪种方式解析,给每一行数据显式记录一个行号字段,不要依赖 Excel 行号和 list 下标。我踩过最惨的一次坑是导出报表在中间系统转了一道,行号全部错位,排查两天才发现是列定位方式不对。从那以后,我所有解析代码都坚持“cellReference 定位列 + 显式记录行号”,后来切换到 EasyExcel 时,这套思路也能平滑迁移。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询