パート1〜4はORMオーバーヘッドを計測した。sqler対raw sqlite3、両方とも _id INTEGER, data JSON でデータを格納。両アームは json_extract() でクエリし、読み込み時にJSONをパースする。ギャップは5〜15%で、それが抽象レイヤーのコスト — クエリコンパイル、アダプターラッピング、Pydanticのハイドレーション。
本稿は別の問いを立てる。「ORMのコストは何か」ではなく、「ドキュメント指向アーキテクチャ自体のコストは何か」という問いだ。
パート5。パート1: 方法論 | パート2: オーバーヘッド | パート3: 修正 | パート4: スコアカード
比較の構成
sqlerアーム: Pydanticモデル、json_extract() クエリ、JSONブロブストレージによる完全なORM。出荷時の開発者体験そのまま。
カラム型アーム: 従来の型付きスキーマ — name TEXT, value INTEGER, category TEXT, score REAL。カラムへの直接アクセス、json_extract() なし、スカラーフィールドの json.loads() なし。カラムごとのバインディングによる標準的な executemany() インサート。
両アームで共通:
- 同一の接続セットアップ(同じ
create_conn()関数) - 同一のPRAGMA(WAL、64MBキャッシュ、normal sync)
- 同一のデータ(DocumentGenerator、seed=42、“small” プロファイル)
- 同一の計測方法論(PrecisionTimer、20イテレーション、3ウォームアップ、GC分離、アーム交互実行)
- デュアルストレージモード(メモリとディスク)
tags フィールドは両アームともJSONのまま — 配列はジャンクションテーブルなしではカラム型での表現が難しい。比較はスカラーフィールドに絞る: name、value、category、score。
数値
8シナリオ、5スケール(10K〜1M行)、--storage both。ディスクモードの数値を報告;メモリモードの比率は全ケースでディスクの10%以内。
クエリと集計
| シナリオ | 10K | 50K | 100K | 500K | 1M |
|---|---|---|---|---|---|
| 等値フィルタ | 9.4x | 11.3x | 11.2x | 10.3x | 11.2x |
| 範囲フィルタ | 3.2x | 3.2x | 3.1x | 3.2x | 3.2x |
| 複合フィルタ | 5.7x | 6.0x | 6.0x | 5.8x | 6.0x |
| Top N | 5.0x | 5.7x | 5.9x | 6.8x | 7.3x |
| 集計 | 9.2x | 9.5x | 9.4x | 9.5x | 9.5x |
等値フィルタは json_extract() コストの最も明確な指標だ。両アームとも1フィールドを1値と照合するが、sqlerは WHERE json_extract(data, '$.value') = ? を実行し、カラム型アームは WHERE value = ? を実行する。11xのギャップはほぼ全て抽出関数のコスト;Pydanticのハイドレーションが結果セットにさらにコストを重ねる。
範囲フィルタは全スケールで3.2xと安定している。低い比率はより高い選択性を反映している — より多くの行がフィルタを通過するため、JSON抽出の行ごとのオーバーヘッドが両アームともに行う結果処理に対して薄まる。
Top Nはスケールとともに悪化する: 10Kで5.0x、1Mで7.3x。ORDER BY json_extract(data, '$.score') はソート前に全候補行で抽出関数を評価しなければならない;ORDER BY score は型付き値を直接読む。抽出がホットパスにある場合、SQLiteのソートバッファ管理のスケールが異なるためギャップが広がる。
集計は〜9.5xで平坦。SUM(json_extract(data, '$.value')) 対 SUM(value) — 抽出関数は全行で実行され、償却は不可能。
インサート
| シナリオ | 10K | 50K | 100K | 500K | 1M |
|---|---|---|---|---|---|
| バルクインサート | 6.8x | 3.1x | 2.5x | 1.7x | 1.3x |
sqlerはチャンク化されたマルチ行INSERT(INSERT INTO t VALUES (...), (...), ...)を使用;カラム型アームはカラムごとのバインディングで executemany() を使用。小規模ではsqlerの固定オーバーヘッドが支配的。1M行ではチャンク化アプローチがほぼ追いつく: JSONシリアライゼーション+マルチ行バッチング対行ごとのバインディング。収束は実際だが出発点が重要;典型的なアプリケーションスケール(10K〜100K)ではギャップは2.5〜6.8x。
エクスポート
| シナリオ | 10K | 50K | 100K | 500K | 1M |
|---|---|---|---|---|---|
| CSVエクスポート | 1.7x | 1.9x | 1.9x | 1.9x | 1.9x |
| JSONLエクスポート | 1.1x | 1.1x | 1.0x | 1.0x | 1.0x |
JSONLエクスポートはスケールで同等性に達する。これがドキュメントアーキテクチャに構造的な優位性がある唯一のシナリオだ: データがすでにJSONである。sqlerは data を読んで書く;カラム型アームは個々のカラムからJSONを組み立てなければならない。1M行で1.0xに収束。
CSVエクスポートは1.9xで安定。sqlerはJSONディクトからフィールドごとの抽出コストを払う;カラム型アームは型付きカラムを直接読む。
数値の意味
ドキュメント指向アーキテクチャは、型付きカラムと比較してクエリと集計で3〜11xのコストがかかる。それが json_extract() の価格だ。これはバグではなく、構造化データをJSONブロブとして格納することの根本的なトレードオフだ。
文脈として: パート1〜4で計測したORMオーバーヘッドは、同じJSONストレージの上に1.03〜1.15xだった。ドキュメントアーキテクチャ自体が高コストな部分;抽象レイヤーはそれに比べればノイズだ。
コストの出所
-
行ごとの
json_extract()— 全クエリ、フィルタ、ソート、集計が型付きカラムを直接読む代わりにjson_extract(data, '$.field')を呼ばなければならない。SQLiteのJSONパーサーは高速だが、全行で文字列パースを行っている。 -
Pydanticのハイドレーション — sqlerはモデルインスタンスを返す;カラム型アームは
sqlite3.Rowから構築したdictを返す。ハイドレーションコスト(パート2で詳述)が抽出コストの上に重なる。 -
インサート時のJSONシリアライゼーション — sqlerはドキュメント全体に
json.dumps()を実行する;カラム型アームは型付き値を直接バインドする。スケールではsqlerのマルチ行INSERTストラテジーがこのコストの一部を相殺する。
コストの出所でないもの
- PRAGMA: 同一。同じ関数、同じ設定。
- 接続セットアップ: 同一。共有ベースラインモジュールから同じ
create_conn()。 - データ: 同一。同じジェネレーター、同じシード、同じドキュメント。
トレードオフ
集計で10xのギャップは壊滅的に聞こえる。それが重要かどうかは何を作っているかによる。
型付きカラムが明確に勝つとき: 分析、レポーティング、バルク集計、ほとんどの行をスキャンしてスカラーフィールドを計算するワークロード全般。クエリが SELECT AVG(score) FROM t WHERE category = 'tech' のような形なら、カラム型スキーマは常に速い。そのクエリは全行に触れる;全行での json_extract() は純粋なオーバーヘッドだ。
ギャップが縮まるか消える場合: ドキュメントエクスポート(JSONLは同等)、スケールでのバルクインサート(1Mで1.3x)、データが自然にネスト・可変スキーマのドキュメントとして存在し、そうでなければジャンクションテーブルやEAVパターンが必要なワークロード。
速度がアーキテクチャ選択の基準でない場合: スキーマの柔軟性。sqlerモデルはPythonクラスだ;フィールドの追加はクラスの変更であり、ALTER TABLE の実行ではない。オプションフィールド、ネストオブジェクト、可変長配列を持つドキュメントはJSONに自然にマッピングされる;カラムにフラット化するには、JSONストレージが先送りにする設計上の決定が前もって必要だ。
コストは実在し、予測可能で、比例的だ。データより速く増えない。集計で10xが許容できるかどうかは、絶対値でその集計が1msかかるか1秒かかるかによる — ほとんどのアプリケーションスケールのデータセットでは前者だ。
既知の注意点
-
インサートストラテジーの非対称性: sqlerのチャンク化マルチ行INSERT対
executemany()は対等なSQL比較ではない。実際の使用を反映している — これが各ツールが実際にデータを挿入する方法だ — しかしスケールでの収束は異なるストラテジーの産物であり、純粋なストレージモデルの効果ではない。 -
戻り型の非対称性: sqlerはPydanticモデルインスタンスを返す(〜1,600ns/行のハイドレーションコスト);カラム型アームはプレーンdictを返す。これは意図的 — 完全な開発者体験を計測している — しかし
json_extract()のコストだけを分離するには両アームが同じ型を返す必要がある。 -
カラム型アームにインデックスなし: 両アームともインデックスを使用しない(主キー以外)。カラム型アームに
CREATE INDEX ON bench(value)を追加すると等値・範囲フィルタのギャップが広がる;sqlerアームにjson_extract(data, '$.value')の関数インデックスを追加するとギャップが狭まる。生のストレージモデルコストを計測するため両方とも省略した。 -
シングルマシン: パート1〜4と同じ注意点。Linux x86_64、8コア、Python 3.12、SQLite 3.50。
自分で実行する
# ベンチマーク依存関係をインストール
uv sync --all-groups
# tabularシナリオをリスト
uv run python -m benchmarks list | grep tabular
# mediumスケールで実行(〜20分)
uv run python -m benchmarks run --suite tabular --scale medium --storage both
# largeスケールで実行
uv run python -m benchmarks run --suite tabular --scale large --storage both
シリーズサマリー
5本の記事、5通りの問い:
| 記事 | 問い | 答え |
|---|---|---|
| パート1 | このベンチマークは公平か? | いいえ。18の問題を発見;全面書き直し。 |
| パート2 | オーバーヘッドはどこから来るか? | Pydanticのハイドレーションとエクスポートパス。 |
| パート3 | 修正できるか? | 5つの修正;バルクインサートは0.89xに。 |
| パート4 | ORMの最終コストは? | クエリで1.03〜1.15x;CSVで1.34x。 |
| パート5 | アーキテクチャのコストは? | クエリで3〜11x;JSONLエクスポートで1.0x。 |
ORMは安い。ドキュメントモデルは高い。ドキュメントモデルが高すぎるかどうかはワークロードによる;ほとんどのアプリケーションにとって、柔軟性はコストに見合う。
最初から読む: パート1: 方法論。
