一张拥有百万行、十余列数据的电子表格,对数据库报表任务来说是再普通不过的导出结果。如果用常规方式打开它——将整个工作簿加载到 TXLSWorkbook——进程就必须在你的业务逻辑运行第一行之前,先将那一千两百万个单元格全部实例化为活动对象。磁盘上的文件可能只有六十兆字节的压缩 XML,但它展开后形成的对象树会是原文件大小的数倍,且由于模型本质上是随机访问的,所有数据必须同时驻留内存。对于一份你打算从头读到尾便丢弃的报告而言,这样的内存消耗完全用在了一个你根本不需要的数据结构上
处理同一文件还有另一条路。不建立模型,而是仅向前扫描工作表 XML,每次处理一个单元格,查看后让它流过。没有任何积累。无论工作表有一千行还是一千万行,内存都保持接近恒定,因为读取器在任何时刻持有的内容不过是当前正在解析的部分,以及几张小型查找表。这正是 HotXLS 直接读取器的工作方式,本文其余部分将解释它为何能保持这么低的内存占用,以及你能因此获得什么
为何内存模型无法扩展
XLSX 文件是一个由 ECMA-376 描述的 XML 分部 ZIP 包。每张工作表都是其中一个独立分部,路径为 xl/worksheets/sheetN.xml,其中每行是一个 <row> 元素,包含若干 <c> 单元格元素。常规加载路径会读取该分部,并为每个单元格构建一个可寻址对象,使你之后可以通过 Cells[12345, 7] 在常数时间内获得答案。随机访问是工作簿模型的核心价值,也正是它使编辑、公式求值和样式设置变得便捷
代价在于,随机访问要求所有数据同时存在。你无法对一个尚未完整构建的结构进行索引。因此,完整加载的峰值内存是单元格数量的函数,在一张拥有数百万有效单元格的工作表上,这个函数的结果会让你的服务难以承受,尤其是当多个此类任务在共享机器上同时运行时。当你实际需要的访问模式是顺序的,为随机访问付出代价等于为你永远不会使用的能力买单
仅向前的 SAX 扫描,不构建任何树
直接读取器打开 ZIP 包,使用 SAX 风格的拉式解析器遍历每个工作表分部。这里的 SAX 意味着解析器在遇到各种结构时即时报告解析事件:开始元素、文本节点、结束元素,然后继续前进,不保留任何节点树。读取器从 r 属性中追踪当前的行列位置,随着事件到来收集单元格的类型、样式索引、值和公式文本,当遇到 </c> 闭合标签时,便发射一个单元格并将其遗忘。下一个单元格会复用同一批局部变量
由于单元格之间不保留任何状态,内存占用不会随单元格数量增长。这正是值得坚守的特性。无论工作表有两百行还是两千万行,读取器的驻留内存消耗完全相同,两者之间的区别只是扫描所需的时间。你放弃了随机访问——模型的核心卖点——换来的是一个单元格数量再多也无法突破的内存上限
什么会常驻内存,以及为何是这两部分
扫描并非完全无状态,而这些例外本身也颇具启发性。有两张小型表必须在整个过程中驻留内存,因为单元格本身携带的信息不足以独立解释
第一张是共享字符串表。在 SpreadsheetML 中,文本单元格不直接存储自己的文本,而是携带 t="s" 标记和一个数字载荷,该数字是 xl/sharedStrings.xml 中的索引——这是一份包含工作簿中所有不重复字符串的去重列表。对于同一标签在数千行中重复出现的文件,这是一种很好的空间优化,但这意味着读取器必须预先加载该字符串表并持续保留,因为任意工作表的任意位置都可能引用其中的任意条目。表的大小由不重复字符串的数量决定,而非单元格数量,因此即便在超大工作表上也保持适中
第二张是来自样式分部的数字格式映射表。在传输层面,数值单元格和日期单元格是完全相同的字节序列:两者都是纯数字,因为 SpreadsheetML 中的日期不过是一个序列天数。区分它们的唯一依据是单元格的样式,该样式通过 xl/styles.xml 中的 cellXfs 指向一个数字格式 ID。为了将日期以日期形式而非原始序列数报告,读取器会加载这张样式到格式的映射表并持续保留。文件中的其他所有内容——构成字节绝大多数的实际单元格数据——都流过而不存储
每个单元格都报告类型和值
每个发射出的单元格以 TXLSDirectCell 记录形式到达。它携带工作表索引和名称、基于 1 的行列编号、语义化的 Kind、以 Variant 形式存放的 Value、不含前导等号的 Formula 文本,以及原始的 StyleIndex。Kind 的取值为 xdkNumber、xdkString、xdkBoolean、xdkDate 或 xdkError 之一,你可以直接根据单元格的含义进行分支处理,而无需重新从属性推导。公式单元格报告其缓存结果的类型,同时附带公式文本,因此一个计算出的合计值会以数字形式到来,同时告知你它是如何产生的
type
TReportScan = class
procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
end;
procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
begin
case Cell.Kind of
xdkString: AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
xdkNumber: AddToTotals(Cell.Col, Double(Cell.Value));
xdkDate: NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
xdkError: LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
end;
end;
区分日期与数字
日期识别问题值得深入探讨,因为这正是大多数简单扫描器出错的地方。数值单元格本身没有日期类型。一个携带序列值 46000 的单元格可能是数量、价格,也可能是 2025 年 2 月 17 日,文件仅通过单元格样式所指向的数字格式 ID 来区分它们。ECMA-376 保留了一批内置格式 ID,其含义在所有合规生产者中固定不变,承载日期的 ID 分布在两个范围内:14 到 22 为标准日期和时间格式,45 到 47 为经过时间格式,例如 [h]:mm:ss。当 DetectDates 开启时(默认如此),读取器会将每个数值单元格的样式解析为其格式 ID,若该 ID 落在上述保留范围内,则将该单元格报告为 xdkDate,其 Value 已转换为 Delphi 的 TDateTime。自定义格式也会通过检查格式代码中的日期和时间标记来处理,但保留范围才是可靠的核心依据。关闭 DetectDates 后,样式表甚至不会被加载,所有数值单元格都以 xdkNumber 形式到来,扫描稍微更轻量
跳过工作表与提前中止
顺序扫描有一个随机访问无法企及的优势:你可以停下来。OnSheet 事件在每张工作表打开之前触发,并提供两个开关。设置 SkipSheet,该整个分部便不会被解析,这正是在多工作表工作簿中只扫描你关心的工作表、而不为其余部分付出代价的方式。设置 Abort,整个扫描立即终止。OnCell 事件也携带自己的 Abort,因此你可以在找到所需内容的瞬间停止——某一特定行、某个哨兵值、某个标题块的末尾——而无需继续读取剩余的数百万个单元格。在仅向前的扫描中,中止是真正零代价的,因为你跳过的工作根本还未发生
procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
// Scan only the "Data" sheet; leave the rest unread
SkipSheet := SheetName <> 'Data';
end;
无需处理器即可统计单元格数量
有一项近期改进值得特别指出,因为它将一个常见问题变成了一次廉价的单次调用。读取器会统计它经过的每个有效单元格,无论是否附加了 OnCell 处理器。此前,在未设置处理器的情况下,有效单元格数量会返回零,因为统计是发射的副作用。现在,统计独立于发射。这意味着你可以只问一个问题——这个工作簿究竟包含多少有效单元格——并以完全无回调扫描的代价得到答案。ReadFile 和 ReadStream 都以 Int64 形式返回该总数,同一数字事后也可通过 CellCount 属性获取。返回 -1 表示文件无法打开或不是 OOXML 包
var
Reader: TXLSDirectReader;
Populated: Int64;
begin
Reader := TXLSDirectReader.Create;
try
// No OnCell handler: a pure populated-cell census, still near-constant memory
Populated := Reader.ReadFile('quarterly_export.xlsx');
if Populated < 0 then
raise Exception.Create('Not a readable XLSX package')
else
Writeln(Format('%d populated cells (CellCount = %d)',
[Populated, Reader.CellCount]));
finally
Reader.Free;
end;
end;
进行完整扫描时,附加处理器后以完全相同的方式调用 ReadFile。与完整加载的对比正是关键所在:将 quarterly_export.xlsx 加载到工作簿中会把每个单元格展开为驻留对象并全部保留,而直接读取器仅保留共享字符串和样式表,让那一千两百万个单元格逐一流过你的 OnCell。每个单元格的运算完成后不留下任何内容,因此峰值内存取决于工作簿中不重复字符串的数量,而非行数
直接读取器是当任务为一次性读取大型工作簿并提取或汇总其内容时的正确工具。若你需要完整模型的随机访问能力,同时希望它在大文件上表现良好,我们关于 Delphi 大型工作簿性能的笔记涵盖了该路径。而当方向反过来,是生成大量输出而非消费输入时,服务器批处理任务的流式写入指南将同样的恒定内存原则应用于写入场景。三者均作为适用于 Delphi 和 C++Builder 的 HotXLS Component 的组成部分提供,与本博客其他地方介绍的读取、写入、公式和格式化 API 一同发布