百万行Excel如何恒定内存?Acl.Excel流式拆解
百万行Excel如何恒定内存?Acl.Excel流式拆解
本文力求客观:既呈现 Acl.Excel 的实测数据,也如实标注测试口径、版本差异与已知局限,供读者自行判断。
口径说明:解压吞吐为 LibDeflate 对 .NET DeflateStream 的实测 1.8x-2.7x;超大文件恒定内存基于 4MB 滑动窗口;自适应读取默认 256MB 内存预算。局限:吞吐依赖数据大小与内容。
系列导航:← 上一篇:不依赖 NPOI/ClosedXML,纯 C# Excel 库如何做到 30 倍性能? · 下一篇:零装箱设计 →
读取一个 100 万行的 Excel,你的内存占用是多少?大多数 .NET 库的做法是:先解压整个 ZIP,再把完整 XML 加载成 DOM,最后逐节点填充对象。这条路径在数据量到几十万行时,内存就奔着几百 MB 去了,文件再大些甚至直接 OOM。
Acl.Excel 反其道而行:它把读取管线拆成四层,每层都做流式处理,数据顺着读取管线逐行 yield 出来,从不全量物化;实测解压吞吐约为 .NET DeflateStream 的 1.8x-2.7x,超大文件恒定内存只占一个 4MB 滑动窗口。读完本文,你会弄清四件事:Excel 读取到底为什么吃内存、Acl.Excel 的四层管线如何层层省内存、三种扫描器策略该怎么选、以及 256MB 内存预算下"物化"与"流式"如何自动切换。
核心结论 - 读取的内存爆炸,根源在"先全量解压、再建 DOM、最后物化对象"三步累加;Acl.Excel 用四层流式管线逐层拆解,数据从不全量物化。 - ZIP 层绕过 DeflateStream,用 LibDeflateDecompressor 直通解压,吞吐约为 .NET 原生的 1.8x-2.7x。 - 超大文件靠 ByteSheetScanner2 的 4MB 滑动窗口实现恒定内存,单行超限时自动降级为标准 XmlReader。 - NARROW 窗口契约让"窗口外"的数据根本不被解析和分配,取 100 行与取 100 万行内存几乎一致。 - 自适应读取按 256MB 内存预算自动在"物化 List"与"流式枚举"间切换,开发者无需手动判断文件大小。
一、读取管线的四层模型
要理解 Acl.Excel 为什么省内存,先看清它把整条读取链路拆成了什么。读取一个 Excel 文件,大多数库的做法是:解压 ZIP → 加载整个 XML DOM → 遍历节点填充对象。这条路径在数据量到达数十万行时,内存占用就会飙升到数百 MB。Acl.Excel 的做法截然不同:它把读取管线拆成四层,每层都采用流式处理,数据从文件逐层流经管线,最终以模型对象的形式逐行 yield 出来,从不全量物化。
ZIP 文件
│
▼
[ZIP 层] LibDeflateDecompressor
直接解压 worksheet XML 条目 → deflate 字节流
│
▼
[XML 扫描层] SheetRowScanner
按策略选择扫描器,逐行 yield RawRow
│
▼
[单元格解码层] CellDecoder
RawRow → CellValue[] (24B 值类型,零装箱)
│
▼
[映射层] CellSetter / CompiledRowMapper
CellValue[] → TModel (表达式树预编译,直接赋值)
│
▼
IEnumerable<TModel> (延迟消费,yield return)
每一层都可以独立替换或优化,而不会影响其他层。例如,可以把 XML 扫描层从 ByteSheetScanner 换成 XmlReaderSheetScanner(回退到标准 XmlReader),上层的 CellDecoder 和 CellSetter 完全不受影响。这种分层带来的好处很直接:任何一层的性能瓶颈,都可以在不触碰其他层的前提下单独攻克。
二、ZIP 层:绕过 DeflateStream 直通解压
为什么要从 ZIP 层开始抠性能?因为解压是整条管线的第一道闸门,闸门越宽,后面的扫描和映射才能跑得越顺。
.NET 的 ZipArchive 在读取压缩条目时,会经过多层包装:ZipArchiveEntry.Open() → WrappedStream → DeflateStream → 底层 Stream。每次 Read 调用都会产生多次虚方法调用和中间缓冲分配。
Acl.Excel 的 SheetXmlReader 通过反射获取 ZipArchive 底层可寻址流,直接定位到目标条目的原始 DEFLATE 数据,然后用 LibDeflateDecompressor 一次解压。这条路径的吞吐量约是 .NET DeflateStream 的 2 倍(1.8x-2.7x,取决于数据大小)。
// 核心路径:绕过 DeflateStream,直通解压
var bytes = ZipRawEntryReader.TryReadRawEntry(archive, entryName);
// bytes 是解压后的完整 worksheet XML 字节数组
对于小文件(压缩体积 ≤1MB),解压后的字节会被缓存起来,同实例多次打开同一 Sheet 时直接命中缓存,避免重复解压。
三、XML 扫描层:三种扫描器策略
ZIP 层把字节流交上来之后,真正的"逐行读取"发生在 XML 扫描层。SheetRowScanner 是读取管线的核心调度器,根据 ReadStrategy 枚举选择底层扫描器实现:
| 策略 | 扫描器 | 内存模型 | 适用场景 |
|---|---|---|---|
Fast |
ByteSheetScanner v1 |
全量入内存 | 中小区间,极致吞吐 |
SlidingWindow |
ByteSheetScanner2 v2 |
4MB 滑动窗口 | 超大文件,恒定内存 |
Standard |
XmlReaderSheetScanner |
流式直读 | 回退方案,兼容性优先 |
三种策略分别对应"要速度""要内存可控""要兼容"三种诉求,下面逐一拆解。
3.1 ByteSheetScanner v1:全量字节扫描
ByteSheetScanner 将整个 worksheet XML 读入一个 byte[],然后用 ReadOnlySpan<byte> 直接在内存中解析。它不创建任何 string 对象,元素名匹配使用 RangeEq 直接比较字节序列:
// 零分配元素名匹配
if (RangeEq(span, "sheetData"u8)) { ... }
if (RangeEq(span, "row"u8)) { ... }
if (RangeEq(span, "c"u8)) { ... }
所有 UTF-8 常量在编译时即内联为 ReadOnlySpan<byte>,运行时零额外分配。这种方式的吞吐量极高,但内存占用与文件大小成正比(全量读入)。
3.2 ByteSheetScanner2 v2:滑动窗口
v1 的短板很明确:文件越大,内存越膨胀。v2 版本针对超大文件场景做了有界流式改造。核心思路是:只维护一个 4MB 的滑动窗口,逐段扫描 XML 字节流。当窗口滑动到末尾时,只搬移 [committed..valid) 残尾(通常很小),避免 O(n²) 的窗口压实开销。
时刻 T1: [已提交] [未提交] [未扫描........................]
↑
窗口起点 (4MB)
时刻 T2: [........已提交] [未提交] [未扫描................]
↑
窗口起点 (4MB)
如果单行数据超过 4MB 窗口大小(极端情况),v2 自动降级为 XmlReaderSheetScanner,确保不丢失数据。
3.3 XmlReaderSheetScanner:标准回退
当 ReadStrategy 设为 Standard 时,SheetRowScanner 使用 .NET 内置 XmlReader 进行流式解析。这是最保守、兼容性最好的方案,适合对分配不敏感的场景。
四、三级缓存体系
解压和扫描都很贵,所以能不重复做就不重复做。SheetXmlReader 内部维护一个三级缓存,用于减少重复解压和 XML 解析:
| 缓存级别 | 生命周期 | 键 | 内容 |
|---|---|---|---|
| G2(跨实例) | 全局静态 | path@mtime@sheetName |
解压后的 byte[] |
| E(同实例) | Workbook 实例 |
sheet 索引 | 解压后的 byte[] |
| 流式直读 | 无缓存 | — | 大表直接 entry.Open() |
G2 缓存以文件修改时间(mtime)作为失效标记:文件被修改后,缓存自动失效。E 缓存由 EnableEntryCache 选项控制,适合同实例重复读取同一 Sheet 的场景。
小表门控:仅压缩体积 ≤1MB 的 Sheet 走全量解压 + 缓存路径。大表直接 entry.Open() 流式直读,避免把几 GB 的数据全部解压到内存。
五、窗口策略:NARROW 契约
缓存解决的是"重复读取",而窗口解决的是"只读我要的"。Acl.Excel 的窗口参数遵循 NARROW 契约:只读取窗口内的行和列,窗口外的数据不包含在结果中。这与某些库的 WIDE 契约(读取全部数据后在内存中过滤)根本不同。
var options = new ExcelQueryOptions
{
StartRow = 100, // 从第 100 行开始读数据
MaxRows = 50, // 最多读 50 行数据
StartColumn = 3, // 从第 3 列开始
MaxColumns = 5, // 最多读 5 列
HasHeader = true // 第 99 行作为表头
};
窗口参数的类型统一为 uint/uint?,语义清晰:
- StartRow:数据起始行号(1-based),含表头时表头在 StartRow - 1 行
- MaxRows:数据记录的最大行数,不含表头
- StartColumn:起始列号
- MaxColumns:最大列数
NARROW 契约的核心价值在于:窗口外的数据根本不会被解析和分配,rows 过滤在映射到模型之前即完成。在百万行数据中只取前 100 行的场景下,内存占用和耗时与 100 行的小表几乎一致。
六、自适应读取:物化与流式的自动切换
NARROW 解决了"只读一部分",但当你就是要读全表时,库还得替你判断:是一次性读进内存方便用,还是边读边吐省内存?QueryAdaptive<TModel> 根据预估单元格数和内存预算(默认 256MB)自动选择策略:
- 小表(数值约 6.3M 单元格、字符串约 1.6M 单元格 @ 256MB):全量物化为
List<TModel>,可自由索引、复用 - 大表:流式枚举,逐行读取,恒定内存
开发者无需手动判断文件大小,Acl.Excel 在打开文件时自动读取 Sheet 维度信息(dimension),在首行数据返回前完成策略切换。
七、自适应读取的失效边界
自适应很省心,但它不是无条件的。开发者需要了解自适应决策的前提:它依赖 Sheet 的 dimension 声明(<dimension ref="A1:Z100000"/>)。如果 dimension 声明与实际数据量差距过大(例如声明为 A1:Z1000000 但实际只有 100 行),自适应可能误判为"大表"而走流式路径,导致无法物化为 List。对于这类文件,显式使用 Query<TModel> 或 QueryAdaptive<TModel> 并传入较小的 memoryBudgetBytes 即可。
八、关键收获
- 读取的内存爆炸,根源在"全量解压 + 建 DOM + 物化对象"的累加;Acl.Excel 用四层流式管线逐层消解,数据从不全量物化。
- 解压走 LibDeflateDecompressor 直通路径,吞吐约为 .NET DeflateStream 的 1.8x-2.7x。
- 超大文件靠 4MB 滑动窗口恒定内存;NARROW 契约让"窗口外"数据根本不被解析,取 100 行与取 100 万行内存几乎一致。
- 三级缓存(G2 跨实例 / E 同实例 / 流式直读)与 ≤1MB 小表门控,避免大文件被整包解压进内存。
- 自适应读取按 256MB 内存预算自动切换"物化 List"与"流式枚举",但它依赖 dimension 声明;声明严重失真的文件需显式传入较小的
memoryBudgetBytes。
免责声明:本文基于 Acl.Excel 4.0.0(2026 年 7 月,撰写日期前后)实测数据撰写,测试环境与口径见正文;数据可能随版本演进变化,重要决策请自行复测核验。
客观性说明:本文的结论基于以下事实约束,而非自诩无偏:① 解压吞吐为 LibDeflate 对 .NET DeflateStream 的实测(1.8x-2.7x);② 超大文件恒定内存基于 4MB 滑动窗口(ByteSheetScanner2);③ 自适应读取基于默认 256MB 内存预算,依赖 dimension 声明。以上局限已在正文相应位置如实标注,供读者结合完整信息自行判断。

浙公网安备 33010602011771号