技術文章

HotXLS 串流寫入:Delphi 伺服器批次作業

假設一個每夜執行的 Delphi 服務為每個客戶產生一個 XLSX,幾百個檔案,其中一些寬達四十萬列。對它做效能分析,令人意外的很少是填入儲存格的迴圈。而是 SaveAs 呼叫。使用預設寫入器時,每個工作表會先序列化為單一記憶體內 XML 字串,再將該字串壓縮進 OOXML zip,而對於一個寬的工作表,暫存字串可能讓它所源出的儲存格模型相形見絀。所以一個舒適地建構資料並保持在 800 MB 的工作,會在儲存時暴增超過 2 GB 容器限制,而 OOM killer 在凌晨三點無人看管時提交錯誤報告。HotXLS,losLab 的原生試算表函式庫,有一個屬性正是針對那個尖峰:StreamingWrite。圍繞它還有兩個進一步的槓桿,決定一個批次工作執行緒是否能留在記憶體與時間預算內,即逐列寫入回呼和樣式集區在緊密迴圈中的行為方式

預設儲存路徑緩衝了什麼,以及 StreamingWrite 改變了什麼

預設 XLSX 寫入器偏好簡單。它完整呈現工作表 XML,再將完成的字串交給 zip 壓縮器。對絕大多數活頁簿來說這是正確的取捨,整個工作表的 XML 只佔幾 MB。但當單一工作表的序列化形式達到數百 MB 時就不正確了。試算表 XML 很冗長:每個數值儲存格花費數十個字元的標記,容納所有這些的字串必須是連續的。在記憶體圖上,特徵很難錯過。填入列時的一段長平坦期,然後在 SaveAs 期間一個尖銳的三角形尖峰,然後 zip 刷新後的崩塌

設定 Book.StreamingWrite := True 會將 SaveAs 切換為一個工作表寫入器,在產生時將工作表 XML 直接發出到 zip 串流中。中間字串永遠不會被配置,三角形尖峰被壓平到噪音中

要精確了解這實際上為你帶來什麼,因為過度推銷會導致錯誤的容量規劃。該旗標只改變儲存路徑。建構活頁簿仍然配置完整的記憶體內儲存格模型,所以填入階段的平坦期與之前完全一樣高。消失的是過去在儲存時疊加在那個平坦期之上的序列化尖峰,而對於一個填入四十萬列的工作,那個尖峰通常是符合記憶體預算與超出之間的全部差異。該屬性預設為 False 以保留歷史行為,所以選擇加入是你有意寫的一行明確程式碼

HotXLS 批次記憶體隨時間變化(Delphi):預設 SaveAs 在填資料高原之上再疊出暫態的工作表 XML 字串尖峰;Book.StreamingWrite := True 則讓整個儲存過程曲線保持平坦
兩種做法的填充高原一模一樣,因為儲存格模型終究在記憶體裡建;StreamingWrite 消除的只是儲存時的序列化尖峰

開啟旗標的大量匯出

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // 集區索引,以 0 為基準
  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 為基準
  end;
  Book.StreamingWrite := True;   // 將工作表 XML 直接串流進 zip
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] 按需建立儲存格,保持迴圈主體整潔。兩個網格上限值得記住:1,048,576 列和 16,384 欄,以 XlsxMaxRowXlsxMaxCol 公開。超過列上限的資料來源必須在你自己的程式碼中跨工作表分割。下游什麼都不會注意到溢出或為你修正,檔案只是在限制處被截斷

無逐儲存格 Variant 負擔的列填入

每次 Cells[R, C].Value 指派都付出一次儲存格查詢和 Variant 轉換。一萬列時沒人注意。一百萬列每列二十欄時,那個每次呼叫的負擔成為填入階段的主導成本,效能分析器會直接指向它。批次介面讓你一次交給寫入器一整列。WriteRows 驅動一個回呼,每次呼叫提供一列:

HotXLS 在 Delphi 的 WriteRows 回呼流程:查詢游標每次呼叫交出一列給 FillRow 回呼,回呼填入 Variant 值陣列或擲出 Skip 與 Cancel,工作表逐列填滿
WriteRows 把迴圈交給 HotXLS,回呼每次供應一列 variant 陣列;Skip 是逐列的退出權,Cancel 是乾淨俐落的全程停止
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;              // 資料來源已耗盡:乾淨地停止
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// 填入第 2..100001 列、A..C 欄,資料來自讀取器
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

Cancel 旗標是將固定列範圍變成「最多 N 列」的關鍵,當列數來自一個你尚未執行完畢的查詢時,這是自然的形狀。Skip 是更輕的觸碰:它讓單列保持空白而不停止執行。除了填入儲存格,回呼結果是存放那些否則會以笨拙方式附加到填入迴圈上的營運關注的好地方。每千列跳動一次的進度計數器、從作業排程器輪詢的取消權杖、對來源資料庫讀取的速率限制器:全部都在一個地方,而不是穿過儲存格寫入程式碼。在讀取端,ForEachRowForEachCell 鏡像相同的模式,當批次作業同時消費和產生大檔案時這很重要

