メインコンテンツへスキップ
  1. Posts/

そのプライマリキー、ボトルネックになっていませんか

·438 文字·3 分·
Postgresql Performance B-Tree Uuid Database-Design
アミット・デイブ
著者
アミット・デイブ
ソフトウェアエンジニアで、しっかりしたシステムを作るのが得意です。難しい問題をわかりやすくすることも得意です。好きなことは、分散システムクリーンアーキテクチャ、そしてオープンソースプロジェクトです。仕事以外では、山を歩くことや日本語の勉強、そしてstderrなどから学ぶことを楽しんでいます。
目次

去年、シニアバックエンドエンジニアを面接していたときの話です。

分散システムに明るく、Kubernetesも使いこなし、Goの経験も豊富。申し分ない候補者でした。私はいつものデータベースの質問を投げました。

「RDS上のPostgreSQLがピーク時に書き込みスループットの上限に達しています。水平スケーリングのアプローチを説明してください。」

返ってきたのは教科書通りの回答。読み取りの多いクエリにはリードレプリカ。コネクションプーリングにはPgBouncer。予算が許せばAurora。シャーディングならCitus。応急処置にインスタンスクラスのスケールアップ。

どれも理にかなっています。そして、どれも本質を外している

インフラのスケーリングを議論する前に、誰も聞かない問いがあります——「プライマリキーの設計が、ハードウェアでは解決できないボトルネックを生み出していないか?」

この記事は、その問いに答えるために書きました。

誰も語らない問題
#

チュートリアル、ORM、「PostgreSQL入門」ガイド。どれもこう教えます:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    total NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

無害に見えます。BIGSERIALは小さく(8バイト)、比較が高速で、ページネーションにも自然。INSERTのたびにシーケンスから次の番号を取得する、シンプルな仕組みです。

しかし裏側では、INSERTが圧倒的にB-treeインデックスの最右端リーフページに集中しています。並行処理下では、この集中がボトルネックに変わります。

B-treeの「右端」で何が起きているのか
#

PostgreSQLのB-treeインデックスは、キーをリーフページにソート順で格納します。キーが単調増加する場合——1, 2, 3, 4, …——新しいキーは常に既存のすべてのキーより大きい。つまりインデックスの最右端のリーフページに向かいます。ページ分割やFILLFACTOR設定、VACUUMで一時的に近隣ページへ分散されることはありますが、統計的パターンは一貫しています。最右端リーフにアクションが集中する。

PostgreSQLはこれを賢く処理しています。nbtinsert.cの「fastpath」最適化は、最右端リーフのブロック番号をキャッシュし、毎回ルートからツリーを辿る必要をなくしています。

ところが並行処理下では、この最適化そのものがボトルネックになる

50、100、200の同時接続がすべて行を挿入しているとき、すべてが同じリーフページを変更しようとします。PostgreSQLはバッファページにcontent lockを使い、タプル挿入には排他ロックが必要です。200のバックエンドが一列に並び、一つずつ、その単一ページに書き込む。

インデックス挿入のクリティカルセクションが直列化される。 ヒープ挿入、WALログ記録、可視性チェックは引き続き並行で動きますが、インデックスへの書き込みそのものがチョークポイントです。スループットはこのパターン下で頭打ちになります。

理論ではありません。今すぐ自分のデータベースで確認できます。

まず計測する——診断クエリ
#

何かを変える前に、計測です。

ステップ1:バッファcontent lockの競合を確認
#

SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;

ピーク時のINSERTトラフィックでLWLock / BufferContentが上位に来ていたら、バッファページのロック競合が発生しています。

ステップ2:「ホット」なページを特定する
#

pg_buffercache拡張が必要です(CREATE EXTENSION IF NOT EXISTS pg_buffercache;):

SELECT c.relname, b.relblocknumber, b.pinning_backends, b.isdirty
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE b.pinning_backends > 1
ORDER BY b.pinning_backends DESC
LIMIT 10;

