技術文章

在 Delphi 中以固定記憶體寫出百萬列 XLSX

一支報表工作跑了一年都好好的。它建立一份活頁簿,把查詢回傳的東西填進工作表,然後存檔。接著,一位手上有五年歷史資料的客戶要求完整匯出,列數跨過一百萬,而這個行程早在檔案落到磁碟之前就以記憶體不足的錯誤死掉了。程式碼本身沒有錯。它把整份活頁簿抱在 RAM 裡,好在最後一刻序列化出去,而它需要的記憶體,是跟著它被要求寫出的列數一起等步成長的

解法不是換一台更大的機器,而是換一種寫入模型。HotXLS 的串流直寫器會在列陸續到來時逐步輸出 OOXML 套件,所以它用掉的記憶體不取決於您寫了多少列。它是串流讀取器在寫入端的對應物:讀取器走過一張巨大的工作表而不建立儲存格樹,寫入器則產生一張這樣的工作表,同樣不建立儲存格樹

對照示意圖:Delphi 中緩衝式的 TXLSXWorkbook 存檔路徑會把每一列留在記憶體直到存檔,HotXLS 串流寫入器則在列到來時就把位元組交給 zip 輸出
緩衝模型會為每個儲存格保留一個活的物件直到最後存檔,所以尖峰堆積量隨列數成長。串流直寫器則把每個儲存格立刻輸出到 zip 串流,只留下固定的記帳資料

為什麼一般的存檔路徑會隨資料成長

一般的 TXLSXWorkbook 路徑會先建起一套完整的物件模型。每個儲存格連同它的值、型別與樣式參照,都以物件的形式活在記憶體裡,直到您呼叫存檔,那時整棵樹才被序列化進套件。當您想讀一張工作表、編輯它、重新計算再寫回去時,這個模型是對的,因為編輯要的正是對任意儲存格的隨機存取。但當您只是把列往一個方向倒進去、從不回頭看時,它就是錯的模型,因為您付出了讓每一列常駐的代價,卻換不到任何好處。一百萬列的物件就是一百萬列的物件,不管您有沒有再回去看它們

串流寫入器把那棵樹拿掉了。儲存格一被寫入,就變成工作表組件中的位元組,而那些位元組會被交給 zip 輸出。唯一會成長的緩衝是工作表串流,而且它是在輸出端成長,不是以活的 Delphi 物件形式堆在堆積上。常駐的只有固定的一小撮記帳資料:工作表名稱、幾個旗標、目前的列號、一個儲存格計數器。這一組東西從第一列到第一千萬列都不會變

共用字串表是陷阱,行內字串是出路

多數串流式 XLSX 寫入器都做得不錯,直到它們碰上文字。OOXML 格式通常把字串存在共用字串表中:每個不重複的字串只寫進另一個組件一次,而每個裝著該字串的儲存格帶的是指向該表的索引,而不是文字本身。對於滿是重複標籤的檔案,這是個很好的空間最佳化,也是標準存檔路徑採用的預設值。但對串流寫入器來說,問題非常殘酷。要去重,這張表就得在整趟工作期間常駐,因為任何還沒寫到的列都可能重複某個早已寫出之列裡的字串,而唯有一份記錄所有見過字串的完整記憶體對映,才能指派正確的索引。於是,串流寫入器唯一串流不了的結構,恰恰就是本來該讓檔案變小的那個結構。文字量大的資料,會打敗您原本要的串流

直寫器則完全繞過那張表。字串以行內方式寫出,成為 t="inlineStr" 儲存格,文字直接以 <is><t> 元素坐在儲存格裡。沒有表要累積,也沒有見過字串的對映要保留,所以文字欄花的記憶體不比數值欄多。這筆交換很明確,值得說白。行內字串會在文字出現的每個地方重複同一段文字,所以含有大量相同標籤的檔案,在磁碟上會比共用字串版本更大。您用檔案大小買到了固定的記憶體用量。對一趟單向匯出而言,這是交換中划算的那一邊,而且 zip 壓縮在輸出途中本來就吸收掉了大部分重複

對照示意圖:串流式 Delphi 寫入器必須常駐的 OOXML 共用字串表,以及 HotXLS 直接寫進 zip 串流的行內字串儲存格
共用字串逼得串流寫入器必須抱著去重對映直到最後一列,所以文字量大的資料會打敗串流。行內的 t="inlineStr" 儲存格徹底移除那張表,以一些檔案大小換取固定的記憶體用量

樣式表最後才出現,且只帶一種日期格式

樣式帶來的張力和字串一樣。活頁簿透過樣式組件參照它的格式設定,而串流寫入器沒辦法讓一份不斷成長的樣式調色盤,跟上早已排出去的儲存格。直寫器的回應方式,是把樣式表維持得又小又固定,並在關閉時才輸出它,而不是一開頭就寫。一種預設儲存格格式涵蓋普通儲存格。一種日期數值格式涵蓋日期,以 yyyy-mm-dd 的格式碼登錄在儲存格格式清單中一個已知的位置上

