【PostgreSQL】UUIDv4とv7のパフォーマンスとインデックス断片化を比較する

【PostgreSQL】UUIDv4とv7のパフォーマンスとインデックス断片化を比較する

2026.06.23

はじめに

テーブルの主キーとして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を挿入したいとします。インデックスはソートされて格納されるので、BACの間に格納されるべきです。しかしページAはすでにいっぱいであるため、そのままでは挿入できません。そこで、新たにページBが追加され、Bを適切な位置に格納するとともに、ページA内の一部のデータがページBに移動します。

ページA [A B C 空 空]
ページB [D E F 空 空]

ページ分割が行われるとデータの移動が発生するため、時間のかかる操作とされています。UUIDv4のように挿入位置がランダムになる場合、ページ分割が発生しやすくなります。一方、v7のように時系列順に値が生成される場合は、挿入位置が末尾付近となるため、ページ分割を抑えられます。

参考

挙動を確認する

ある程度データが入った状態でINSERTしたときの挙動を確認します。

データを用意

以下の3パターンのテーブルを用意します。

  • users_v4:主キーはUUID型に格納したUUIDv4
  • users_v7:主キーはUUID型に格納したUUIDv7
  • users_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のパフォーマンスや、断片化について調査、検証してみました。

この記事がどなたかの参考になれば幸いです。

この記事をシェアする

関連記事