背景
UUID (GUID) はユニークな ID を手軽に生成できて便利ですが、SQL Server で Guid.NewGuid() をクラスタードインデックスの主キーに使うと、断片化率の上昇やページ密度の低下を招きやすいことが知られています。
SQL Server はデータを「ページ(8KB)」という単位で管理しています。
データが末尾に追加され続ける場合、SQL Server はページがいっぱいになるまでデータを格納し、空きがなくなると新しいページを確保します。この状態ではページは効率よく利用され、ページの論理的な並びも保たれます。
一方、ランダムな GUID を主キーに使うと、生成される値が既存データの間に入るため、B-tree の途中への挿入が頻発します。対象ページに空きがなければ、SQL Server はページ分割(Page Split)を行います。分割後のページはデータファイル上で離れた場所に配置されることがあり、ページの論理順序と物理配置がずれていきます。このようにページの論理順序と物理配置がずれた状態を、インデックス断片化(Fragmentation)と呼びます。
また、ページ分割ではデータが複数ページに分散されるため、ページ密度(Page Density)も低下します。
In many workloads, increasing page density results in a greater positive performance impact than reducing fragmentation.
Microsoft は断片化率だけでなくページ密度も性能に大きく影響する指標として重視しています。
- Page and extent architecture guide
- Optimize index maintenance to improve query performance and reduce resource consumption
目的
UUID を主キーに使いつつ、断片化率とページ密度を悪化させにくい生成方式を確認します。複数の GUID 生成方式でデータを投入し、結果を比較しました。
参考: SQL Server ベースのデータベースエンジン の UUID v7 の対応状況
実験手法
比較対象
以下の 5 種類のキー生成方式を、INSERT パターン違いを含めて 7 ケースで比較しました。
| # | 方式 | 説明 |
|---|---|---|
| 1 |
int 連番 |
コントロール群。アプリ側で連番を指定 |
| 2 | Guid.NewGuid() |
.NET 標準のランダム GUID(問題のあるパターン) |
| 3 | Guid.CreateVersion7() |
.NET 9+ の UUIDv7。先頭 48 ビットにタイムスタンプを配置 |
| 4 |
SequentialGuidValueGenerator(1行ずつ) |
EF Core 組み込みのシーケンシャル GUID 生成器 |
| 5 |
SequentialGuidValueGenerator(一括) |
同上。AddRange + SaveChangesAsync で一括 INSERT |
| 6 |
NEWSEQUENTIALID()(1行ずつ) |
SQL Server がサーバー側で生成するシーケンシャル GUID |
| 7 |
NEWSEQUENTIALID()(一括) |
同上。一括 INSERT |
SequentialGuidValueGenerator と NEWSEQUENTIALID() は、1行ずつ INSERT する場合と一括 INSERT する場合の両方を計測しました。
手順
- Azure SQL Database 上に各方式に対応するテーブルを作成
- コンソールアプリ(C# / EF Core)から各テーブルに 1,000 件を INSERT
-
sys.dm_db_index_physical_statsDMV で断片化率とページ密度を計測
計測に使用した列:
-
avg_fragmentation_in_percent— 断片化率(ページの論理順序と物理順序の乖離度) -
avg_page_space_used_in_percent— ページ密度(ページあたりの平均充填率)
なお、今回のデータ件数は 1,000 件と小さく、ページ数も少ないため、断片化率の絶対値は環境や実行タイミングで変動する可能性があります。本記事では、主に方式間の傾向を見る目的で比較しています。
環境
| 項目 | 値 |
|---|---|
| データベース | Azure SQL Database |
| .NET SDK | 10.0.300 |
| ターゲットフレームワーク | net10.0 |
| Microsoft.EntityFrameworkCore.SqlServer | 10.0.8 |
結果
断片化率は低いほど、ページ密度は高いほど良好な状態を示します。
| # | キータイプ | INSERT方式 | 断片化率(%) | ページ密度(%) | ページ数 |
|---|---|---|---|---|---|
| 1 | int 連番 | 1行ずつ | 11.11 | 94.70 | 9 |
| 2 | Guid.NewGuid() |
1行ずつ | 94.12 | 60.30 | 17 |
| 3 | Guid.CreateVersion7() |
1行ずつ | 93.75 | 67.15 | 16 |
| 4 | SequentialGuidValueGenerator |
1行ずつ | 8.33 | 91.61 | 12 |
| 5 | SequentialGuidValueGenerator |
一括 | 94.12 | 63.20 | 17 |
| 6 | NEWSEQUENTIALID() |
1行ずつ | 8.33 | 93.67 | 12 |
| 7 | NEWSEQUENTIALID() |
一括 | 8.33 | 91.61 | 12 |
int 連番でも断片化率が 0% ではありませんが、ページ数が 9 ページと少ないため、少数ページの物理配置差が割合として大きく表れた可能性があります。
考察
ランダム GUID(#2):断片化率 94%・ページ密度 60%
想定通り、ランダム GUID では大きく悪化しました。ランダムな値が B-tree の途中に挿入され続けるため、ページ分割が頻発しています。同じ 1,000 件でも int 連番(9 ページ)に対して約 2 倍の 17 ページを消費しており、ページ密度 60% はストレージと Buffer Pool の両面で無駄が大きい状態です。
UUIDv7(#3):期待に反して改善せず
Guid.CreateVersion7() は RFC 9562 に準拠し、先頭 48 ビットにタイムスタンプを配置するためソート可能な UUID として設計されています。
しかし、今回の SQL Server での結果では改善しませんでした。UUIDv7 はバイト列としては時刻順に並びやすい形式ですが、SQL Server の uniqueidentifier 型における比較順序は UUID の文字列表現や単純なバイト列順とは一致しません。そのため、UUIDv7 の時刻順序性がクラスタードインデックス上の挿入順序として活かされませんでした。
SequentialGuidValueGenerator(#4, #5):INSERT 方式に依存
EF Core の SequentialGuidValueGenerator は SQL Server のバイト比較順序を考慮して設計されており、1 行ずつ INSERT した #4 では断片化率 8%・ページ密度 91% と良好でした。
しかし一括 INSERT の #5 では断片化率 94%・ページ密度 63% に悪化しました。原因としては、クライアント側で生成された GUID の生成順・送信順・SQL Server 上のキー順が完全には一致せず、結果として末尾追加にならなかった可能性があります。ただし、本検証結果のみでは断定できないため、原因特定のためには ID生成順、EF Core が発行した SQL、ORDER BY Id の並び順などを追加で確認する必要があります。
NEWSEQUENTIALID()(#6, #7):INSERT 方式によらず安定
NEWSEQUENTIALID() は 1 行ずつ・一括のいずれでも断片化率 8%・ページ密度 91〜93% と安定した結果を示しました。SQL Server 自身が INSERT 実行時に値を生成するため、クラスタードインデックス上では末尾付近への追加になりやすく、今回の検証では INSERT 方式に依存せず安定した結果になりました。
結論
SQL Server で UUID を主キーにしつつ、断片化率とページ密度を悪化させにくい選択肢としては、NEWSEQUENTIALID() が最も安定していました。1行ずつの INSERT だけでなく、一括 INSERT でも良好な結果を維持しています。
ただし、NEWSEQUENTIALID() の連続性にはハードウェア依存の側面があります。Windows の再起動後は低い範囲から再開する可能性があり、Azure SQL Database のフェールオーバーなどで別のコンピューターに移動した場合には、シーケンシャル値の範囲が変わる可能性があります。そのため、長期間の運用やフェールオーバーを含む環境では、今回の単発検証とは異なる傾向になる可能性があります。
NEWSEQUENTIALID (Transact-SQL)
また NEWSEQUENTIALID() は DEFAULT 制約でのみ使用できるため、アプリケーション側で ID を事前採番したい場合や、DB 挿入前に ID を外部連携・イベント発行に使いたい場合など、性能とは別軸の観点で採用できないこともありそうです。
SequentialGuidValueGenerator も 1行ずつの INSERT では有効でしたが、一括 INSERT では大きく悪化しました。Web サーバーのスケールアウトや並列処理など、挿入順序が保証されにくい環境では、期待通りの効果が得られない可能性があります。
UUIDv7(Guid.CreateVersion7())は SQL Server の uniqueidentifier 型の比較順序と合致しないため、今回の検証では断片化抑制の効果は見られませんでした。
なお、本検証は Azure SQL Database を前提としたものです。PostgreSQL など、UUID の比較順序や格納方式が異なるデータベースでは、結果が異なる可能性があります。