技术文章

适用于 Delphi 服务器批处理作业的 HotXLS 流式写入

假设一个夜间的 Delphi 服务为每个客户生成一个 XLSX,共计几百个文件,其中一些文件有 400,000 行宽;对其进行性能分析,令人惊讶的很少是单元格填充循环,而是 SaveAs 调用;在使用默认写入器时,每个工作表在被压缩进 OOXML zip 包之前,都会被序列化为单个内存中的 XML 字符串,而对于一个大工作表,该临时字符串的大小可能会让构建它的单元格模型相形见绌;因此,一个本能轻松构建数据并保持在 800 MB 内存的工作在保存期间会飙升超过 2 GB 的容器限制,而 OOM 杀手会在凌晨 03:00 没人监视时提交错误报告;HotXLS 是 losLab 专为 Delphi 和 C++Builder 开发的原生电子表格库,它拥有一个直面该飙升问题的属性:StreamingWrite;围绕它还有另外两个杠杆,它们决定了批处理工作线程是否能保持在内存和时间预算之内,即行级写入回调以及样式池在紧凑循环中的行为方式

默认保存路径的缓冲,以及 StreamingWrite 所改变的内容

默认的 XLSX 写入器更倾向于简单性:它完整地渲染工作表 XML,然后将完成的字符串交给 zip 压缩器;这对于绝大多数工作簿来说是正确的权衡,因为整个工作表的 XML 大小只有几兆字节;当一个工作表的序列化形式达到几百兆字节时,这种权衡就不再适用了;电子表格 XML 是冗长的:每个数值单元格都需要耗费几十个字符的标记,并且保存所有这些内容的字符串必须是连续的;在内存图表上,其特征很难被忽略:行填充时是长长的平坦高原,然后在 SaveAs 期间是一个尖锐的三角形飙升,最后在 zip 刷新后崩溃

设置 Book.StreamingWrite := True 会将 SaveAs 切换为工作表写入器,在生成工作表 XML 时直接将其写入 zip 流;中间字符串绝不会被分配,三角形的飙升被拉平成噪音

一定要准确理解这究竟能为您带来什么,因为过度吹嘘会导致错误的容量计划;该标志仅改变保存路径;构建工作簿仍会分配完整的内存中单元格模型,因此填充阶段的高原与以前完全一样高;消失的是原本在保存时叠加在该高原之上的序列化飙升,而对于填充 400k 行的工作,该飙升通常就是符合内存预算与超出预算之间的全部区别;该属性默认为 False 以保留历史行为,因此选择加入是您特意编写的一行显式代码

在开启该标志的情况下进行批量导出

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // pool index, 0-based
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
    if (R mod 1000) = 0 then
      Sheet.Cells[R, 2].FontIndex := BoldIdx + 1;        // 1-based at the cell
  end;
  Book.StreamingWrite := True;   // stream sheet XML straight into the zip
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] 根据需要创建单元格,从而保持了循环体干净;有两个网格上限值得牢记:1,048,576 行和 16,384 列,公开为 XlsxMaxRow and XlsxMaxCol;在您自己的代码中,超出该行上限的数据源必须跨工作表拆分;下游没有任何内容会注意到超出上限或为您修复它,文件只会简单地在限制处被截断

在没有每单元格 Variant 开销的情况下填充行

每个 Cells[R, C].Value 赋值都需要支付单元格查找和 Variant 转换的开销;在一万行时,没人会注意到这一点,但在 20 列的百万行中,这种每次调用的开销就变成了填充阶段的主导成本,性能分析器将直接指向它;批处理接口允许您一次向写入器交付整行;WriteRows 驱动一个回调,每次调用提供一行:

procedure TBulkExporter.FillRow(Sender: TObject; SheetIndex, Row, FirstCol,
  LastCol: Integer; var Values: Variant; var Skip: Boolean;
  var Cancel: Boolean);
begin
  if not FReader.Next then
  begin
    Cancel := True;              // data source drained: stop cleanly
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// fill rows 2..100001, columns A..C, pulling from the reader
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