那個日期格式,正是 WriteDateTime 要獨立成一個呼叫的原因。Excel 沒有原生的日期型別;日期是一個穿著日期格式的數字。WriteDateTime 把值寫成單純的序號,並為儲存格標上那唯一的日期樣式,讓試算表把它呈現成日期,而不是一個五位數的整數。它寫出的序號對往返很重要。它在 1900 日期系統下直接儲存 TDateTime 值,這與一般 TXLSXWorkbook 存檔路徑採用的慣例相同。由於兩條路徑對序號的認知一致,串流寫入器產出的檔案,用 HotXLS 讀取器讀回來、或用 Excel 開啟時,日期都與您的本意相符,寫入端與讀取端之間不會出現差一天或紀元錯亂的意外

順序是強制的,因為位元組已經出去了

串流是用一條您必須遵守的規則,換來它的記憶體特性。輸出邊走邊排出,不能回頭修改,所以每樣東西都必須按它在檔案中出現的順序寫入。在一列之內,儲存格要按欄號遞增排列。在一張工作表之內,列要遞增排列。沒有任何緩衝能讓寫入器事後幫您把儲存格排序,因為您片刻前關掉的那一列,早已是 zip 串流中的位元組,再也搆不著了。在同一列中先給它第 5 欄再給第 2 欄,輸出就是壞的,因為寫入器就是照您給的順序原樣輸出您給的東西

列的 API 為常見情況準備了一點便利。AddRow 接受以 1 為起點的列索引,但傳 0 表示取用前一列之後的下一列,所以循序填資料時不必自己追蹤並傳入一個遞增的計數器。每次 AddRow 都會關掉它前面那一列,每次 AddSheet 都會關掉它前面那張工作表,所以您從來不需要明確結束一列或一張工作表。您開始下一個,寫入器就替您把開著的結構收尾

Delphi 版 HotXLS 串流寫入器中強制的列與欄遞增順序示意圖,其中已關閉的列早已是 zip 串流中的位元組
串流輸出無法回頭修改,所以每列之內儲存格必須依欄遞增,每張工作表之內列必須遞增。AddRow(0) 會自動取用下一列,而每次呼叫都會關掉它前面的結構

逸出處理發生在文字進入 XML 的地方

您寫入的任何文字都會變成 XML 文件的一部分,所以那五個預先定義的 XML 實體必須被逸出,否則只要某個值含有 ampersand 或角括號,套件當下就無效了。寫入器會替您在行內字串文字與公式文字這兩處逸出 &、<、>、" 與 ',那正是呼叫端提供的字元會落進標記中的地方。您傳入原始的 WideString,寫入器負責把它變安全。像 Smith & Co <Ltd> 這樣的產品名稱,或是一條參照到帶引號工作表名稱的公式,出來都是良構的 XML,您這邊完全不必自行逸出

生命週期,以及為什麼 Destroy 仍會關閉套件

把套件收尾,才會寫出活頁簿組件、樣式組件、內容型別與關聯組件,最後還有 zip 的中央目錄。這些工作發生在 Close 裡。一份從未關閉的套件是一個不完整的 zip,沒有任何試算表程式打得開,所以關閉不是可有可無的善後,它是讓檔案有效的那個步驟。為了防範在錯誤路徑上忘了呼叫 Close,若套件仍開著,Destroy 會盡力執行一次關閉,所以就算例外跳過了明確的呼叫,釋放寫入器也不會漏掉底層的 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');

    // 標題列,依欄號遞增寫出
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // 資料列;傳 0 給 AddRow 會自動取用下一列
    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;                       // 為套件收尾
  finally
    W.Free;
  end;
end;

要加第二張工作表,只要在繼續之前再呼叫一次 AddSheet,寫入器會在開啟第二張時關掉第一張。布林旗標用 WriteBoolean,它寫出的是具型別的布林儲存格,而不是文字 "True"。若您想確認檔案健全且能往返,屬性 CellCount 會回報寫出了多少個儲存格,而用串流讀取器把結果讀回來,應該會回報同樣的總數

  // 資料工作表之後,再來一張具型別旗標的工作表
  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 回應或記憶體串流,不必在磁碟上留下暫存檔。寫入器並不擁有您傳入的串流,所以它的生命週期仍由您掌控

當這項工作是一個依需求建置活頁簿的伺服器端點時,伺服器與批次工作的串流寫入一文中的模式,示範了如何把它接進請求處理常式與排程匯出。當問題轉為超大活頁簿在讀與寫兩端的整體代價時,Delphi 中的大型活頁簿效能涵蓋了時間與記憶體究竟花在哪裡。串流直寫器隨適用於 Delphi 與 C++Builder 的 HotXLS Delphi 元件出貨,與本部落格其他文章介紹的完整讀取、編輯與存檔 API 並肩