技术文章

在 Delphi 中以恒定内存写入百万行 XLSX

一个报表任务稳定运行了一年:构建工作簿,将查询结果填入工作表,然后保存。直到某天,一位拥有五年历史数据的客户要求全量导出,行数突破百万,进程在文件落盘前就因内存不足而崩溃。代码本身并没有问题。问题在于它将整个工作簿保存在内存中以便最终序列化,而所需内存随着写入行数的增加同步增长

解决办法不是换一台更大的机器,而是换一种写入模型。HotXLS 的 streaming 直接写入器在行数据到达时就以增量方式生成 OOXML 包,因此内存占用与写入的行数无关。它是 streaming 读取器在写入侧的对应实现:读取器无需构建单元格树即可遍历超大工作表,写入器同样无需构建单元格树即可生成工作表

普通保存路径为何随数据量增长

常规的 TXLSXWorkbook 路径会先构建完整的对象模型:每个单元格连同其值、类型和样式引用,都以对象形式驻留在内存中,直到调用保存时才将整棵树序列化到包中。这种模型在需要读取工作表、编辑、重新计算再写回的场景下是正确的,因为随机访问任意单元格正是编辑所需。但当你只是单向写入行数据、从不回头查看时,它就成了错误的选择——你为保持每行常驻付出了代价,却没有任何收益。一百万行对象就是一百万行对象,不管你是否会再次访问它们

streaming 写入器去掉了这棵树。单元格写入后立刻转换为工作表部分中的字节,并交给 zip 输出。工作表流是唯一会增长的缓冲区,且增长发生在输出侧,而不是以堆上的 Delphi 对象形式存在。常驻内存的只有固定数量的簿记信息:工作表名称、若干标志位、当前行号以及单元格计数器。这些内容从第一行到第一千万行始终不变

共享字符串表是陷阱,内联字符串才是出路

大多数 streaming XLSX 写入器在遇到文本之前表现良好。OOXML 格式通常将字符串存储在共享字符串表中:每个唯一字符串写入一次到独立部分,持有该字符串的每个单元格只存储一个指向该表的索引,而不是文本本身。这对充满重复标签的文件是一种很好的空间优化,也是标准保存路径的默认行为。但对 streaming 写入器而言,问题是毁灭性的。为了去重,该表必须在整个任务期间始终驻留内存,因为后续的任何一行都可能重复已写入行中出现过的字符串,只有完整的已见字符串内存映射才能分配正确的索引。因此,streaming 写入器唯一无法流式处理的,恰恰是本该让文件变小的那个结构。文本密集型数据会让你所期望的 streaming 优势荡然无存

直接写入器彻底绕过了这张表。字符串以内联方式写入,作为 t="inlineStr" 单元格,文本通过 <is><t> 元素直接内嵌在单元格中。不需要积累任何表,也不需要维护已见字符串的映射,因此文本列的内存开销与数字列相同。这种取舍是明确的,值得直说:内联字符串会在每次出现时重复相同的文本,因此含有大量重复标签的文件在磁盘上比使用共享字符串的版本更大。你用文件体积换取恒定内存。对于单次导出而言,这是正确的取舍,而且 zip 压缩无论如何都会吸收大量重复内容

样式表在最后写入,只包含一种日期格式

样式与字符串面临同样的矛盾。工作簿通过样式部分引用其格式,而 streaming 写入器无法在已刷出的单元格之后再维护一个不断增长的样式调色板。直接写入器的解决方案是保持样式表小而固定,并在关闭时才生成,而非提前写入。一种默认单元格格式覆盖普通单元格,一种日期数字格式覆盖日期,以 yyyy-mm-dd 格式代码注册在单元格格式列表中的已知位置

正是因为这种日期格式,WriteDateTime 才作为独立调用存在。Excel 没有原生日期类型;日期本质上是穿着日期格式的数字。WriteDateTime 将值写为普通序列号,并为单元格打上那个日期样式标记,使电子表格将其渲染为日期而非五位整数。写入的序列号对于往返读取至关重要。它直接按 1900 日期系统存储 TDateTime 值,与常规 TXLSXWorkbook 保存路径使用的约定相同。由于两种路径对序列号的处理一致,streaming 写入器生成的文件通过 HotXLS 读取器读回并在 Excel 中打开时,日期与预期完全吻合,写入器与读取器之间不存在差一错误或纪元偏差

顺序是强制要求,因为字节已经写出去了

