背景
在处理大规模 Excel 数据导入时,常遇到文件末尾包含汇总行的情况。这类汇总数据不应被导入业务表,但在流式处理模式下,传统的"读取总行数再减N"的方式无法应用。本文介绍一种基于延迟缓冲区的解决方案。
问题分析
传统方案的局限性
方案一:全量加载后过滤
java
List<Row> allRows = readAllRows(file);
for (int i = 0; i < allRows.size() - skipEndRows; i++) {
process(allRows.get(i));
}
缺陷:违背流式处理的设计初衷,大文件场景下易导致 OOM。
方案二:直接流式处理
java
void invoke(RowData row) {
// 边读边处理,但无法判断当前行是否为末尾行
}
缺陷:在逐行回调模式下,当前行的位置信息不足以判断其是否属于需要跳过的末尾区间。
技术方案
核心思想
采用固定窗口延迟缓冲机制 :维护一个大小为 skipEndRows 的滑动窗口,仅处理窗口溢出的数据,确保末尾 N 行始终保留在缓冲区内,直到文件读取完毕后被自动丢弃。
实现原理
- 使用
LinkedList作为延迟缓冲区 - 每读取一行数据,追加至缓冲区尾部
- 当缓冲区大小超过
skipEndRows时,从队列头部移除一行并提交处理 - 文件读取完成后,缓冲区内剩余的
skipEndRows行数据自动随对象销毁而丢弃
流程示意图
以 skipEndRows = 1 为例,处理包含 5 行数据 + 1 行汇总的文件:
| 阶段 | 触发事件 | delayBuffer 状态 | batchRowData | 操作说明 |
|---|---|---|---|---|
| 1 | invoke(row1) | [row1] |
[] |
size=1 ≤ skipEndRows,暂不处理 |
| 2 | invoke(row2) | [row2] |
[row1] |
size=2 > 1,移除 row1 加入批次 |
| 3 | invoke(row3) | [row3] |
[row1, row2] |
移除 row2 |
| 4 | invoke(row4) | [row4] |
[row1, row2, row3] |
移除 row3 |
| 5 | invoke(row5) | [row5] |
[row1, ..., row4] |
移除 row4 |
| 6 | invoke(汇总行) | [汇总] |
[..., row5] |
移除 row5,汇总行滞留 |
| 7 | doAfterAllAnalysed() | [汇总] |
[] |
处理剩余批次,不处理缓冲区 |
| 8 | GC 回收 | null |
- | 缓冲区对象销毁,汇总行丢弃 |
代码实现
抽象基类核心逻辑
java
protected void processExcelFileStreaming(Path filePath, int batchSize,
BiConsumer<Map<Integer, String>, List<Map<Integer, String>>> batchProcessor) throws Exception {
final Map<Integer, String> headerRowMap = new HashMap<>();
final List<Map<Integer, String>> batchRowData = new ArrayList<>(batchSize);
final int skipEndRows = skipEndRowCount();
final LinkedList<Map<Integer, String>> delayBuffer = new LinkedList<>();
EasyExcel.read(filePath.toFile(), new AnalysisEventListener<Map<Integer, Object>>() {
@Override
public void invoke(Map<Integer, Object> data, AnalysisContext context) {
// 省略:表头解析、空行过滤等预处理逻辑
Map<Integer, String> rowData = convertToStringMap(data);
// 延迟缓冲机制:入队
delayBuffer.add(rowData);
// 当缓冲区溢出时,出队并提交处理
if (delayBuffer.size() > skipEndRows) {
Map<Integer, String> eligibleRow = delayBuffer.removeFirst();
batchRowData.add(eligibleRow);
// 批次满时触发回调
if (batchRowData.size() >= batchSize) {
batchProcessor.accept(headerRowMap, new ArrayList<>(batchRowData));
batchRowData.clear();
}
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 处理批次尾部数据
if (!batchRowData.isEmpty()) {
batchProcessor.accept(headerRowMap, new ArrayList<>(batchRowData));
batchRowData.clear();
}
// 关键:delayBuffer 内的 skipEndRows 行数据在此被隐式丢弃
}
}).sheet(getTargetSheetIndex()).headRowNumber(0).doRead();
}
模板方法设计
java
public abstract class AbstractBaseExcelParser {
/**
* 钩子方法:返回需跳过的末尾行数
* 子类可覆写以适配不同业务场景
*/
protected int skipEndRowCount() {
return 0; // 默认不跳过
}
}
@Component
public class FinancialStatementParser extends AbstractBaseExcelParser {
@Override
protected int skipEndRowCount() {
return 1; // 跳过汇总行
}
}
技术细节
EasyExcel 回调时机分析
doAfterAllAnalysed() 的触发条件:
EasyExcel 内部实现伪代码:
java
public void doRead() {
int totalRows = sheet.getLastRowNum(); // Excel 内部元数据记录总行数
for (int i = 0; i <= totalRows; i++) {
Row row = sheet.getRow(i);
listener.invoke(parseRow(row), context);
}
// 循环结束后触发
listener.doAfterAllAnalysed(context);
}
Excel 文件结构说明:
.xlsx 文件本质是 ZIP 压缩包,其中 xl/worksheets/sheet1.xml 包含元数据:
xml
<worksheet>
<dimension ref="A1:C1001"/> <!-- 数据范围元信息 -->
<sheetData>
<row r="1">...</row>
<!-- ... -->
<row r="1001">...</row>
</sheetData>
</worksheet>
EasyExcel 解析 <dimension> 标签即可获知总行数,从而判断读取结束时机。
方案总结
优势
- 内存可控 :占用量仅为
O(batchSize + skipEndRows),与文件大小无关 - 逻辑清晰:核心实现仅 3 行代码,可读性强
- 扩展性好:通过模板方法模式支持子类灵活配置跳过行数
适用场景
- 大规模 Excel 数据导入
- 文件末尾包含汇总/统计行
- 内存受限环境
- 需要边读边处理的流式场景