樣式集區獎勵提前提取

XLSX 樣式模型是一組共用集區。Fonts.AddFills.AddSolidBorders.Add 都傳回 0 基底的集區索引,而儲存格透過在 FontIndex 中儲存該索引加一來參照字型,其中零保留給活頁簿預設值。那個 +1 就在上面的大量範例中。忘了它,儲存格就悄悄取得錯誤的樣式,因為樣式集區索引中的 off-by-one 仍然是有效索引,什麼都不會引發

隨之而來的紀律是在列迴圈之前建立每個樣式物件,並在迴圈內參照其索引。Fonts.Add 去重複相同的定義,所以每列呼叫它一次只是浪費 CPU。Alignments.Add 是陷阱,因為它在每次呼叫時傳回一個新項目。在一個十萬列的迴圈中,這會將 styles.xml 埋在十萬個重複的對齊記錄下,使磁碟上的檔案膨脹,並在 Excel 中每次後續開啟時因重複項目被重新解析而變慢。在迴圈外建立每個樣式一次,然後根據需要多次參照其索引

串流、暫存目錄,以及圍繞這一切的批次迴圈

這些都不需要檔案系統。兩個 facade 在其 IO 介面上都帶有 TStream 多載,OpenSaveAsSaveAsCSVSaveAsHTMLSaveAsODS 都在其中,所以一個批次工作者可以直接呈現到綁定至 blob 儲存或 HTTP 回應的 TMemoryStream 而完全不碰磁碟。有一個鋒利的邊緣要記住。SaveAs(Stream) 從串流的目前位置寫入,事後不倒轉,所以在將串流交給傳遞它的任何東西之前,你自己要設定 Position := 0,否則消費者讀取零位元組。XLS facade 增加了它自己的兩個旋鈕。SetTempDir 將 BIFF 寫入器的暫存檔指向有足夠空間和 IO 餘裕來吸收它們的磁碟區,這在預設暫存路徑位於狹窄系統磁碟上的伺服器上很重要。UseSharedFormulas 將重複的公式主體摺疊成共用群組,對於一個公式被複製到整個欄的經典報表形狀來說,這是實際的檔案大小縮減

批次迴圈本身刻意保持無聊:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // 全新執行個體:不會有狀態殘留
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // 單一壞輸入絕不能弄垮整批
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

每個檔案一個新鮮的活頁簿實例花費微秒,並移除一整類跨檔案污染錯誤:檔案 17 的樣式、已定義名稱和文件屬性沒有路徑洩漏到檔案 18。在 Open 失敗時的跳過並繼續同樣有價值,因為六百個檔案批次中的一個截斷上傳應該讓你花一條日誌行而不是其餘的執行。同樣值得標記的是 CSV 那段刻意不做的事。SaveAsCSV 將公式以文字字面值寫出且從不求值,所以一個消費者期望計算值的轉換批次必須先對相關儲存格執行 Calculate,或從已帶有先前計算快取結果的活頁簿開始

並行模型:每執行緒一個活頁簿

兩個 facade 的物件都不是執行緒安全,設計也從未假裝如此。因為實例之間沒有共用全域狀態,擴展規則就是每個工作者執行緒一個活頁簿,不在執行緒之間共用活頁簿。N 個工作者的池,每個擁有自己的 TXLSXWorkbook,接近線性擴展直到記憶體成為上限,而那個上限是你可以量化的事物:最大的並行儲存格模型乘以工作者數量,加上 StreamingWrite 已經壓平的任何儲存時負擔。當佇列很深時,在作業佇列施加背壓而不是在寫入器內部。一個已半寫入活頁簿的飢餓執行緒沒有產出任何有用的東西,而一個等待了幾秒鐘取得空閒工作者的作業則完整完成

HotXLS 的 Delphi 伺服器批次工作並行模型:工作佇列餵給各自擁有私有 TXLSXWorkbook 執行個體的工作執行緒,在佇列施加背壓,記憶體是擴充上限
活頁簿執行個體不共享全域狀態,每執行緒一本活頁簿因此可擴展,直到並發的儲存格模型撞上記憶體天花板

關於更廣泛的調校全貌,包括共用公式、讀取端的圖形跳過,以及 XLS 特有的槓桿,請見 大型活頁簿效能指南。列直接來自查詢的批次作業在 Delphi 報表的資料庫匯出模式 中單獨涵蓋

HotXLS 以原生 Object Pascal 編譯到你的 Delphi 或 C++Builder 服務中,無外部相依;版本與授權在 HotXLS Delphi Component 產品頁