Streaming 以一条必须遵守的规则换取内存优势。输出在写入时立即生成,无法回溯,因此所有内容必须按照文件中出现的顺序写入。在同一行内,单元格按列号升序写入;在同一工作表内,行按升序写入。没有任何缓冲区允许写入器事后对单元格排序,因为刚刚关闭的那一行已经是 zip 流中的字节,不再可访问。如果在同一行中先写第 5 列再写第 2 列,输出将是畸形的,因为写入器只是按你提供的顺序原样输出

行 API 为常见情况提供了一个小便利。AddRow 接受从 1 开始的行索引,但传入 0 表示在前一行之后取下一行,因此顺序填充时无需自行跟踪并传入递增计数器。每次 AddRow 会关闭前一行,每次 AddSheet 会关闭前一工作表,因此你永远不需要显式结束一行或一个工作表。开始下一个时,写入器会自动完成当前打开结构的收尾工作

转义在文本进入 XML 时处理

你写入的任何文本都会成为 XML 文档的一部分,因此五个预定义 XML 实体必须经过转义,否则一旦值中包含 & 符号或尖括号,包就会立即失效。写入器会自动对内联字符串文本和公式文本中的 &<>"' 进行转义——这是调用方提供的字符进入标记的两处位置。你传入原始 WideString,写入器负责使其安全。产品名称如 Smith & Co <Ltd>,或引用带引号工作表名称的公式,都会以格式良好的 XML 输出,无需你在调用侧进行任何转义

生命周期,以及为何 Destroy 仍会关闭

完成包的写入,意味着写出工作簿部分、样式部分、内容类型和关系部分,最后写出 zip 中央目录。这些工作在 Close 中完成。从未关闭的包是一个不完整的 zip,任何电子表格程序都无法打开,因此关闭不是可选的清理操作,而是使文件有效的必要步骤。为了防止在错误路径中遗漏 CloseDestroy 会在包仍处于打开状态时执行尽力而为的关闭,从而即使异常跳过了显式调用,释放写入器也不会泄漏底层 zip 对象。可靠的模式仍然是普通的 Delphi 写法:在 try 内写入,调用 Close,在 finally 中释放

端到端流式写入大型工作表

任务的结构是:开始、添加工作表、倒入数据行、关闭。以下示例写入一个标题行,然后写入大量有类型的数据行,混合了字符串、数字、无缓存结果的公式和日期。无论写入十行还是一千万行,内存占用都相同,因为每个单元格写入后立即流向 zip 流

uses
  lxDirectWrite;

procedure StreamReport(const Path: string; RowCount: Integer);
var
  W: TXLSDirectWriter;
  I: Integer;
begin
  W := TXLSDirectWriter.Create;
  try
    W.BeginFile(Path);
    W.AddSheet('Sales');

    // Header row, written in ascending column order
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Data rows; pass 0 to AddRow to take the next row automatically
    for I := 1 to RowCount do
    begin
      W.AddRow(0);
      W.WriteString(1, 'Item ' + IntToStr(I));
      W.WriteNumber(2, I);
      W.WriteNumber(3, 1.5 + (I mod 10));
      W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
      W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
    end;

    W.Close;                       // finalises the package
  finally
    W.Free;
  end;
end;

第二个工作表只需在继续之前再调用一次 AddSheet,写入器会在打开第二个工作表时自动关闭第一个。布尔标志使用 WriteBoolean,它写入一个有类型的布尔单元格,而不是文本"True"。如果想确认文件完好且可往返读取,属性 CellCount 会报告已写入的单元格总数,用 streaming 读取器读回结果应报告相同的总数

  // A second sheet of typed flags after the data sheet above
  W.AddSheet('Flags');
  W.AddRow(1);
  W.WriteString(1, 'Name');
  W.WriteString(2, 'Active');
  W.AddRow(0);
  W.WriteString(1, 'alpha');
  W.WriteBoolean(2, True);

  WriteLn(Format('wrote %d cells', [W.CellCount]));

写入流而非文件的代码完全相同,只需将 BeginFile 替换为 BeginStream,这样服务器就可以将工作簿发送到 HTTP 响应或内存流,无需在磁盘上创建临时文件。写入器不拥有你传入的流,因此你保留对其生命周期的控制权

如果工作是一个按需构建工作簿的服务端点,服务器与批处理作业的 streaming 写入中的模式展示了如何将其接入请求处理器和计划导出任务。如果关注的是超大工作簿(读写两侧)的整体开销,Delphi 中大型工作簿的性能深入分析了时间和内存的实际去向。streaming 直接写入器作为 HotXLS Component 的组成部分随 Delphi 和 C++Builder 版本一同发布,与本博客其他文章所介绍的完整读取、编辑和保存 API 并存