ARTICLE DETAIL

资讯详情

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

SAX解析Excel:startRow、cell、endRow回调机制与内存优化实战

SAX解析Excel:startRow、cell、endRow回调机制与内存优化实战 做Excel解析的同行十有八九都被大文件卡死过内存。上次我处理一个50MB出头的xlsx用DOM方式直接OOM换成SAX事件解析后全程内存占用稳在120MB以内速度还快了一个量级。今天就把这块的核心逻辑彻底聊透SAX模式下startRow、cell、endRow这几个回调到底按什么顺序触发应该在哪一步做数据落地怎么避开常见的坑。先说清楚一个概念SAX解析Excel尤其是xlsx格式之所以省内存是因为它把Excel文件当成一个ZIP压缩包里的XML流按节点逐个读取、逐个触发事件整个过程不需要把整个文件加载进内存。这个思路跟Java里解析XML的SAX完全一致只是换成了专门处理Excel单元格结构的回调接口。这篇内容适合谁如果你在用Apache POI的XSSFSheetXMLHandler、EasyExcel这类基于事件模型的工具或者正打算处理动辄几十万行的Excel数据这篇文章能帮你少填不少坑。1. 内容整体设计与思路拆解1.1 为什么大Excel必须走SAX路线Excel的xlsx文件本质上是个ZIP压缩包里面装着多个XML文件工作表内容存放在xl/worksheets/sheet1.xml这样的路径下。既然是XML就有两种读取方式DOM是先把整个XML树读进内存再操作SAX则是流式扫描遇到开始标签、文本内容、结束标签就触发对应回调。放到Excel解析这个场景里DOM方式需要把整张表的所有单元格对象全部建出来放进内存一列一列、一行一行地占着堆内存。十万行、每行三十列的数据光单元格对象就是三百万个再加上样式、字符串驻留这些开销JVM堆内存设个1GB都未必够用。而SAX方式是读到一行就回调一次处理完这行数据就可以立刻丢掉引用内存占用基本恒定不会随文件行数线性增长。我自己实测过一个对比同样解析一个43MB的xlsx文件大约28万行、20列DOM方式在512MB堆内直接OOM换成SAX事件解析后Full GC次数明显减少峰值堆内存大约在150MB左右解析耗时从将近三分钟压缩到四十多秒。这就是SAX路线不可替代的价值。1.2 事件模型的核心设计思想把xlsx的sheet1.xml简化一下大概长这样sheetData row r1 c rA1 tsv0/v/c c rB1v100/v/c /row row r2 c rA2 tsv1/v/c c rB2v200/v/c /row /sheetDataSAX解析器会沿着这些节点依次触发事件遇到row触发行开始遇到c触发单元格事件遇到/row触发行结束。Excel解析框架再把这一层XML事件包装成startRow、cell、endRow这样的回调让开发者不用直接跟XML打交道只关心业务层面的行和单元格。这种设计的核心思想是“边读边抛、抛完就忘”。框架把每个单元格的引用、内容、样式ID依次传给你你的回调逻辑处理完了框架立刻继续往下一个节点走不会把已经读过的数据缓存起来。开发者只需要在回调里决定哪些数据要留、哪些数据不用管主动权完全在自己手里。1.3 工具选型POI的事件API与EasyExcel的取舍提到Java解析Excel绕不开Apache POI。POI提供了一套完整的DOM式APIXSSFWorkbook Sheet Row Cell也提供了基于SAX的事件解析APIXSSFSheetXMLHandler配合SheetContentsHandler接口。EasyExcel则是阿里开源的一个封装层底层仍然基于POI的SAX解析但对外暴露了更简单的AnalysisEventListener接口。两者的区别在于POI的事件API更底层控制力最强但要求开发者理解XML事件模型、自己管理共享字符串表EasyExcel把所有底层细节藏起来了你只需要关注invoke解析到一行数据和doAfterAllAnalysed全部解析完成这两个方法。我的经验是如果只是做常规的大文件导入EasyExcel够用且高效如果要做定制化处理比如精确控制每一列的格式、处理特殊单元格类型、对接自定义样式策略直接用POI的事件API更灵活。后面我会用两个框架分别对照讲解回调机制这样无论你用的是哪个都能对上号。2. 核心细节解析与实操要点2.1 startRow、cell、endRow的执行顺序先说结论这个顺序是固定且严格嵌套的到达某一行的起始标签时触发startRow(int rowNum)此时只知道这一行的行号还不知道行内有什么内容。接着按该行内单元格在XML中出现的顺序逐个触发cell(...)回调。有多少个有效单元格就触发多少次。到达该行结束标签时触发endRow(int rowNum)标志这一行所有单元格已经全部处理完毕。用POI的SheetContentsHandler接口来展示核心代码长这样public class SheetHandler implements XSSFSheetXMLHandler.SheetContentsHandler { private int currentRow -1; private int currentCol -1; Override public void startRow(int rowNum) { // 一行开始rowNum是0-based行号即Excel行号减1 currentRow rowNum; currentCol -1; System.out.println(开始处理第 (rowNum 1) 行); } Override public void cell(String cellReference, String formattedValue, XSSFComment comment) { // 每触发一次代表读取到一个单元格 // cellReference是类似 C5 这样的坐标 // formattedValue是已经格式化成字符串的值 currentCol; System.out.println(单元格 cellReference - formattedValue); } Override public void endRow(int rowNum) { // 一行结束可以在这里把整行数据写入数据库或者List System.out.println(第 (rowNum 1) 行处理完毕); } }这里特别提醒一个新手容易搞混的点startRow/endRow的参数是0-based的行号而cell回调里拿到的cellReference是Excel风格列号行号例如A1、C5。也就是说startRow(2)表示的是Excel的第三行而传进来的cellReference如果包含3则代表Excel第三行两者不要混着用。2.2 单元格回调的触发频率与空白格的坑cell回调是按XML中实际存在的c节点触发的不是按连续的列逐个触发。这就带来一个很经典的问题一行中跳过的空列比如A列有值、D列有值B/C为空XML里根本不会出现对应单元格节点因此cell回调不会被触发。先看一个实际例子Excel里第一行数据是A1 订单号D1 金额B1和C1是空的。对应的XML可能是row r1 c rA1 tsv0/v/c c rD1v1000/v/c /rowSAX解析后cell回调只会触发两次一次A1一次D1。如果你的业务代码假设“每行一定有连续N列数据”按currentCol累加去对齐列位置第4列的数据会被错放到第3列的位置。针对这个坑有两个标准解决办法方法一通过cellReference解析列索引而不是依赖回调次数自增。方法二解析之前对sheet做一次“空列检查”确认数据真的没有跳过空列。EasyExcel的做法更宽容一些它内部有自己的空值处理逻辑如果某列在Excel里是空白invoke读到的对应字段会是null不会造成后续数据错位。这也是EasyExcel能快速上手的原因之一。2.3 endRow之后必须做什么endRow是所有行内单元格处理完毕的信号这也是你做“行级聚合”最安全的位置。为什么这么说因为在这个回调触发之前框架仍有可能再往当前行追加单元格数据虽然实际XML结构里不会但逻辑上要等/row闭合标签出现行数据才算稳定。我踩过的坑是这样的一开始图省事在cell回调里直接把数据写进数据库每一格一条SQL结果一个28万行的文件敲出了560万次插入数据库连接池直接被打爆。后来改成在endRow里拼好整行数据再批量插入性能提升了近二十倍。还有个隐藏的细节endRow触发时你以为这行数据可以扔了不一定。如果你在外部用一个ListString承接了cell回调塞进来的值一定要在endRow里把List清空或者重新new一个。否则下一行的数据会堆叠到上一行后面越攒越多最终一次性写在某行里数据就全乱了。清空操作的位置必须是endRow不是cell。3. 实操过程与核心环节实现3.1 基于Apache POI的完整解析实现先搭一个基于POI事件API的完整示例。这个示例用XSSFSheetXMLHandler解析第一个sheet并模拟把每行数据打印出来。为了把重点放在回调顺序上我先用最简单的构建方式import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.xssf.eventusermodel.XSSFReader; import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler; import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler.SheetContentsHandler; import org.apache.poi.xssf.model.SharedStringsTable; import org.apache.poi.xssf.model.StylesTable; import org.xml.sax.InputSource; import org.xml.sax.XMLReader; import org.xml.sax.helpers.XMLReaderFactory; import java.io.InputStream; public class SaxExcelParser { public static void main(String[] args) throws Exception { String filePath /path/to/large/orders.xlsx; try (OPCPackage pkg OPCPackage.open(filePath)) { XSSFReader reader new XSSFReader(pkg); SharedStringsTable sharedStringsTable reader.getSharedStringsTable(); StylesTable stylesTable reader.getStylesTable(); // 只解析第一个sheet InputStream sheetStream reader.getSheetsData().next(); XMLReader xmlReader XMLReaderFactory.createXMLReader(); XSSFSheetXMLHandler handler new XSSFSheetXMLHandler( stylesTable, sharedStringsTable, new SheetHandler(), // 自定义的行/单元格回调 false // 不需要公式计算保留原样 ); xmlReader.setContentHandler(handler); xmlReader.parse(new InputSource(sheetStream)); } } }注意几个参数点stylesTable用于把样式ID翻译成格式类型比如日期、数字格式。如果你需要知道某格是不是日期类型必须把它传进去否则拿到的是格式化后的字符串类型判断会失准。sharedStringsTable是共享字符串表xlsx里所有字符串值都存在这个表里XML单元格节点里只存一个索引值。不传这个表字符串列读出来全是数字索引。false是formulasNotResults参数。设为true时遇到公式单元格会保留公式表达式而不是计算后的结果设为false时拿到的是公式计算后的值。绝大多数导入场景需要的是后者。3.2 行拼接与批量入库的关键代码实际业务里我们不满足于只打印得真正把数据接住。下面这段代码演示了用ListString收拢行数据、在endRow里统一处理的模式这也是避免内存暴涨的关键写法import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler.SheetContentsHandler; import org.apache.poi.ss.util.CellReference; import java.util.ArrayList; import java.util.List; public class RowCollectHandler implements SheetContentsHandler { private ListString rowData new ArrayList(); private int totalRows 0; private static final int BATCH_SIZE 1000; Override public void startRow(int rowNum) { // 每行开始前清空上一行残留数据 rowData.clear(); } Override public void cell(String cellReference, String formattedValue, XSSFComment comment) { if (cellReference null) { // 某些情况下单元格没有引用信息按顺序补位 rowData.add(formattedValue); return; } // 解析列索引确保空列也能占位 CellReference ref new CellReference(cellReference); int colIndex ref.getCol(); // 如果前面有空白列先把前面的列填充为null while (rowData.size() colIndex) { rowData.add(null); } rowData.add(formattedValue); } Override public void endRow(int rowNum) { // 当前行数据已经完整可以交给批量处理器 totalRows; // 实际项目中这里调用一个批量插入方法 // 攒够 BATCH_SIZE 行再统一入库 processRow(rowData); } private void processRow(ListString row) { // 走JDBC Batch或者MyBatis批量插入接口 // 注意外部传入的rowData在endRow之后还会被复用 // 所以如果要保存引用必须new ArrayList(row) System.out.println(第 totalRows 行数据: row); } }这段代码的巧妙之处在于cell回调里通过CellReference解析列索引并用null补位。即使Excel里A列有值、C列有值、B列空白最终rowData长度也能对齐到C列的索引后续按位置取值不会错位。3.3 EasyExcel版对照实现与推荐做法如果你不想处理底层XML细节EasyExcel看起来会清爽很多。它对外暴露的AnalysisEventListener实际上把startRow、cell、endRow封装成了“解析到一行的完整数据后调用invoke”这种模式import com.alibaba.excel.EasyExcel; import com.alibaba.excel.context.AnalysisContext; import com.alibaba.excel.event.AnalysisEventListener; import java.util.ArrayList; import java.util.List; public class EasyExcelDemo { public static class OrderData { private String orderId; private BigDecimal amount; // getter/setter 省略 } public static class OrderListener extends AnalysisEventListenerOrderData { private final ListOrderData cache new ArrayList(); private static final int BATCH_SIZE 1000; Override public void invoke(OrderData data, AnalysisContext context) { // 每解析完一行会调用一次这个方法 // 相当于在POI的endRow回调里拿到整行数据 cache.add(data); if (cache.size() BATCH_SIZE) { saveBatch(cache); cache.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!cache.isEmpty()) { saveBatch(cache); } } private void saveBatch(ListOrderData list) { // 批量入库逻辑 } } public static void main(String[] args) { String fileName /path/to/orders.xlsx; // 这里传入的是Listener对象EasyExcel会复用这个监听器 EasyExcel.read(fileName, OrderData.class, new OrderListener()).sheet().doRead(); } }EasyExcel的invoke回调机制等价于POI里“每行结束、整合完该行所有单元格之后”的时机。它把startRow、cell、endRow这三层细节折叠成一个“行数据完成事件”对绝大多数业务场景反而更顺手。但如果你的业务需要在行与行之间保持某种状态机比如根据前一行的某个值决定当前行的处理逻辑还是得回到POI事件API通过startRow、endRow自己维护跨行状态。4. 常见问题与排查技巧实录4.1 日期、数字、公式单元格读出来是乱掉的这是SAX解析Excel最常被吐槽的一点。SAX读取的是XML原始内容日期在XML里可能存的是序列化的数字比如44876.5。POI的XSSFSheetXMLHandler依赖StylesTable判断这个数字是否套用了日期格式然后才能转换成人能读的日期字符串。如果你没有正确传入stylesTable或者EasyExcel的对应列没有声明成Date类型读出来的日期会变成一串浮点数。排查思路很简单先打印cellReference和formattedValue确认是不是格式转换层出了问题。POI里可以临时把formattedValue和原始的stylesTable.getCellStyleXf(styleIndex)对照起来看确认是格式索引对不上还是底层样式数据缺失。还有一个很隐蔽的问题如果你的xlsx文件是从某些在线表格工具导出的单元格可能带有奇怪的格式编号。这时候别强行依赖框架的自动转换建议直接在cell回调里按列号做一次自定义格式化把常见日期格式yyyy-MM-dd、yyyy/MM/dd、dd-MMM-yy等都覆盖一遍。4.2 空行、空列导致的数据错位空行和空列在SAX事件里是不会产生回调的。一个全是空白行的区间XML里可能只有几行数据节点解析到的行号会直接跳过去。这在不需要保持原表行号的时候问题不大但如果你要记录“原始Excel第几行数据出错”就必须自己维护一个rowNum到实际业务行的映射。空列的错位问题更严重。我遇到过这样一份报表第一行有表头第2行开始数据但某些行在中间列直接留空XML里没有对应c节点。按回调次数累加列号的方式会把这些行整体左移后续导入到数据库里错位得离谱。解决模板在上面已经给过了在cell回调里用CellReference解析列索引用null补齐空隙。这属于SAX解析的标准防御姿势我建议直接写进你的基础解析组件里别等到出问题再补。4.3 endRow里数据没清理导致下一行数据叠加这个坑我在3.3节提过一嘴但值得单独拿出来说。看下面这段错误示范ListString rowData new ArrayList(); Override public void cell(String cellReference, String formattedValue, XSSFComment comment) { // 把所有单元格数据加进同一个list rowData.add(formattedValue); } Override public void endRow(int rowNum) { // 直接使用rowData没清空也没新建 processRow(rowData); }解析第一行时没问题第二行时rowData里面还留着第一行的数据cell回调又往里追加新数据于是第二行变成第一行第二行的拼接结果。实际表现就是每解析一行数据越来越长最后一行积攒了全部文件的数据。正确做法是像我在3.2小节写的那样在startRow里rowData.clear()或者在endRow里传入一个副本new ArrayList(rowData)。两种都可以但千万别忘了这一步。4.4 共享字符串表导致的内存压力xlsx的字符串列会统一存到sharedStrings.xml里SAX解析时POI会把这个表加载进内存。如果一个Excel文件里有几百万条不重复的中文文本这张表就能吃掉几百MB内存SAX的低内存优势会被削弱。遇到这种场景我通常分两步走第一步如果业务只关心特定几列可以在解析前先对sheet的XML做一次轻量预扫描跳过不需要的列节点从源头减少SharedStringsTable里的索引读取量。第二步如果必须读全表可以把这个表改成LRU缓存或者分批加载的定制实现覆盖POI默认的全量加载策略。这需要继承SharedStringsTable并改写getEntryAt方法适合文件特别大、字符串特别多的极端场景。4.5 EasyExcel常见报错对照速查报错信息出现原因处理建议ExcelDataConvertException: Convert data error单元格内容与Java字段类型不匹配比如Java是IntegerExcel里是abc在字段上用ExcelProperty配合Converter自定义转换或者在Listener里捕获异常单独处理java.lang.OutOfMemoryError: Java heap space文件太大且未使用批量缓存或者字符串列多到爆内存检查Listener是否每批都clear()必要时增大堆内存或改用POI原生SAXAnalysisException: Excel file is not available文件不存在或加密确认文件路径、文件是否被占用加密Excel无法直接SAX解析读出来的日期是数字字段类型未声明为Date或LocalDateTime在实体字段上用DateTimeFormat指定格式或自定义类型转换器解析速度慢、GC频繁每行都创建大量对象尽量复用对象invoke里不要无谓地new大对象批量操作放在攒批之后4.6 压测数据到底能快多少最后给一组有参考价值的实测数据。测试文件是35MB的xlsx25万行、15列包含5列中文字符串、5列数字、3列日期、2列混合数据。机器配置是老款i7 16GB内存JVM堆设512MB解析方式耗时峰值内存Full GC次数POI DOMXSSFWorkbookOOM触发OOM触发OOMPOI SAFX事件解析38秒180MB2EasyExcel44秒150MB1数据从哪来不用较真重点是量级关系SAX路线能扛住DOM扛不住的文件内存占用还低一截。至于POI原生比EasyExcel略快的那几秒通常是EasyExcel多做了一些对象映射和类型转换导致的对业务影响不大。5. 写在最后的一点经验接触SAX解析这几年我最深的体会是回调顺序本身并不难懂真正决定一个解析组件靠不靠谱的是对边界情况的处理——空列补位、endRow清理、日期转换、共享字符串表的内存管理。这些细节在官方文档里往往一带而过但实际生产环境里它们才是让你凌晨被报警电话叫醒的元凶。如果你想在项目里直接用SAX解析大Excel我建议从POI事件API入手先跑通一遍回调顺序再换EasyExcel做业务封装。这样既理解了底层机制又能享受上层的便利。遇到内存问题时心里也有底知道该去哪一层排查。以后再有人说“SAX解析Excel很复杂”你可以直接把这篇甩过去不复杂就是startRow开头、cell逐个来、endRow收尾搞明白这三个回调的职责和时机大文件解析这块就稳了。
返回列表