技術記事

Delphiサーバーバッチ向けHotXLSストリーミング書き込み

夜間に動くDelphiのサービスが顧客ごとにXLSXを1つずつ、合計で数百ファイル生成し、そのいくつかは40万行に及ぶとします。プロファイルを取ってみると、意外な犯人はセルを埋めるループであることはめったにありません。犯人はSaveAsの呼び出しです。既定のライターでは、各ワークシートはOOXMLのzipに圧縮される前に、いったん1本のメモリ上のXML文字列へシリアライズされます。横に広いシートでは、その一時的な文字列が、元になったセルモデルよりはるかに大きくなることがあります。その結果、データ構築の段階では余裕をもって800 MBに収まっていたジョブが、保存中に2 GBのコンテナ上限を突き抜け、誰も見ていない午前3時にOOM killerが不具合報告を書き残すことになります。DelphiとC++Builder向けのlosLab製ネイティブスプレッドシートライブラリであるHotXLSには、まさにそのスパイクを狙ったプロパティがあります。StreamingWriteです。その周囲には、バッチワーカーがメモリと時間の予算内に収まるかどうかを左右するレバーがさらに2つあります。行単位の書き込みコールバックと、きついループの中でスタイルプールがどう振る舞うかです

既定の保存経路が抱え込むもの、StreamingWriteが変えるもの

既定のXLSXライターは単純さを優先します。ワークシートのXMLを完全に生成し、できあがった文字列をzip圧縮器に渡します。シート全体のXMLが数メガバイトに収まる大多数のワークブックにとっては、これが正しい取引です。正しくなくなるのは、1枚のシートのシリアライズ結果が数百メガバイトに達するときです。スプレッドシートのXMLは冗長で、数値セル1つにつき数十文字のマークアップがかかり、そのすべてを保持する文字列は連続領域でなければなりません。メモリのグラフに現れる特徴は見逃しようがありません。行を埋めている間は長く平らな台地が続き、SaveAsの間に鋭い三角形のスパイクが立ち、zipが書き出されると一気に崩れます

Book.StreamingWrite := Trueを設定すると、SaveAsはシートのXMLを生成しながらそのままzipストリームへ書き出すワークシートライターに切り替わります。中間の文字列は一度も確保されず、三角形のスパイクはノイズの中に埋もれるまで平らになります

これが実際に何をもたらすのかは、正確に押さえておくべきです。過大に見積もると容量計画を誤ります。このフラグが変えるのは保存経路だけです。ワークブックの構築では相変わらずメモリ上に完全なセルモデルを確保するため、データを埋める段階の台地の高さは以前とまったく同じです。消えるのは、保存時にその台地の上へ積み上がっていたシリアライズのスパイクです。40万行を埋めるジョブでは、そのスパイクこそがメモリ予算に収まるか超過するかを分ける差になることがよくあります。このプロパティは従来の挙動を保つため既定でFalseになっており、意図して1行書くことで初めて有効になります

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]はセルを必要になった時点で生成するので、ループの本体を簡潔に保てます。グリッドの上限は2つ、覚えておく価値があります。1,048,576行と16,384列で、それぞれXlsxMaxRowXlsxMaxColとして公開されています。行の上限を超えるデータフィードは、自分のコードでシートに分割する必要があります。下流の誰かが超過に気づいて直してくれることはなく、ファイルは単に上限で切り詰められた状態になります

セルごとのVariantオーバーヘッドなしで行を埋める

Cells[R, C].Valueへの代入は、いずれもセルの検索とVariantの変換を伴います。1万行では誰も気づきません。20列を100万行分となると、その呼び出しごとのオーバーヘッドがフィル段階の支配的なコストになり、プロファイラはまっすぐそこを指し示します。バッチ用のインターフェイスを使えば、代わりに1行まるごとをライターに渡せます。WriteRowsは、呼び出しごとに1行を供給するコールバックを駆動します:

Delphi における HotXLS WriteRows コールバックの流れ。クエリのカーソルが 1 回の呼び出しにつき 1 行を FillRow コールバックへ渡し、コールバックは値のバリアント配列を埋めるか Skip や Cancel を立て、ワークシートが 1 行ずつ埋まっていく
WriteRows はループを HotXLS に任せ、コールバックは呼び出しごとにバリアント配列の 1 行を供給します。行単位の見送りが 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はもっと軽い手当てで、実行を止めずに個々の行を空のまま残します。セルを埋めること以外にも、このコールバックは、そうでなければフィルループに不格好に取り付けられる運用上の関心事を置く場所として具合がよいことが分かります。1000行ごとに進む進捗カウンター、ジョブスケジューラから取得するキャンセルトークン、ソースデータベースからの読み取りに対するレート制限といったものが、セル書き込みのコードに縫い込まれるのではなく、1か所にまとまります。読み取り側ではForEachRowForEachCellが同じパターンを鏡写しにしており、バッチジョブが大きなファイルを読みも書きもする場合に効いてきます

スタイルプールは巻き上げに報いる

