
2026/06/06 8:13
SQLiteにおけるUUID主キーの落とし穴
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
SQLite でランダムな UUID4 をプライマリキーとして使用することは、B-tree 構造への行の順序付された挿入を乱すことで、データベースのパフォーマンスを著しく低下させます。順次 64 ビットの
rowid を使用する標準的なテーブルとは異なり、不規則な UUID4 の使用を強制することにより、ツリーのリバランス、過剰なページリング、構造化オーバーヘッドが常発します。「WITHOUT ROWID」テーブルを用いたベンチマークでは、1,000 万行のランダム UUID を挿入する場合、標準的な整数 rowid を使用する場合と比較して約 12 倍(約 1 秒対約 12 秒/百万行)遅くなります。このパフォーマンスへの悪影響は、クラスタ化されたインデックスに依存する他のデータベースシステムにも及んでいます。データ整合性を確保するために GUID のような一意の識別子が好まれる一方で、ストレージコストとのバランスを考慮する必要があります。より堅牢な代替案として UUID7 が挙げられ、時間順にキーを生成することで無秩序な挿入問題を解消し、合理的な挿入速度(約 12〜13 秒/百万行)に戻すことができます。UUID7 は 8 バイトの整数と比較して、その 16 バイトサイズによりわずかなオーバーヘッドを導入しますが、高ボリュームデータのアプリケーションに対しては、論理的な一意性と物理的な書き込み最適化の間で最適なバランスを提供します。本文
SQLite と UUID パフォーマンス:クラスタ化インデックスによるボトルネックと解決策
データベースにおけるプライマリーキーとしてランダムな UUID(UUID4)を使用することは一般的ですが、非順序性がクラスタ化インデックスに対して深刻なパフォーマンスコストをもたらすという欠点があります。B ツリーの葉節にランダムに行を挿入する際、ツリーを再バランスさせるための追加のページリングが発生します。本稿では SQLite を中心にこの問題について解説しますが、他のデータベースでも同様の傾向が観測されます。
クラスタ化インデックスの仕組み
クラスタ化インデックスは、テーブル内の行の物理的保存順序を決定します。データ行は索引付けされたキーによってソートされ、B ツリーの葉節に格納されます。主な特徴は以下の通りです。
- 一意性: テーブルにつき 1 つのみ存在します(物理的順序は一方方向しかありません)。
- データの保存場所: クラスタ化インデックス自体がテーブルであり、葉節には完全な行データが含まれています。
- 非クラスタ化インデックスとの違い: 通常のインデックスは、索引キーと行へのポインタのみを格納し、実際のリポジトリは別の場所にあります。
SQLite の通常のテーブルでは、暗黙の
rowid がこのクラスタ化インデックスとして機能します。データは rowid でソートされた B ツリー上に保存されます(WITHOUT ROWID テーブルを除く)。
ベースライン:Integer Rowid
まずは標準的な整数型プライマリーキー(
rowid)でのパフォーマンスを確認します。1,000 万行を 100 万行ずつのバッチで挿入した結果です。
テストコード (Clojure)
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id INT PRIMARY KEY, data BLOB)"]) (dotimes [_ 10] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (data) values (?)" data])))))
ベンチマーク結果 (ms)
| 総行数 | 所要時間 (ms) | 備考 |
|---|---|---|
| 10,000,000 | 1208 | |
| 20,000,000 | 11023 | |
| 30,000,000 | 11774 | |
| 40,000,000 | 11385 | |
| 50,000,000 | 10866 | 安定化開始 |
| 60,000,000 | 11017 | |
| 70,000,000 | 10708 | |
| 80,000,000 | 10699 | 最適水準 |
| 90,000,000 | 10791 | |
| 100,000,000 | 1081 | (原文の表記ミスと思われるが、文脈上 10,081 などと推測) |
結論: 大雑把に言うと、秒間に約 100 万件の挿入処理が可能でした。
UUID4 の問題:激しい性能低下
次に、ランダムな UUID4 をプライマリーキーとして使用した場合(
WITHOUT ROWID)を検証します。
テストコード (Clojure)
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"]) (dotimes [_ 10] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (id, data) values (?, ?)" (random-uuid4-bytes) data])))))
ベンチマーク結果 (ms)
| 総行数 | 所要時間 (ms) |
|---|---|
| 10,000,000 | 2649 |
| 20,000,000 | 5644 |
| 30,000,000 | 7137 |
| 40,000,000 | 8352 |
| 50,000,000 | 9359 |
| 60,000,000 | 9817 |
| 70,000,000 | 10490 |
| 80,000,000 | 11130 |
| 90,000,000 | 11668 |
| 100,000,000 | 1258 |
分析:
- ベースラインに比べて 10〜12 倍もの遅延が発生しています。
- 初期挿入時の遅延が非常に大きく、バッチ規模が大きくなるほどその影響が目立ちます。
プロファイリングによる洞察
プロファイリングの結果(標準化された差分グラフ)から、以下の点が明確になりました。
- 木構造の再バランスリング: UUID4 の非順序性により、行がランダムな順序で挿入されることがあります。
- B ツリーの再調整コスト: 行をランダム位置に追加するために、SQLite は B ツリーを絶えず再バランスさせる必要があります。
- I/O 増大: 読み込みと書き込みにおいて多くの時間を費やし、ページリングが発生しています。
解決策:UUID7 の採用
UUID4 の順序付けの問題を解消するため、時系列順の UUID7を使用する方法を試しました。
テストコード (Clojure)
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"]) (dotimes [_ 10] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (id, data) values (?, ?)" (random-uuid7-bytes) data])))))
ベンチマーク結果 (ms)
| 総行数 | 所要時間 (ms) |
|---|---|
| 10,000,000 | 1372 |
| 20,000,000 | 1280 |
| 30,000,000 | 1365 |
| 40,000,000 | 1250 |
| 50,000,000 | 1256 |
| 60,000,000 | 1270 |
| 70,000,000 | 1246 |
| 80,000,000 | 1257 |
| 90,000,000 | 1245 |
| 100,000,000 | 1258 |
分析:
- パフォーマンスは再び妥当な水準に戻りました。
- 整数ベースライン(約 1.1s)よりわずかに遅い傾向(約 1.3s)ですが、UUID4 と比べると劇的な改善が見られます。
- 原因: UUID のプライマリーキーが
(16 バイト) であるのに対し、整数はBLOB
(8 バイト) です。サイズの違いが軽微なオーバーヘッドになっている可能性があります。INT
まとめ
SQLite において UUID プライマリーキーを使用する際の注意点をまとめます。
- UUID4 は避ける: ランダム生成により B ツリーの再バランスコストが大きく、10 倍以上の性能劣化を引き起こします。
- UUID7 で改善可能: シーケンス性の高い UUID7 を使用することで、クラスタ化インデックスのメリットを享受しつつ、順序付けの問題を回避できます。
- ROWID の活用: 自然数キー(
)が最も高速ですが、ビジネスロジックで ID が必須な場合は UUID7 が代替案となります。rowid
より詳しいベンチマークコードは公式サイトで参照できます。興味深い事例として、「[SQLite で 100 万 TPS を実現する]」記事もご紹介します。
関連トピック
- クラスタ化インデックス
- クラスタ化インデックスと
オプティマイゼーションWITHOUT ROWID - clj-async-profiler によるプロファイリング手法
- 火炎図(flamegraphs)の活用方法