プライマリキーインデックスの特定ブロックにpinning_backendsが集中していれば、それが右端リーフの競合です。すべてのINSERTがそのページを奪い合っている。

ステップ3:インデックスのI/Oパターンを比較
#

SELECT indexrelname, idx_blks_read, idx_blks_hit,
       round(idx_blks_hit::numeric /
             nullif(idx_blks_hit + idx_blks_read, 0), 3) as hit_ratio
FROM pg_statio_user_indexes
ORDER BY idx_blks_read DESC
LIMIT 10;

シーケンシャルキーのインデックスでは、バッファプールのヒット率は一見良好に見えます(ホットページ自体はキャッシュされているため)。しかし真の問題はロック競合であり、I/O統計には現れません。

これらの症状が見られなければ、ここから先は読まなくて大丈夫です。 BIGSERIALで何の問題もありません。安心して機能開発に戻ってください。この記事は、毎秒数千件の並行INSERTを処理しながら、CPUを増やしても状況が改善しない理由に悩んでいるチームに向けて書いています。

重要な前提条件: この競合が顕在化するのは、ボトルネックがI/Oからインメモリのロック競合に移行した場合です。高い並行INSERT、メモリに収まるPKインデックス、ミリ秒あたり十分な数のINSERT——これらが揃って初めて問題になります。ワークロードがI/Oバウンドなら、右端リーフは最後に心配すべきことです。

B-tree内部の動き
#

PostgreSQLのB-tree実装はsrc/backend/access/nbtree/nbtinsert.cにあります。行挿入時のインデックス処理を簡略化すると:

  1. 対象のリーフページを見つける。 単調増加キーではfastpathが起動し、最右端リーフのブロック番号を記憶してそこへ直行。

  2. バッファページの排他content lockを取得。 これが直列化ポイント。ロックを保持できるのは一度に1バックエンドだけ。

  3. インデックスタプルをページに挿入。

  4. ページが満杯なら分割。 新ページを割り当て、約半分のタプルを移動し、親のdownlinkを更新し、WALレコードを書き、両ページと親に排他ロックを保持する。(1行の挿入にこれだけの処理が走ります。)

  5. ロック解放。

単調キーでは、ステップ4が予測可能に繰り返されます。最右端リーフが満杯→分割→新しい最右端リーフが次のホットページ。PostgreSQL 9.5で導入されたBufMappingLockパーティショニングは、異なるバックエンドが異なるページにアクセスする場合には有効ですが、すべてが同じページにアクセスしている場合、無力です。

競合の本質は単一ページへの並行アクセスです。I/Oでも、CPUでも、メモリでもない。この事実が、あらゆるスケーリング戦略に影を落とします:

  • スケールアップ: CPUが増えても、ロックは1ページへの書き込みを直列化し続ける。天井が上がるだけで、ボトルネックの形は変わりません。
  • リードレプリカ: 書き込みスループットには無関係。
  • シーケンシャルIDをシャードキーとしたシャーディング: これが最も残酷な罠です。各シャードが独自のB-treeと最右端リーフを持ちますが、すべての書き込みが1つのシャードに集中する。(customer_idやハッシュでシャーディングしているなら別の話——PKの右端リーフ競合は各シャード内に残りますが、少なくとも書き込みはシャード間に分散されます。)

シーケンス競合について
#

「シーケンス自体もボトルネックでは?」と思うかもしれません。歴史的にはそうでした——共有シーケンスのnextval()競合は実際に問題でした。現代のPostgreSQLはバックエンドごとのシーケンスキャッシュ(CREATE SEQUENCECACHE句)でこれをほぼ解消しています。今日のボトルネックはシーケンスではなくインデックスです。ただし、シーケンス操作でLWLock系のwaitが見られる場合は、B-treeを疑う前にキャッシュ設定を確認してください。