XLSXのスタイルモデルは共有プールの集まりです。Fonts.AddFills.AddSolidBorders.Addはいずれも0起点のプールインデックスを返し、セルはそのインデックスに1を足した値をFontIndexに格納してフォントを参照します。0はワークブックの既定用に予約されています。この+1は、上の一括処理の例にそのまま現れています。忘れるとセルは黙って別のスタイルを拾います。スタイルプールのインデックスが1つずれていても、それは有効なインデックスであり、何の例外も発生しないからです

そこから導かれる作法は、スタイルオブジェクトをすべて行ループの前に作り、ループの中ではそのインデックスを参照することです。Fonts.Addは同一の定義を重複排除するので、行ごとに呼んでもCPUを無駄にするだけで済みます。落とし穴はAlignments.Addで、こちらは呼び出しのたびに新しいエントリを返します。10万行のループの中で呼べば、styles.xmlは10万件の重複した配置レコードに埋もれ、ディスク上のファイルが膨らみ、以降Excelで開くたびに重複の解析でわずかずつ遅くなります。スタイルはループの外で1度だけ作り、そのインデックスを必要なだけ参照してください

ストリーム、一時ディレクトリ、そしてそれらを囲むバッチループ

ここまでの話にファイルシステムは必須ではありません。どちらのファサードもIO面全体にTStreamのオーバーロードを備えており、OpenSaveAsSaveAsCSVSaveAsHTMLSaveAsODSなどが含まれます。そのためバッチワーカーは、ディスクに一切触れずに、blobストレージ行きやHTTPレスポンス用のTMemoryStreamへ直接書き出せます。1つだけ鋭い角があります。SaveAs(Stream)はストリームの現在位置から書き込み、書き終えても巻き戻しません。配信を担う処理へ渡す前に自分でPosition := 0を設定してください。さもないと受け手は0バイトしか読めません。XLSのファサードには独自のつまみが2つ加わります。SetTempDirはBIFFライターの一時ファイルを、それを受け止めるだけの容量とIOの余裕があるボリュームへ向けます。既定の一時パスが手狭なシステムディスク上にあるサーバーでは重要です。UseSharedFormulasは繰り返し現れる数式の本体を共有グループにまとめ、1つの数式を列全体にコピーする典型的な帳票の形では実際にサイズを削減します

バッチループそのものは、意図して退屈なままにしておきます:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // 新しいインスタンス: 状態が漏れない
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // 不正な入力1件でバッチを止めてはならない
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

ファイルごとに新しいワークブックのインスタンスを作るコストはマイクロ秒単位でありながら、ファイルをまたぐ汚染バグの一群をまるごと取り除きます。17番目のファイルのスタイル、定義された名前、ドキュメントプロパティが18番目に漏れ出す経路はありません。Openが失敗したときに読み飛ばして続行することも同じくらい価値があります。600ファイルのバッチの中に途中で切れたアップロードが1件あったとしても、失うのはログ1行であって、残りの実行全体ではないからです。CSVの経路が意図的にやらないことも指摘しておく価値があります。SaveAsCSVは数式をそのままの文字列として書き出し、評価は一切行いません。したがって、計算済みの数値を期待する利用者向けの変換バッチでは、事前に該当セルに対してCalculateを実行するか、以前の計算によるキャッシュ値をすでに持つワークブックから始める必要があります

並行実行モデル:スレッドごとに1つのワークブック

どちらのファサードのオブジェクトもスレッドセーフではありませんし、設計上そう装ったこともありません。インスタンス間で共有されるグローバル状態がないため、スケーリングの規則は単純です。ワーカースレッド1本につきワークブック1つとし、ワークブックをスレッド間で共有しないことです。それぞれが自分のTXLSXWorkbookを持つN個のワーカーのプールは、メモリが天井になるまでほぼ線形にスケールします。しかもその天井には具体的な数値を与えられます。同時に存在する最大のセルモデルにワーカー数を掛け、StreamingWriteが平らにしきれなかった保存時のオーバーヘッドを足したものです。キューが深く積み上がるときは、ライターの内側ではなくジョブキューの側で背圧をかけてください。ワークブックを半分書きかけたまま飢えたスレッドは何も有用なものを生みませんが、空きワーカーを数秒待ったジョブは無傷で完了します

Delphi のサーバーバッチジョブ向け HotXLS 並行実行モデル。ジョブキューがワーカースレッドへ供給し、各スレッドが専用の TXLSXWorkbook インスタンスを持ち、背圧はキュー側でかけ、スケーリングの天井はメモリ
ワークブックのインスタンスはグローバル状態を共有しないため、スレッドごとに1つのワークブックという構成は、同時に存在するセルモデルがメモリの天井に届くまでスケールします

共有数式、読み取り側での画像スキップ、XLS固有のレバーを含む、より広いチューニングの全体像については大きなワークブックの性能ガイドを参照してください。行がクエリから直接得られるバッチジョブについては、Delphi帳票向けのデータベースエクスポートのパターンで別途扱っています

HotXLSは外部依存のないネイティブなObject Pascalとして、お使いのDelphiまたはC++Builderのサービスにコンパイルされます。エディションとライセンスについてはHotXLS Delphi Component の製品ページをご覧ください