はじめに
UUIDv4 を主キーに使っている SQL Server のテーブルでは、クラスタードインデックスの断片化が問題になりがちです。UUIDv4 は完全ランダムなので、INSERT のたびにインデックスの中間にページ分割が発生します。
一方、.NET 9.0 では UUIDv7 を生成する Guid.CreateVersion7() が追加されました。UUIDv7 はタイムスタンプベースで時系列順にソートできるため、これを使えば断片化を解決できるのではないか。そう考えて調べたところ、SQL Server の uniqueidentifier 型では UUIDv7 の順序性が機能しないことがわかりました。
この記事では、その原因と現時点での選択肢を整理します。
UUIDv7 とは
UUIDv7 は RFC 9562 で定義された UUID のバージョンです。従来の UUIDv4(完全ランダム)とは異なり、先頭 48 ビットに Unix Epoch からのミリ秒タイムスタンプを持ちます。
| ビット範囲 | サイズ | 内容 |
|---|---|---|
| 0-47 | 48bit | Unix Epoch からのミリ秒タイムスタンプ |
| 48-51 | 4bit | バージョン(固定値 0111) |
| 52-63 | 12bit | ランダム |
| 64-65 | 2bit | バリアント(固定値 10) |
| 66-127 | 62bit | ランダム |
先頭にタイムスタンプがあるため、生成順序がそのままバイト列の大小関係と一致します。
.NET 9.0 での UUIDv7 サポート
.NET 9.0 で Guid.CreateVersion7() メソッドが追加されました。
// UUIDv7 を生成
Guid id = Guid.CreateVersion7();
// タイムスタンプを指定して生成することも可能
Guid idWithTimestamp = Guid.CreateVersion7(DateTimeOffset.UtcNow);
従来の Guid.NewGuid() は UUIDv4(完全ランダム)を生成するのに対し、CreateVersion7() は時系列順序性を持つ UUID を生成します。主キーやクラスタードインデックスに使えば、INSERT のたびにインデックス末尾に追記される形になり、ページ分割が発生しにくくなります。
Entity Framework Core などの ORM でエンティティの ID を Guid.NewGuid() から Guid.CreateVersion7() に差し替えるだけで、インデックス効率の向上が期待できます。
ただし、この期待が成り立つかどうかは、データベース側の UUID の扱いに依存します。
SQL Server の uniqueidentifier 型の問題
バイト順序の不一致
SQL Server の uniqueidentifier 型は、RFC 標準とは異なる独自のバイト順序で値を格納・比較します。
UUIDv7 では、先頭 6 バイト(バイト 0-5)がタイムスタンプです。しかし SQL Server はこれをそのまま格納しません。
| バイト位置 | UUIDv7 での役割 | SQL Server の格納順 |
|---|---|---|
| 0-3 | タイムスタンプ(上位) | 逆順(3, 2, 1, 0) |
| 4-5 | タイムスタンプ(下位) | スワップ(5, 4) |
| 6-7 | バージョン + ランダム | スワップ(7, 6) |
| 8-15 | ランダム | そのまま |
先頭 4 バイトはリトルエンディアンで逆順に格納され、バイト 4-5 とバイト 6-7 もそれぞれスワップされます。バイト 8-15 のみが元の順序を保ちます。
ソート順序の問題
バイト順序の問題に加え、SQL Server は uniqueidentifier のソート時にも独自のルールを持っています。
SQL Server は GUID を 5 つのバイトグループ(4-2-2-2-6 バイト)に分割し、最後の 6 バイトグループから順に比較します。つまり、末尾のバイトが最も優先されます。
UUIDv7 のタイムスタンプは先頭 48 ビット(バイト 0-5)に格納されていますが、SQL Server のソートではこの部分の優先度が最も低くなります。結果として、UUIDv7 を uniqueidentifier 型に格納して ORDER BY しても、時系列順には並びません。
// C# で異なる時刻の UUIDv7 を3つ生成
var id1 = Guid.CreateVersion7(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
var id2 = Guid.CreateVersion7(new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero));
var id3 = Guid.CreateVersion7(new DateTimeOffset(2024, 12, 1, 0, 0, 0, TimeSpan.Zero));
-- SQL Server で ORDER BY すると...
SELECT Id, CreatedAt FROM MyTable ORDER BY Id;
-- 結果: 生成順(id1 → id2 → id3)とは一致しない並び順になる
-- ランダム部分(バイト 8-15)がソートで優先されるため
この挙動は NEWID() で生成した UUIDv4 をソートした場合と実質的に変わりません。UUIDv7 の最大の利点である時系列順序性が、SQL Server では機能しないということです。
他の DB との比較
同じ UUIDv7 でも、データベースによって扱いが異なります。
| データベース | UUID のバイト順序 | UUIDv7 の時系列ソート |
|---|---|---|
| PostgreSQL | RFC 標準(ビッグエンディアン) | そのまま機能する |
| MySQL |
UUID_TO_BIN(uuid, 1) でスワップ可能 |
スワップフラグで対応可能 |
| SQL Server | 独自の混合エンディアン | 機能しない |
他の主要 DB では UUIDv7 の順序性を活かせる仕組みがありますが、SQL Server にはこれらに相当する仕組みがありません。
現時点での選択肢
SQL Server で順序性のある ID を使いたい場合、以下の選択肢があります。
1. NEWSEQUENTIALID() を使う
SQL Server がネイティブに提供する順序付き UUID 生成関数です。
CREATE TABLE MyTable (
Id uniqueidentifier DEFAULT NEWSEQUENTIALID() PRIMARY KEY,
Name nvarchar(100)
);
前回生成した値より大きい GUID を生成するため、クラスタードインデックスの断片化を抑えられます。ただし、以下の制約があります。
- DEFAULT 制約でのみ使用可能(
SELECT NEWSEQUENTIALID()のような直接呼び出しは不可) - ID の生成がデータベースサーバー側に限定される。
Guid.NewGuid()でアプリケーション側から ID を生成していた設計からの移行では、INSERT の仕方を変更する必要がある - フェイルオーバー時に順序性がリセットされる可能性がある
2. binary(16) 型で格納する
uniqueidentifier 型を避け、binary(16) 型に UUIDv7 のバイト列をそのまま格納する方法です。
CREATE TABLE MyTable (
Id binary(16) PRIMARY KEY,
Name nvarchar(100)
);
binary 型はバイト列の先頭から素直に比較されるため、UUIDv7 の時系列順序性が保たれます。ただし、uniqueidentifier 型と比べて以下のトレードオフがあります。
-
NEWID()やNEWSEQUENTIALID()と併用できない - SSMS などのツールでの表示が 16 進数のバイト列になり、可読性が下がる
- ORM のマッピングに追加の設定が必要になる場合がある
まとめ
- .NET 9.0 の
Guid.CreateVersion7()で UUIDv7 を生成できるようになったが、SQL Server のuniqueidentifier型に格納すると時系列順ソートは機能しない - 原因は SQL Server 独自のバイト順序(混合エンディアン)とソート優先順位にある
- SQL Server で UUID の順序性が必要な場合は、
NEWSEQUENTIALID()またはbinary(16)型を要件に応じて選択する