【PostgreSQL】UUIDv4とv7のパフォーマンスとインデックス断片化を比較する
はじめに
テーブルの主キーとしてUUIDを使用したいケースがあります。連番とは異なり、分散システムであっても現在値の管理をすることなく、重複のないIDを発行できます。
UUIDにはバージョンがいくつかあり、v4は完全にランダムな値が生成されます。B-Treeインデックスにランダムな値を挿入すると、挿入位置が不定となります。これによりページ分割が発生しやすくなり、パフォーマンスの低下を引き起こすという懸念があります。
これに対して、UUIDv7はタイムスタンプを含み、時系列順でソートされます。挿入位置がインデックスの末尾付近となるため、v4と比べてページ分割が起きにくいとされています。
この記事では、実際にPostgreSQL上でv4とv7用のテーブルを用意し、INSERTのパフォーマンスやインデックスの断片化について確認してみました。
PostgreSQLにおけるUUID
PostgreSQLにはUUID専用のUUID型があります。16バイトの固定長のため、TEXT型などの文字列型で持つよりもストレージ効率が高いです。
UUID型を持たないデータベースもあり、その場合は文字列型の列にUUIDの文字列を格納します。今回の検証はPostgreSQLで実施するため、UUID型を使用できますが、この記事ではUUID型とTEXT型との比較も行ってみます。
PostgreSQLでは、UUIDを生成するネイティブの関数が用意されています。PostgreSQL 17まではUUIDv4を生成するgen_random_uuid関数のみ用意されており、v7を使用するにはアプリケーション側で発行する必要があります。PostgreSQL 18では、UUIDv4を生成するuuidv4、v7を生成するuuidv7が追加されました。(gen_random_uuid関数も引き続き利用可能です)
参考
ページ分割とは
PostgreSQLでは、データは8KB(デフォルト)のページという単位の集まりとしてディスク上に格納されています。インデックスもこのページ単位で管理されており、ページがいっぱいになると、新たなページが追加されるとともに、既存のページ内のデータが一部移動します。これをページ分割といいます。
例えば、インデックスの値が以下のようにページAに格納されているとします。
ページA [A C D E F]
ここに新しくBを挿入したいとします。インデックスはソートされて格納されるので、BはAとCの間に格納されるべきです。しかしページAはすでにいっぱいであるため、そのままでは挿入できません。そこで、新たにページBが追加され、Bを適切な位置に格納するとともに、ページA内の一部のデータがページBに移動します。
ページA [A B C 空 空]
ページB [D E F 空 空]
ページ分割が行われるとデータの移動が発生するため、時間のかかる操作とされています。UUIDv4のように挿入位置がランダムになる場合、ページ分割が発生しやすくなります。一方、v7のように時系列順に値が生成される場合は、挿入位置が末尾付近となるため、ページ分割を抑えられます。
参考
挙動を確認する
ある程度データが入った状態でINSERTしたときの挙動を確認します。
データを用意
以下の3パターンのテーブルを用意します。
users_v4:主キーはUUID型に格納したUUIDv4users_v7:主キーはUUID型に格納したUUIDv7users_v7_text:主キーはTEXT型に格納したUUIDv7
各テーブルに100万件のダミーデータをINSERTします。
CREATE TABLE users_v4 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT NOT NULL,
score INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users_v7 (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT NOT NULL,
email TEXT NOT NULL,
score INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users_v7_text (
id TEXT PRIMARY KEY DEFAULT uuidv7()::TEXT,
name TEXT NOT NULL,
email TEXT NOT NULL,
score INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO users_v4 (name, email, score, created_at)
SELECT 'user_' || i,
'user_' || i || '@example.com',
(RANDOM() * 1000000)::INT,
NOW() - INTERVAL '1 year' + (i * INTERVAL '31 seconds')
FROM generate_series(1, 1000000) AS i;
-- users_v7、users_v7_textも同様のクエリで登録
INSERTのパフォーマンス確認
各テーブルに対して追加でINSERTを行います。件数について何パターンか試してみます。
※以下のSQLの1..100の部分を件数に応じて変更します。
DO $$
DECLARE
start_time TIMESTAMPTZ;
BEGIN
start_time := clock_timestamp();
FOR i IN 1..100 LOOP
INSERT INTO users_v4 (name, email, score, created_at)
VALUES (
'user_' || i,
'user_' || i || '@example.com',
(RANDOM() * 1000000)::INT,
NOW()
);
END LOOP;
RAISE NOTICE 'Execution time: %', clock_timestamp() - start_time;
END;
$$;
100件投入の結果
| 回数 | UUID v4 | UUID v7 | UUID v7 string |
|---|---|---|---|
| 1 | 0.04157 s | 0.008247 s | 0.006588 s |
| 2 | 0.028914 s | 0.003107 s | 0.003229 s |
| 3 | 0.032386 s | 0.002348 s | 0.001947 s |
1,000件投入の結果
| 回数 | UUID v4 | UUID v7 | UUID v7 string |
|---|---|---|---|
| 1 | 0.218346 s | 0.022018 s | 0.024128 s |
| 2 | 0.208283 s | 0.025428 s | 0.023157 s |
| 3 | 0.17478 s | 0.017787 s | 0.047302 s |
| 4 | 0.106207 s | 0.027011 s | 0.023831 s |
| 5 | 0.093788 s | 0.017772 s | 0.020835 s |
10,000件投入の結果
| 回数 | UUID v4 | UUID v7 | UUID v7 string |
|---|---|---|---|
| 1 | 0.699061 s | 0.14907 s | 0.19718 s |
| 2 | 0.22445 s | 0.146704 s | 0.25264 s |
| 3 | 0.603682 s | 0.148026 s | 0.192686 s |
| 4 | 0.220432 s | 0.144825 s | 0.222202 s |
10万件投入の結果
| 回数 | UUID v4 | UUID v7 | UUID v7 string |
|---|---|---|---|
| 1 | 2.101958 s | 1.586907 s | 2.77646 s |
| 2 | 1.932423 s | 1.749187 s | 2.443926 s |
| 3 | 2.915823 s | 1.627434 s | 2.409044 s |
| 4 | 2.747953 s | 2.622848 s | 2.123219 s |
| 5 | 4.345691 s | 2.595733 s | 2.114248 s |
上記の結果から、v4はv7より時間がかかる傾向があることがわかります。これはランダムな値の挿入によりページ分割が発生し、それにかかる時間が積み重なったものと考えられます。一方、UUID型とTEXT型では、若干UUID型の方が速いものの、大きな差はありませんでした。
ストレージサイズの確認
この時点で各テーブルには1,545,300件のレコードが格納されています。ストレージサイズを比較します。
SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS index_size
FROM
pg_stat_user_tables
WHERE
relname IN ('users_v4', 'users_v7', 'users_v7_text')
ORDER BY
relname;
| table_name | total_size | table_size | index_size |
|---|---|---|---|
| users_v4 | 200 MB | 137 MB | 62 MB |
| users_v7 | 184 MB | 137 MB | 47 MB |
| users_v7_text | 259 MB | 172 MB | 87 MB |
users_v7_textが明確に大きくなっており、UUIDを格納するならUUID型が効率的であることがわかります。
users_v4については同じUUID型を利用していますが、ページ分割によってページ数が増えていることが原因と考えられます。
断片化の状況確認
インデックス断片化を確認するには、pgstattupleという拡張機能を使用します。この拡張機能はページレベルの詳細な情報を返します。
参考
このモジュールの中のpgstatindex関数を使用して、ページ数や平均密度、断片化具合を確認できます。
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
(pgstatindex(indexrelid)).avg_leaf_density,
(pgstatindex(indexrelid)).leaf_fragmentation,
(pgstatindex(indexrelid)).leaf_pages
FROM pg_stat_user_indexes
WHERE relname IN ('users_v4', 'users_v7', 'users_v7_text')
AND indexrelname LIKE '%pkey%';
| indexrelname | index_size | avg_leaf_density | leaf_fragmentation | leaf_pages |
|---|---|---|---|---|
| users_v4_pkey | 62 MB | 67.28 | 49.69 | 7,929 |
| users_v7_pkey | 47 MB | 89.99 | 0.0 | 5,921 |
| users_v7_text_pkey | 87 MB | 89.94 | 0.0 | 11,038 |
v4はページの平均密度を表すavg_leaf_densityが他の2パターンより低く、断片化率を表すleaf_fragmentationも高くなっています。ランダムな値の挿入によってページ分割が頻発し、ページ内に空きが生まれたり、ページの並び順が不定になったためだと考えられます。
また、users_v7_text_pkeyではページ数が多くなっていますが、これは1ページあたりに格納できるレコード数がUUID型のテーブルと比較して少ないためだと考えられます。
その他の考慮事項
PostgreSQLはページ単位でキャッシュを行います。つまり、あるデータを検索したとき、そのデータが格納されているページ全体がキャッシュに格納されます。もし次に別のデータを検索したとき、そのキャッシュの中に目的のデータがあれば、キャッシュを使って検索を高速化できます。
通常のSELECTでは、インデックスページとテーブルページの両方を読み取ります。そのため、両方のキャッシュ効率がパフォーマンスに影響します。
UUIDv7のような時系列順の場合、INSERTが末尾方向に集中します。そのため、最近作成されたレコードを頻繁に検索するようなパターンでは、直近のページがキャッシュに格納されている可能性が高くなり、キャッシュヒット率が向上します。
インデックスがランダム値の場合、クエリごとにアクセスするインデックスページとテーブルページが異なります。そのため、SELECT時に目的のデータがキャッシュに残っている可能性は相対的に低くなり、ディスクアクセスが発生しやすくなります。
UUIDv4、v7の選択基準
以上より、UUIDを主キーに使う場合の選択基準は以下のように考えられます。
- IDから生成時刻や作成順序、作成頻度が推測できることがセキュリティなどの理由で問題になる場合は、ランダムなv4を選択する
- 上記のような制約がなく、v4でもv7でもどちらでも良い場合は、v7を選んだ方が有利になる
おわりに
この記事ではPostgreSQLにおけるUUIDv4とv7のパフォーマンスや、断片化について調査、検証してみました。
この記事がどなたかの参考になれば幸いです。