Cancel 标志是将固定行范围转换为“最多 N 行”的关键,当行数来自您尚未执行完毕的查询时,这是很自然的形态;Skip 是更轻微的操作:它将单行留空而不停止运行;除了填充单元格之外,回调被证明是放置其他业务逻辑的合适场所(否则它们将以尴尬的方式被套在填充循环中):每隔一千行滴答一次的进度计数器、从作业调度程序中轮询的取消令牌、源数据库读取的限速器 —— 所有这些都放在一个地方,而不是穿透单元格编写代码;在读取端,ForEachRowForEachCell 镜像了相同的模式,当批处理作业同时消费和生产大型文件时,这一点就很重要

样式池奖励提取(Hoisting)

XLSX 样式模型是一组共享池;Fonts.AddFills.AddSolidBorders.Add 都返回基于 0 的池索引,单元格通过在 FontIndex 中存储该索引加 1 来引用字体,其中 0 被保留用于工作簿默认值;上面的批量示例中就有这个 +1;忘记它,单元格就会静默地选取错误的样式,因为样式池索引中偏移 1 依然是一个有效的索引,且不会引发任何异常

随之而来的约束是,在行循环之前创建每个样式对象,并在循环内引用其索引;Fonts.Add 对相同的定义进行了去重,因此每行调用它一次只会浪费 CPU;Alignments.Add 是个陷阱,因为它在每次调用时都返回一个新条目;在 100k 行的循环中,这会将 styles.xml 埋在十万个重复的对齐记录之下,从而使磁盘上的文件膨胀,并随着重复记录被重新解析而减慢以后在 Excel 中打开的速度;在循环之外构建一次每个样式,然后在需要时多次引用其索引

流、临时目录以及围绕这一切的批处理循环

所有这些都不需要文件系统;这两个接口在其 IO 表面都携带了 TStream 重载,其中包括 OpenSaveAsSaveAsCSVSaveAsHTMLSaveAsODS,因此批处理工作线程可以直接渲染进绑定到 blob 存储或 HTTP 响应的 TMemoryStream 中,而无需触及磁盘;有一个需要记住的尖锐边缘:SaveAs(Stream) 从流的当前位置写入,之后不会回滚,因此在将流递交给传递它的任何内容前,请自行设置 Position := 0,否则使用者将读取到 0 字节;XLS 接口添加了两个自己的控制旋钮:SetTempDir 将 BIFF 写入器的临时文件指向具有容纳空间 and IO 余量的卷,这在默认临时路径位于狭窄的系统盘的服务器上很重要;UseSharedFormulas 将重复的公式体折叠进共享组,这对于单列公式复制的经典报表形状是一项实在的空间缩减

批处理循环本身故意保持单调:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // fresh instance: no state bleed
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // one bad input must not kill the batch
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

每个文件分配一个新鲜的工作簿实例耗时微乎其微,并消除了一整类跨文件污染的 bug:文件 17 中的样式、已定义名称和文档属性没有路径泄露到文件 18 中;在失败的 Open 上跳过并继续同样发挥了作用,因为在 600 个文件的批处理中,一个被截断的上传只需花费您单行日志,而不是丢掉其余的运行;同样值得指出的是 CSV 路径故意没有做的事:SaveAsCSV 将公式写成字面文本,且从不计算它们,因此如果使用者期望计算后的数字,转换批处理必须首先在相关单元格上运行 Calculate,或者从已携带先前计算缓存结果的工作簿开始

并发模型:每个线程一个工作簿

这两个接口的对象都不是线程安全的,设计上也从未这样宣称过;因为实例之间没有共享的全局状态,所以缩放规则仅仅是“每个工作线程一个工作簿”,不能在线程之间共享工作簿;一个拥有各自 TXLSXWorkbook 的 N 个工作线程池,在内存达到上限前,会接近线性地缩放,而该上限是可以量化出来的:最大的并发单元格模型乘以工作线程数,加上 StreamingWrite 所平移拉平的任何保存时开销;当队列变深时,在作业队列而不是在写入器内部应用反压(Back-pressure);半写完工作簿的饥饿线程无法产生任何有用的东西,而等待几秒钟以获得空闲工作线程的作业能完好无损地完成

对于更广泛的调优图景,包括共享公式、读取端图形跳过以及 XLS 特有的杠杆,请参见大型工作簿性能指南;行数据直接来自查询的批处理作业在Delphi 报表的数据库导出模式中进行了单独介绍

HotXLS 作为原生 Object Pascal 编译进您的 Delphi 或 C++Builder 服务中,没有外部依赖;版本和许可位于 HotXLS Component 产品页面