それでもシーケンシャルIDが正解であるケース
#

ここからが、インターネット上の「UUID推し」記事が飛ばしがちな話です。

シーケンシャルIDはほとんどのワークロードで積極的に優れています。

キャッシュ局所性が抜群。 最右端リーフパターンは、B-treeのホットな作業セットが小さく予測可能であることを意味します。現在の最右端リーフとその親パス、わずか数ページ。バッファプールにほとんど負担がかからない。対照的に、ランダムキーの挿入ではインデックス全体をキャッシュしないとまともな書き込み性能が出ない可能性があります。

追記型パターンとの相性が抜群。 時系列データ、イベントログ、最新データへの読み取りが中心のワークロード——シーケンシャルIDは完璧な物理的順序を提供します。最近の行がディスク上でクラスタリングされ、インデックスエントリもB-tree内でまとまる。シーケンシャルスキャンや範囲クエリの性能が大きく向上します。

JOINが速い。 主な理由はCPUの比較コスト(8バイト整数 vs 16バイトUUID)ではなく、キャッシュ密度です。小さいキーはページあたりのエントリ数が多く、走査ページが少なく、バッファプールのヒット率が高い。外部キーでこれらのIDを参照している場合、サイズ差はすべてのインデックス、JOIN、ルックアップで累積します。

ポーリングベースのCDCが容易。 WHERE id > @last_processed_idは最もシンプルで効率的なポーリングベースの変更データキャプチャパターンです。ログベースのCDC(Debezium、論理レプリケーション)ではPKの型はそれほど重要ではありませんが、ポーリングパターンではシーケンシャルIDに敵うものはありません。

そして何より——大多数のPostgreSQLデプロイメントは右端リーフの競合に到達しません。 数百件/秒未満の並行INSERT(多くの人が思うよりずっと多い数字です)であれば、BIGSERIALが正解。シンプルで、小さく、この記事で述べた特定の病理以外のすべてにおいて高速です。

問題が確認された場合の選択肢
#

診断クエリを実行した。BufferContentロック待ちがプライマリキーインデックスに集中している。右端リーフだと確認できた。では、どうするか。

選択肢1:FILLFACTORチューニング(手軽な緩和策)
#

大きな変更の前に、まず最もシンプルな調整から:

ALTER INDEX orders_pkey SET (fillfactor = 70);
REINDEX INDEX orders_pkey;

fillfactorを下げると(B-treeインデックスのデフォルトは90)、各リーフページに空き領域が確保されます。ページ分割が遅延し、最もコストの高い競合イベントの頻度が下がる。根本解決にはなりません——INSERTは依然として最右端リーフに集中します——しかし、分割起因のロック競合を緩和して時間を稼げます。痛み止めであって、根治療法ではありません。

選択肢2:時間範囲パーティショニング(最も影響の少ない構造的対策)
#

本番稼働中でプライマリキー戦略を変更できない場合——現実にはよくあることです——時間範囲パーティショニングが実用的な解決策になります:

CREATE TABLE orders (
    id BIGSERIAL,
    customer_id BIGINT NOT NULL,
    total NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2026_02 PARTITION OF orders
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

各パーティションが独自のインデックスを持つため、右端リーフの競合が複数の小さなインデックスに分散されます。バックフィルなど複数パーティションにまたがるデータの挿入では、競合は自然に散らばります。

トレードオフは運用の複雑さ。パーティション管理、クエリプランニングのオーバーヘッド、そしてSELECT * FROM orders WHERE id = 12345にパーティションキーが必要になる理由をチームに説明する楽しさが待っています。

選択肢3:新規テーブルにはUUID v7(最も効果的な書き込み分散)
#

新規サービス、新規テーブル、グリーンフィールド。現在、多くのチームがUUID v7(RFC 9562)に収束しつつあります。それには十分な理由があります。

UUID v7はミリ秒精度のタイムスタンプを上位ビットに埋め込み、時間順序を保持します(範囲スキャンやソートが引き続き機能する)。残りの74ビットはランダムまたはサブミリ秒データ(12ビットのrand_a + 62ビットのrand_b、RFC 9562準拠)で埋められます。このランダム性がINSERTを分散させる鍵です。

PostgreSQL 17時点で、組み込みのUUID v7ジェネレータはありませんpg_uuidv7拡張またはアプリケーション側での生成が必要です:

-- 必要: CREATE EXTENSION pg_uuidv7;
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
    customer_id BIGINT NOT NULL,
    total NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

Goでの生成例:

import "github.com/google/uuid"

func NewOrderID() uuid.UUID {
    // uuid v7: timestamp + random (google/uuid v1.6.0+が必要)
    id, _ := uuid.NewV7()
    return id
}

分散の仕組み: 同じミリ秒内に生成されたUUID v7の値は、タイムスタンプ部分こそ共通ですが、その後の74ビットのランダム/サブミリ秒ビットが異なります。ソート順ではこれらのビットがタイムスタンプの後に来るため、同じミリ秒内の値はそのミリ秒のキー空間全体に散らばります。テーブルが成長し、1ミリ秒分のキー空間が複数リーフページにまたがるようになると、同時挿入が異なるページに到達する。競合は劇的に軽減され、単一ページへの集中から、ページ範囲への分散に変わります。

正直な注意点: この分散効果はインデックスサイズに比例します。小さな新規テーブルでインデックスが数ページに収まるうちは、ランダムビットがあっても同じページに到達します——分散先が足りないからです。テーブルの成長とともに効果が発現する。右端リーフ競合が問題になるスケール(大きなテーブル、高いINSERTレート)では、分散は十分に機能します。ただし、挿入は時間経過とともに右方向にトレンドし続けます——ホットスポットが単一ページから領域に広がる、という変化です。

トレードオフ:

  • 16バイト vs 8バイト。 プライマリキーインデックスのサイズがおよそ2倍に。外部キーインデックスも同様。累積的に効いてきます。
  • 比較コスト。 UUID比較は整数比較より高コスト。JOINに影響。
  • TOASTの懸念はなし——16バイトではTOASTは発生しません。しかし、インデックスと外部キー全体にわたるサイズ影響は無視できません。

選択肢4:UUID v4はプライマリキーに使わない
#

これを選択肢に含めたのは、「使うな」と言うためです。

UUID v4は完全にランダムで時間コンポーネントがない。B-tree全体に書き込みを美しく分散します。しかし、局所性と範囲スキャン性能を破壊し、キャッシュ圧力を劇的に増大させる。ポイントルックアップは動きますが、順序や空間的局所性に依存するアクセスパターンはすべて打撃を受けます。

外部公開用に予測不可能なIDが必要なら、UUID v4はセカンダリカラムに。プライマリキーはまともなものを使いましょう。API設計のためにB-treeを犠牲にする必要はありません。

比較表
#

キータイプ サイズ 書き込み分散 範囲スキャン キャッシュ局所性 シャーディング適性
BIGSERIAL 8B ❌ 右端集中 ✅ 自然な順序 ✅ 小さな作業セット ❌ ホットシャード
UUID v4 16B ✅ 完全ランダム ❌ 順序なし ❌ 高いキャッシュ圧力 ✅ 均等分散
UUID v7 16B ⚠️ 分散(インデックスサイズに依存) ✅ 時間順序 ⚠️ 最新データに良好 ✅ 均等分散
ULID 16B ⚠️ 分散(インデックスサイズに依存) ✅ 時間順序 ⚠️ 最新データに良好 ✅ 均等分散
Snowflake 8B ⚠️ ワーカー間でバースト競合軽減、ワーカー内は単調 ✅ 時間順序 ✅ ワーカーごとに連続 ⚠️ ワーカー数に依存

Snowflake IDについて補足。複数のアプリケーションインスタンスを運用しているなら(Kubernetes上ではほぼ確実にそうでしょう)、各インスタンスはワーカー/マシンIDビットに基づく異なるキー範囲でIDを生成します。異なるワーカーからの同時挿入がツリーの異なる領域に向かうため、バースト時の競合は軽減される。ただし、Snowflake IDはグローバルには依然として単調増加であり、書き込みはインデックスの右端に向かい続けます。軽減されるのはバースト競合であって、構造的な右方向パターンではありません。

規模別の判断指針
#

1. 並行INSERT < 500/秒程度(大多数のシステム): BIGSERIALが最適解。シンプルで小さく、読み取りとJOINが速い。存在しない問題を最適化する必要はありません。

2. LWLock:BufferContentの競合がPKインデックスで確認された場合: 上記の診断クエリで右端リーフを確認。そこから段階的に:

  • まず手軽に: インデックスのfillfactorを下げて余裕を確保。
  • 既存システム、PK変更不可: 時間範囲パーティショニングでキー戦略を変えずに分散。
  • 新規テーブル/サービス: UUID v7。範囲スキャンに十分な時間順序と、書き込み分散に十分なランダム性を兼ね備えます。

3. 誰かがUUID v4をプライマリキーに提案してきたら: 範囲スキャン戦略について聞いてみてください。相手が固まるのを見届けましょう。

面接の答え合わせ
#

冒頭の面接に戻ります。私が求めていた回答はこうです:

「インフラのスケーリングを検討する前に、データ構造レベルのボトルネックがないか確認します。pg_stat_activityでピーク書き込み時のLWLock:BufferContentの集中を見て、pg_buffercacheで特定のインデックスページが不均衡にホットでないか調べます。シーケンシャルプライマリキーに対する高い並行INSERTで右端リーフの競合が起きているなら、水平スケーリングでは解決しません——アプリケーション層でキー分散に対処する必要があります。」

リードレプリカもコネクションプーリングも重要です。しかし処方の前に診断が要る。私たちエンジニアは、インフラのスケーリングに手が伸びがちです——目に見えるし、ベンダーが提供し、クレジットカードで買えるから。データ構造の病理はそうはいかない。より深い分析を要し、クラウドプロバイダのダッシュボードには現れません。

「もっとハードウェアが必要」と「データモデルを変える必要がある」を見分けられるエンジニアこそ、チームに欲しい人材です。

プライマリキーは、ORMのデフォルトではなく、書き込みパターンに基づいて選びましょう。 BIGSERIALはActiveRecordのデフォルト、Djangoのデフォルト、あらゆる入門ガイドのデフォルト。95%のアプリケーションにとって正しいデフォルトです。しかし残りの5%——本格的な並行書き込みスループットを求めるシステム——に該当するなら、AWSの料金計算機に手を伸ばす前に、なぜそれがボトルネックになり得るのかを理解してください。

移行の前に計測を。処方の前に診断を。そしてPostgreSQLのせいにするのはやめましょう——アプリケーションが作り出した競合パターンなのですから。


この記事の診断クエリはPostgreSQL 14以降で動作します。pg_buffercacheはcontribモジュールで、CREATE EXTENSION pg_buffercache;で有効化できます。

関連記事

天井は同じだ。床が上がっただけ。
·87 文字·1 分
Devlog Ai Software-Engineering Economy Future-of-Work
Goでモジュラーモノリスを設計する:構成、境界、実践パターン
·173 文字·1 分
Go Architecture Golang Modular-Monolith Architecture
Goプロジェクトの構成方法(実用的な慣習、ルールじゃない)
·147 文字·1 分
Go Architecture Golang Project-Structure Monolith Best-Practices
配管地獄を止める:`bs` を作っている話
·80 文字·1 分
Go Devops Build-Systems Bs Engineering