XLSXストリーミング書き込みで正確性とスループットを分ける
目次
DEV Communityに出ていた「Splitting Correctness from Throughput: A Hybrid Approach to XLSX Streaming Writes」が面白かった。
XLSXの書き込みを、OOXMLとして壊れないことと、大量セルを速く吐くことに分ける話だ。
XLSXはZIPの中にXMLを詰めた形式なので、行とセルのXMLだけなら自前で書ける。
でもリレーション、content types、名前空間、テーマ、drawing relsまで手で組むと、Excelで開いたときに修復ダイアログが出るファイルを作りやすい。
原典の提案はそこで割り切っている。
Apache POIに正しいXLSXの骨格を作らせ、反復量が爆発する <sheetData> だけを StringBuilder でストリーミング生成する。
XLSXの遅さはセルのオブジェクト生成に寄る
Apache POIの XSSFWorkbook は、セル、文字列、スタイル参照をオブジェクトとして扱える。
正しさはPOIが面倒を見るが、ワークブック全体をメモリ上に持つので、大きい表では重くなる。
SXSSFWorkbook はPOIのストリーミング書き込みAPIだ。
公式ドキュメントでも、一定数の行だけをメモリに残し、古い行をflushすることで巨大なスプレッドシートを書けると説明されている。
ただしセルごとのAPIはPOIのモデルに乗ったままなので、Cell や RichTextString の生成コストは残る。
原典のベンチマークは100K行、混在型、shared string tableあり、JMH測定。
数字だけ見るとこうなる。
| 手法 | 時間 | メモリ |
|---|---|---|
XSSFWorkbook via Jackson layer | 334 ms | 258 MB |
SXSSFWorkbook direct | 283 ms | 207 MB |
| scaffold-based hybrid | 150 ms | 191 MB |
SXSSFWorkbook 比で書き込み時間47%削減。
メモリ差は劇的ではないが、セルごとのラッパーオブジェクトを作らないぶん下がっている。
ここで速くなっているのは、ZIPやXML全体を魔法のように高速化したからではない。
大量に繰り返すセル生成だけを、POIのオブジェクトモデルから外したからだ。
M1 Maxで実測してみる
数字を信じる前に、手元で再現するか確認した。
scndry/jackson-dataformat-spreadsheet v1.4.1のリポジトリ付属 WriteBenchmark をそのままJMHで回す。
4列(name/quantity/price/description)混在型、100K行、warmup 2 iter / measurement 3 iter / fork 1、JDK 21(Homebrew openjdk 21.0.11)、M1 Max。
| Benchmark | 時間 (ms/op) | 中身 |
|---|---|---|
jacksonSpreadsheet | 253 | scaffold-hybrid(デフォルトの SpreadsheetMapper) |
fastExcel | 268 | 別系統の高速XLSXライブラリ |
poiSXSSF | 437 | SXSSFWorkbook を直接叩く |
jacksonSpreadsheetPOI | 501 | hybridの仕組みを使わずPOI User Modelに乗せた版 |
fesod | 532 | 参考値 |
絶対値は原典(hybrid 150ms / SXSSF 283ms)と合わない。
JMH iter数を絞ったぶん精度が落ちているし、JVMやハードも違う。
ただし相対比は原典が hybrid/SXSSF = 53%、M1 Maxでは 58%。
「sheetData を自前で吐けばSXSSFの半分強の時間で済む」という主張は手元でも崩れなかった。
逆に言うと、平均的なJVMで100K行を283ms、最適化してもらってさらに半分、というオーダー感はマシンが変わっても大きく動かないということでもある。
同じレポジトリに MemoryBenchmark や SharedStringsBenchmark も入っているので、メモリ側を詰めたい場合はそちらも回せる。
POIに骨格を作らせてsheetDataだけ差し替える
流れは単純だ。
flowchart TD
A["POIで空のXLSXを生成"] --> B["sheet1.xmlを取得"]
B --> C["sheetDataの前後に分割"]
C --> D["headをコピー"]
D --> E["rowとcellを自前で逐次生成"]
E --> F["tailをコピー"]
F --> G["他のzip entryをPOI出力からコピー"]
sheet1.xml の中では、行データの本体が <sheetData> に入る。
ECMA-376上、<worksheet> の子要素として <sheetData> は決まった位置に1回出てくるため、そこを境界にできる、というのが原典の主張だ。
POIが作ったXMLの前半と後半はそのまま使う。
差し替えるのは <sheetData> の中身だけ。
セルの書き込みは、たとえば文字列なら shared string table のindexを取って、<c r="A1" t="s"><v>0</v></c> に近い形のXMLを直接appendする。
この分担がうまい。
| 領域 | 担当 | 理由 |
|---|---|---|
| workbook rels | POI | 互換性の地雷が多い |
| styles.xml | POI | スタイルIDと参照の整合性が必要 |
| theme / drawings | POI | Excel固有の周辺XMLを壊しやすい |
| sheetData | 自前 | 行とセルの反復で、件数に比例して支配的になる |
| sharedStrings.xml | 自前ストリーミング | 一意文字列の表として別に流せる |
OOXMLを全部手で書くわけではない。
逆に、POIのUser Modelを最後まで使うわけでもない。
壊れやすい固定部分はPOI、量が支配する反復部分は自前、という境界をXMLの構造に合わせて切っている。
sharedStringsとstyle tableが境界になる
この手法で一番見たいのは、sheetData の外へ依存が漏れていないかだ。
文字列セルは sharedStrings.xml を参照する。
原典の実装では、文字列をキャッシュしてindexを払い出し、sharedStrings.xml も独立してストリーミングする。
セル本体は軽くなるが、一意文字列が多すぎるデータでは shared string table 側のメモリやI/Oが効いてくる。
スタイルはさらに制約が強い。
原典でも、style tableはscaffold作成時点で固定されると書かれている。
行を書き始めてから新しいスタイルを増やすには styles.xml を出し直す必要があり、今回の実装では未対応だ。
この制約を飲みやすいのは、こういう出力だ。
| 向いている出力 | 理由 |
|---|---|
| 定型レポート | スタイルを先に決められる |
| 大量行のエクスポート | セル生成が支配的になる |
| JacksonのPOJO出力 | スキーマが先にある |
| BIや管理画面のCSV代替XLSX | レイアウトより行数が問題になる |
反対に、ユーザー操作に応じて途中でスタイル、結合セル、図形、コメントを増やす帳票ビルダーだと境界が崩れやすい。
POIに任せた固定XMLと、自前で吐くセルXMLのあいだで参照を合わせる作業が増える。
小さい帳票には向かない
scaffoldを作る固定コストがあるので、小さいXLSXでは得をしない。
数百行の帳票なら、POIのUser Modelや SXSSFWorkbook のままのほうが実装も検証も楽だ。
この手法が効くのは、セル数が多く、同じ形の行を延々と吐く場合だ。
たとえばWebアプリの管理画面から10万行以上の明細をXLSXで落とす、バッチで日次レポートを生成する、JavaサービスでJacksonのデータバインディングからスプレッドシートへ出す、といった場面。
測る数字も、平均応答時間だけでは足りない。
100K行で150msという原典の結果は強いが、実データでは一意文字列数、列数、スタイル数、ZIP圧縮、出力先ストレージ、HTTPレスポンスへの流し方で変わる。
特にWebダウンロードでは、XLSX生成が速くなっても、レスポンスをメモリに全部貯めてから返していたら別の場所で詰まる。
読み取り側の記事とは逆向きの話
このブログでは以前、MarkItDownでExcelをMarkdownに変換する話を書いた。
あちらはExcelやPDFをLLM向けのテキストへ読む側の話だった。
今回のXLSXストリーミング書き込みは逆向きで、構造化データをExcel互換のファイルとして外へ出す側の話になる。
信頼度スコアで文書抽出の人手確認を絞る記事とも、パイプラインの位置が違う。
文書抽出では「読めた値を業務データとして採用していいか」が問題だった。
XLSX書き込みでは、採用済みの業務データを、Excelが壊さず開ける形で大量に吐けるかが問題になる。
読み取りも書き込みも、Excelを単なる表として見るとつらい。
入力側ではセルの見た目、読み順、抽出根拠が絡む。
出力側ではOOXMLの周辺ファイル、参照、shared strings、style tableが絡む。
原典のハイブリッド手法は、「Excelファイルを全部理解して高速化する」ではなく、「壊れやすいところをPOIに残したまま、量が支配するところだけ外す」という設計だ。
ここが使いやすい。
XLSX出力が遅いからといって、いきなり全部を手書きXMLに寄せる前に、sheetData が本当にホットパスなのかを測る価値がある。
POIはJavaだけで動いている
Apache POIは純粋なJava実装だ。
ネイティブコードもCOM呼び出しもない。
XLSXの実体はZIPの中にXMLを詰めたOOXMLなので、JavaのZIP I/OとXMLパーサーだけでファイルを丸ごと組み立てられる。
Excelのプロセスを裏で起動して操作しているわけではない。
「ActiveXみたいなやつ」で想像するのは、WindowsのCOM/OLEオートメーションだろう。
VBAやC#から Excel.Application を生成して、走っているExcelプロセスにセルの値を流し込むやり方だ。
セルを書くたびにExcelが内部状態を更新し、保存するとExcelがファイルを書く。
正確だが遅い。Windows上にExcelが必要で、サーバーでExcelプロセスを立てるとライセンス問題も出る。
POIはこの方式を完全に避けている。
ECMA-376で定められたOOXMLの仕様に沿って、Java側でXMLのDOMやストリームを直接組む。
Excelがなくてもファイルを作れるし、Linuxでも動く。
今回の記事で扱ったハイブリッド手法も、POIで骨格を作って StringBuilder でXML文字列を吐くという構成で、全部Java側で完結している。
JavaScriptでXLSXを扱うライブラリも存在する。
SheetJSやExcelJSがそれに当たるが、POIとは別のプロジェクトだ。
原典のscndry/jackson-dataformat-spreadsheetはJava 8以上 + Apache POI 4.1.1以上で動く、純粋なJavaスタックになっている。
動くか壊れるか試すまでわからない例
sheetData の中だけを差し替える手法なので、sheetData の外に依存する機能はscaffoldとストリームの境界をまたぐ。
Excelが黙って直すのか、修復ダイアログを出すのか、壊れて開けないのかは機能ごとに違うし、Excel・Google Sheets・LibreOfficeで動作が変わる。
このセクションのうち、結合セル・XMLエスケープ・日付セルは手元でscndryの実装を実際に動かして出力XLSXのXMLを覗き、挙動を確認した。
それ以外(数式・ハイパーリンク・オートフィルタ・入力規則・テーブル・名前付き範囲・コメント)はOOXML仕様とソース観察からの推定で、Excel/Sheets/LibreOfficeを開いて目視確認するところまではやっていない。
数式セル
セルに <f>A1+B1</f> を書くのはsheetData内なのでストリームから直接吐ける。
ただし calcChain.xml はPOI側が管理するファイルで、空シートのscaffoldには計算チェーンが存在しない。
Excelは開いた時点で再計算するので結果が表示されることが多いが、Google Sheetsに読ませたときに同じ動作をするかは別の話だ。
さらに、INDIRECT、OFFSET、VOLATILEな関数を含む数式がcalcChain無しで正しく評価されるかは仕様上の保証がない。
ハイパーリンク
セルの表示テキストはsheetDataに入るが、リンク先の情報は <hyperlinks> 要素として </sheetData> の後ろに出る。
scaffoldは空シートなのでこのセクションが空だ。
tailをそのままコピーする実装だと、URLの情報が抜けて青い下線だけのセルになるか、あるいは何も起きない。
scaffold生成時にダミーのハイパーリンクを仕込んでおく手はあるが、行数もリンク先も事前に確定している必要がある。
オートフィルタ
<autoFilter ref="A1:E50000"> は </sheetData> の後に来る。
scaffoldを作る時点では最終行番号が確定していない。
行を全部書き終えてから ref を差し替えればいいが、tail部分を文字列置換するか、scaffold生成時にダミーの行数を入れて後から上書きすることになる。
「50000行ぐらいだろう」と雑に入れて実データが60000行だった場合、フィルタ範囲の外に行が溢れる。
Excelは範囲を自動拡張してくれることもあるが、してくれないこともある。
結合セルと条件付き書式
<mergeCells> も <conditionalFormatting> も sheetDataの後ろに置かれる。
どちらも参照範囲(ref や sqref)を持つので、ストリームで書く行の位置と整合させる必要がある。
「ヘッダー行だけ結合」のようにscaffold生成時に決まるケースは問題ない。
データ行の中で動的にセル結合が発生する場合は、ストリーム側でマージ範囲をリストに溜めておき、</sheetData> を書き終えたあとでtailにXMLを差し込めばいい。
ECMA-376では <worksheet> の子要素の順序が決まっているので、<mergeCells> を入れる位置はその順序に従って探す。
scndryの実装にも SSMLSheetWriter#mergeScopedColumns でマージ範囲を蓄積し、_appendMergeCellsIntoSuffix でtailに書き戻す処理が既に入っており、tailの後付け書き換えは特殊な機能ではなく定型的なテクニックとして使われている。
入力規則(ドロップダウン)
<dataValidation> 要素で指定するプルダウンリストや入力制限も sheetDataの外にある。
scaffoldで定義できるが、sqref に指定した範囲とストリーミングしたデータ行の実際の数が合わないと、一部の行にだけバリデーションがかかったり、存在しないセルを参照する範囲を指定したことになる。
Excelは大抵エラーにはしないが、意図どおりかはファイルを開かないとわからない。
テーブル定義
Excelの「テーブルとして書式設定」で作られる構造化テーブルは、sheetDataの外に独立したXMLファイルを持つ。
xl/tables/table1.xml に範囲、列名、スタイル情報が入り、sheet側の <tableParts> からrelationshipで参照される。
scaffoldの空シートにはテーブル定義がないので、ストリーミングで吐いたデータをExcel上でテーブル化するぶんには問題ない。
問題はscaffoldの段階でテーブルを定義しておきたい場合だ。
テーブルの ref 属性に A1:E100 と書いておいて、実データが200行だったとする。
Excelは開いたときにテーブル範囲を自動拡張することがあるが、しないこともある。
LibreOfficeはテーブル定義自体を無視して単なるセルデータとして表示することもある。
テーブルの列名とsheetData内のヘッダー行のテキストが食い違った場合も、修復ダイアログが出たり出なかったりする。
名前付き範囲と印刷範囲
workbook.xml 内の <definedNames> で宣言される名前付き範囲は、シートとは別のファイルに定義がある。
印刷範囲(_xlnm.Print_Area)やフィルタ範囲(_xlnm._FilterDatabase)も名前付き範囲の一種だ。
これらはscaffold生成時に固定される。
ストリーミングでデータ行が増えても workbook.xml 側の範囲定義は更新されない。
印刷範囲が A1:E100 なのに実データが500行あると、印刷時に先頭100行しか出ない。
ユーザーが印刷プレビューを見るまで気づかないので、データが欠損したように見えるバグになる。
Excelの修復ダイアログは出ないし、ファイルとしては壊れていない。
ただ「足りない」だけだ。
コメントとノート
Excel 365以降、セルに付くテキストは「コメント」(スレッド型、共有レビュー用)と「ノート」(旧来の黄色い付箋)の2系統がある。
旧来のコメントは xl/comments1.xml に格納され、sheet側の <legacyDrawing> からVMLファイル経由で参照される。
新しいスレッドコメントはさらに別のXMLで管理される。
どちらもsheetDataの外の話なので、scaffoldの空シートにはこれらのファイルがない。
ストリーミング側からコメントやノートを足す方法は、XMLを自前で追加してrelationshipも書き換えることになる。
VMLファイルの書式はOOXMLの中でも古い仕様の残り物で、Excelが受け入れるVMLと壊れるVMLの境界が実質的に文書化されていない。
POIですらVML周りのバグは定期的にissueが上がる場所だ。
セル値のXMLエスケープ漏れ
StringBuilder でセルXMLを自前で組むとき、一番静かに壊れるのがXMLエスケープだ。
文字列セルの値に & や < が含まれていると、エスケープしないままXMLに書き込んだ時点でXML自体が壊れる。
scndryの実装が実際に何をやっているか、各種文字を流して xl/sharedStrings.xml を覗いた。
| 入力 | 出力 |
|---|---|
Acme & Sons | Acme & Sons |
<script>alert(1)</script> | <script>alert(1)</script> |
"Quoted" | "Quoted" |
He said 'hi' | He said 'hi' |
5種すべてエスケープされている(テキストノード内では " と ' のエスケープは不要だが安全側に倒している)。
制御文字も流してみた。XML 1.0仕様で許可される 0x09(TAB)/ 0x0A(LF)/ 0x0D(CR)は素通り、その他の制御文字は 黙って削除 されていた。
| 入力 | 出力 |
|---|---|
0x09 (TAB) | 保持 |
0x0A (LF) | 保持 |
0x0D (CR) | 保持 |
0x01, 0x07, 0x08, 0x0B, 0x0C, 0x0E, 0x1F | すべて削除 |
0x7F (DEL) | 保持(XML 1.0的にはOK、ただし不可視) |
Excel本家がやる _x0001_ 形式(OOXMLの _xHHHH_ エスケープ)への変換は 行われていない。
CSVから読んだ顧客名やコメントに制御文字が紛れ込んでいた場合、エラーは出ないがデータは無言で1文字短くなる。
Excelで開いたとき同じ文字列がそのまま見えてほしい場合は、ストリーム書き込みの前段で _xHHHH_ 形式へ変換する層を自前で挟む必要がある。
POIのUser Modelを使っていればこのあたりは自動で処理されるが、StringBuilder で直接書く以上は自分の責任になる、という構図だ。
日付セルのスタイル依存
Excelの日付は、内部的には1900年1月1日からの経過日数を浮動小数点で持つ数値セルだ。
セルの見た目が 2026/05/01 になるかどうかは、セルに適用されたスタイルの numFmtId で決まる。
scndryの実装に Date 型のフィールドを流したところ、出力はこうなっていた。
<c r="C2" s="0" t="n"><v>25569.375</v></c>
<!-- 1970-01-01 09:00 JST -->
<c r="C4" s="0" t="n"><v>46143.75386275463</v></c>
<!-- 2026-05-01 18:05 JST -->
セルは t="n" (数値)として書かれている。
スタイル s="0" は xl/styles.xml の cellXfs index 0、つまり numFmtId="0"(General)。
このまま開けば 46143.75... がそのまま表示される。
日付として見せるには、scaffold生成時に styles.xml へ日付フォーマット(たとえば yyyy/mm/dd)を定義し、そのスタイルIDをストリーム側でセル要素に振り分ける必要がある。
ただし、scaffoldのstyle tableは固定なので、あとからフォーマットの種類を増やせない。
yyyy/mm/dd と yyyy-mm-dd hh:mm:ss の両方が必要なら、scaffold生成時に両方定義しておく。
さらに面倒なのが1900日付システムのバグだ。
Excelは互換性のためにLotus 1-2-3のバグを引き継いでおり、1900年2月29日(実在しない)を有効な日付として扱う。
このため、1900年3月1日以降のシリアル値は本来の値より1大きい。
POIはこの挙動を内部で吸収するが、自前でシリアル値を計算する場合は自分で合わせる必要がある。
間違えても1日ずれるだけなので、テストで気づきにくい。