断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!
俺がバックエンドエンジニアによく投げる質問がある。
「RDS上のPostgreSQLが、ピーク時の書き込みスループットで頭打ちになっている。水平スケーリングのアプローチを説明してくれ。」
だいたい返ってくるのは教科書通りの答えだ。読み取りが多いクエリにはリードレプリカ。コネクションプーリングにはPgBouncer。予算が許せばAuroraかもしれない。シャーディングにはCitus。応急処置としてインスタンスクラスを上げる。
どれも理にかなっている。そしてどれも、先に聞くべき問いを飛ばしている — プライマリキーの設計が、ハードウェアでは絶対に直らない競合パターンを生み出していないか?
この記事のテーマはそのパターンだ — 単調増加キーにおける、右端リーフの競合。このトピックはネット上の俗説だらけだから、このブログのいつもの基準を守ることにする。以下の数字は全部、実際に走らせた結果だ — PostgreSQL 18.4をpodmanコンテナで動かし(6 CPU、shared_buffers=512MB、synchronous_commit=off — この選択については後で説明する)、pgbenchで40の同時クライアントが、300万行を事前に投入したテーブルに1行ずつINSERTする。ハーネスは記事の最後に置いてある。結果は俗説より雑然としているけど、だからこそ公開する価値がある。
誰も測らない、みんなが語る問題 #
どのチュートリアルも、どの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のたびに、シーケンスの次の番号が振られる。
でもB-treeは、リーフページ全体でキーをソート順に保存する。キーが単調増加のとき — 1, 2, 3, 4, … — 新しいキーは常に既存のどのキーよりも大きい。つまりすべてのINSERTが、インデックスの最右端のリーフページを狙う。
実はPostgreSQLはここを賢く処理している。B-treeのコードには「fastpath」最適化があって(nbtinsert.cの_bt_search_insert)、各バックエンドが最右端リーフのブロック番号をキャッシュするから、INSERTのたびにルートからツリーを辿る必要がない。効率的だ — でも並行処理下では、その効率そのものが争いを一点に集める。タプルを挿入するには、バックエンドはそのバッファページに排他のcontent lockを取らなきゃいけなくて、それを持てるのは一度に1バックエンドだけだ。40バックエンドがINSERTしているなら、39が列になって、たった1枚の8KBページを待っている。
理屈はここまで。実機ではどう見えるか。
実際に起きているところを見る #
BIGSERIALテーブルに対する20秒間のpgbench実行中(40クライアント、約183,000 inserts/sec)、pg_stat_activityを0.5秒ごとにサンプリングして、アクティブなバックエンドが何を待っているか数えてみた。
321 LWLock:BufferContent
62 Client:ClientRead
20 LWLock:XidGen
8 IPC:ProcarrayGroupUpdate
LWLock:BufferContent — 「メモリ上のデータページへのアクセス待ち」 — が支配的だ。どのサンプル時点でも、40バックエンドのうちだいたい3分の1が、ページロックの順番待ちだった。
どのページか? 実行の途中でうまく捕まえられれば、pg_buffercacheが教えてくれる。実行開始から5秒後のスナップショットを、PKインデックスに絞って見る。
hot_block | pinning_backends | max_block
-----------+------------------+-----------
31863 | 9 | 31875
31864 | 6 | 31875
31,875ブロックのインデックスの中で、一番熱い2ページはブロック31,863と31,864 — まさに右端だ。これが最右端リーフ(と、分割したばかりの後継ページ)で、9バックエンドが同時にピンしている瞬間を撮った写真だ。理屈じゃない、ブロック番号そのものだ。
この診断について、注意点が2つある。ネット上に出回っているこの手のクエリ(この記事の以前のバージョンも含めて)には抜けがあるからだ。pg_buffercacheにはreldatabaseのフィルタが要る — relfilenodeの値はデータベースをまたいで衝突するから。そしてpinning_backendsはあくまで代理指標で(ロック待ちじゃなくピンの数)、マイクロ秒単位で変わる。だから両方のクエリは、負荷がかかっている最中にループで実行しなきゃいけない。アイドル状態で1回スナップショットを撮っても、何も見えないし、「問題なし」と誤解するだけだ。
俗説が飛ばす部分:スループット #
つまりBIGSERIALは、時間の3分の1を1枚のページの順番待ちに使っている。俗説によれば、これが書き込みを直列化してスループットの上限を作る — 「シーケンシャルキーは絶対に使うな」というアドバイスの前提全部がこれだ。同じ20秒の実行を、5種類のテーブル全部、同じ箱でやってみる。
| キー戦略 | tps | 平均レイテンシ | BufferContentサンプル数 |
|---|---|---|---|
| BIGSERIAL | 183,577–201,954* | 0.218 ms | 321 |
UUID v7(ネイティブuuidv7()) |
152,023 | 0.263 ms | 248 |
UUID v4(gen_random_uuid()) |
202,120 | 0.198 ms | 上位waitに0件 |
| BIGSERIAL、ハッシュパーティション ×8 | 212,612 | 0.188 ms | 108 |
| BIGSERIAL、fillfactor 70 | 207,357 | 0.193 ms | 276 |
* 2回実行した結果の範囲。この箱では実行ごとに約10%のばらつきがあるから、一桁%の差はノイズとして扱うこと。
この表はよく見てほしい。俗説が言わないことを語っているからだ。
- 競合は本物だ。 wait-eventの指紋は理論どおりにキー戦略を追いかける。完全ランダムなv4は
BufferContentを消し去り、ハッシュパーティショニング(独立した8つの最右端リーフ)はそれを3分の1に削り、v7はBIGSERIALのすぐ近くにいる。 - でもスループットの崩壊は起きていない — このスケールでは。 どの変種も200k inserts/secのノイズ範囲に収まる。ただしUUID v7だけは一番遅い — 取り除いた競合の価値より、増えた16バイトキーとWAL量のコストのほうが大きかった。
- 6コアで40クライアントというこの規模では、右端リーフの待ち行列は見える指紋であって、まだ天井じゃない。この待ち行列のコストは、並行度がコア数をはるかに超えたとき — 何百というバックエンド、もっと大きい箱 — に効いてくる。ロックの待ち行列は非線形に劣化するけど、ページの分散はそうならないからだ。
ハーネスについて、正直に言っておくことが一つある。synchronous_commit=offだ。これをオンにすると、今回の実行は全部WALフラッシュ(IO:WALSync)がボトルネックになって、インデックスのパターンはほとんど関係なくなる — これ自体が、この記事で一番大事な診断ポイントだから、ステップゼロにしておく。
診断:まず退屈なボトルネックを消す #
プライマリキーを疑う前に、ピーク時の書き込みでバックエンドが実際に何を待っているか確認しよう。
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND pid <> pg_backend_pid()
GROUP BY 1, 2
ORDER BY 3 DESC;
負荷がかかっている間、ループで実行する(psqlの\watch 0.5)。それから切り分ける。
- 上位に
IO:WALSync、LWLock:WALWrite、IO:DataFileWrite? それはI/Oバウンドだ — コミットのレイテンシ、WALフラッシュ、ディスク。RDSでは圧倒的に多い天井で、Performance Insightsに映るのもこれだし、どんなキー戦略でも動かせない。ここで読むのをやめていい。この記事の話じゃない。 LWLock:extend? リレーションの拡張 — ヒープが育っている症状で、大量の追記でよく一緒に起きるけど、これもキー選びとは関係ない。- 上位に
LWLock:BufferContentが居座っている? じゃあページを特定しよう。CREATE EXTENSION pg_buffercache;して、また負荷中にループで。
SELECT c.relname, b.relblocknumber, b.pinning_backends,
(pg_relation_size(c.oid) / 8192) - 1 AS max_block
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE b.reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
AND b.pinning_backends > 1
ORDER BY b.pinning_backends DESC
LIMIT 10;
PKインデックスの一番熱いブロックが右端にあれば(relblocknumber ≈ max_block)、さっきと同じ、最右端リーフの写真が撮れたということだ。
この指紋が見えないなら、ここで読むのをやめて、機能開発に戻ろう。 君のBIGSERIALキーは問題ない。この症状が出るには、高い並行INSERTレート、メモリに載っているインデックス(つまりI/Oが支配的な待ちじゃない)、そして1ミリ秒あたり十分な数のバックエンドが、1ページに本物の待ち行列を作ることが必要だ。
B-treeの内側で本当に起きていること #
単調キーのINSERTの流れを、単純化するとこうなる(nbtreeの内部実装)。
- 対象のリーフを見つける。 fastpathがバックエンドごとに最右端リーフのブロックを覚えていて、そこに直行する。
- そのバッファページの排他content lockを取得する。これが直列化のポイントだ。
- インデックスタプルを挿入する。
- ページが満杯なら分割する — 新しいページを割り当て、タプルを移動し、親のdownlinkを更新し、両方のページと親に排他ロックを保持したまま、全部をWALに記録する。
- ロックを解放して戻る。
単調キーでは、ステップ4がメトロノームみたいに起きる。最右端リーフが満杯になり、分割して、新しい最右端リーフがその待ち行列を受け継ぐ。(「ロックのパーティショニングが効くらしい」と読んだことがあるかもしれないけど、効かない。BufMappingLockのパーティショニングは、PostgreSQL 8.2から存在して9.5で16から128パーティションに増えたものだけど、これはバッファのルックアップテーブルを分散させる、まったく別系統のロックだ。全バックエンドが同じページのcontentロックを欲しがっているとき、分散させるものが何もない。)
シーケンス自体について、ひとこと — いつも誰かが聞くから。nextval()は、シーケンスの1ページに対する短いクリティカルセクションだ。極端なレートだと、その1ページのリレーションへのバッファ待ちとして出てくることがある。もし出てきたら、シーケンスのCACHE(デフォルト1)を上げれば、バックエンドごとに値を事前確保できる。今回の箱では、それが上位waitに食い込むことは一度もなかった。争う価値があるのはインデックスページのほうだ。
だから、面接の質問にあったスケーリングのメニューは、どれもこれに触れない。
- スケールアップ はCPUを買うだけだ。待ち行列があるのはロックであって、コアじゃない。形は同じで、天井が上がるだけ。
- リードレプリカ は書き込みスループットにまったく触れない。
- シーケンシャルIDをシャードキーにしたシャーディング は一番残酷な冗談だ — 各シャードが自分のB-treeを持つのに、現在の書き込みは全部1つのシャードに集中する。(
customer_idやハッシュでシャーディングするなら、話は別だ。)
シーケンシャルIDが正解であるケース #
ここからが、ネットが飛ばしがちで、俺のベンチマークがちょうど裏付けた部分だ。
キャッシュ局所性が抜群だ。 ホットな作業セットは、最右端リーフとその親パス — わずか数ページ。ランダムキーのINSERTと比べてみるといい。そっちはまともな書き込み性能を出すのにインデックス全体をキャッシュしたがる。300万行のシードデータが、これを別の形で具体的に見せてくれた。BIGSERIALのPKインデックスは64 MB、UUIDのほうは90 MB — 外部キーが1個絡む前から、すでに+40%だ。
追記型パターンとの相性が抜群だ。 時系列データ、イベントログ、最新データを読むワークロード全部 — シーケンシャルキーは、タダで物理的なクラスタリングをくれる。
JOINが速い — 主な理由は比較コストじゃなくて、キャッシュ密度だ。1ページあたりのエントリ数が多く、1回のルックアップで見るページ数が少ない。この効果は、そのテーブルを参照する全部の外部キーインデックスで積み重なる。
ポーリング型のCDCが簡単。 WHERE id > @last_processedは、ポーリングパターンにおいて無敵だ。
そして、さっきの表のとおり — 6コアで約200k inserts/secなら、BIGSERIALはUUID v7に完全に勝った。 大多数のPostgreSQLデプロイメントは、右端リーフの待ち行列が天井になるほどの並行度には、そもそも届かない。ここで閾値の数字をでっち上げるつもりはない — この記事の以前のドラフトは「500 inserts/sec」と書いていたけど、それはもう削除した。正直な答えは、さっきのwait-eventループを、君の箱で、君の負荷で実行することだからだ。
問題が確認できたら:選択肢を計測する #
選択肢1:FILLFACTOR — 俗説の対策は、実は悪化させる #
Stack Overflowに転がっているアドバイスはこうだ。インデックスのfillfactor(B-treeのデフォルトは90)を下げて、「余裕を残して分割を遅らせる」。この記事の以前のバージョンも、同じアドバイスを繰り返していた。でも実際に計測してみたら、まさにこのワークロードに関しては、逆効果だった。
単調なINSERTの場合、nbtreeは完成したページをfillfactor分だけ詰めて、右に進む(nbtsplitloc.c:最右端の分割では、左側のページがfillfactor%だけ埋まった状態で残る)。予約された空き領域は、INSERTの位置より手前のページに座っている — 厳密に増加していくキーが、二度と戻ってこない場所に。
setting | avg_leaf_density | bytes per row | BufferContent samples
---------------+------------------+---------------+------------------------
default (90) | 90.68 | 22 | 321
fillfactor 70 | 70.24 | 28 | 276
リーフの密度は、ぴったりfillfactorの値に落ち着いて、そのまま永遠に変わらない。インデックスは1行あたり約27%大きくなり、各ページは次の分割までに吸収できるタプルが減る(分割は減るどころか増える)。そして競合の指紋は変わらない。fillfactorの余裕は、散らばったINSERTやHOT寄りのUPDATEパターンのためのものだ — このワークロードとは正反対。やめておけ。
選択肢2:ハッシュパーティショニング — キーを変えずに分散する #
どうしてもBIGSERIALを維持しなきゃいけないなら、これがメカニズムそのものに効く構造的な対策だ。しかも、8バイトキーを保ったまま、さっきの表の中で他の全部に勝った変種でもある。
CREATE TABLE orders (
id BIGSERIAL,
customer_id BIGINT NOT NULL,
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
) PARTITION BY HASH (id);
CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... p1 through p7
8パーティション → 8つの独立したB-tree → 8つの最右端リーフ。連続したidが違うパーティションにハッシュされるから、ライブトラフィックが分散する。BufferContentサンプルは321から108に落ちて、tpsもこの中で一番よかった。ここでPKが機能しているのは、まさにパーティションキーがidそのものだからだ — PostgreSQLはパーティションキーがプライマリキーの一部であることを要求する。
その制約こそが、よく提案される代替案 — 時間範囲でのパーティショニング — がこの問題を直さない理由でもある。理由は2つ。まず、PARTITION BY RANGE (created_at)だと、PRIMARY KEY (id)はそもそも許されない。(id, created_at)に広げる(orders(id)へのシンプルな外部キーとはお別れ、id単体でのグローバルな一意性ともお別れ)か、黙ってPKを外すかのどちらかになる。もう一つ、現在のINSERTは全部現在のパーティションに、その1つの最右端リーフに集中する — ホットスポットをパーティション境界の裏に移しただけで、「直った」と言っているに過ぎない。時間パーティショニングがその複雑さに見合うのは、保持期間の管理とバックフィルのためであって、ライブINSERTの競合対策のためじゃない。
選択肢3:UUID v7 — 正しい理由、正直なメカニズム #
PostgreSQL 18以降なら、これはもう依存ゼロだ(リリースノート)。
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuidv7(),
customer_id BIGINT NOT NULL,
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- SELECT uuidv7(); → 019f32b1-fe4e-7607-8e62-481b03d48dfe
(17以下なら、pg_uuidv7拡張か、アプリ側での生成 — google/uuidの≥1.6.0にあるuuid.NewV7() — がその隙間を埋めてくれる。)
UUID v7(RFC 9562)は、上位ビットに48ビットのミリ秒タイムスタンプを埋め込む — だから範囲スキャンやソートは引き続き機能する — そして残り74ビット(12ビットのrand_a + 62ビットのrand_b)を、ランダムかサブミリ秒のデータで埋める。
ここからは、大抵の記事(そしてまた、この記事の以前のバージョンも)より丁寧にメカニズムを言う。新しいv7の値は、それでも前のミリ秒の全部の値より後にソートされる。書き込みの分散が存在するのは、同じミリ秒のコホートの中だけだ。 だから、これがスケールするのは1ミリ秒あたりのINSERTレートであって、インデックスのサイズじゃない。今回の実行の約180 inserts/msだと、16バイトキーの1コホート丸ごとが、だいたい1枚の8KBリーフに収まってしまう — だからv7の競合の指紋(248)は、v4(0)よりBIGSERIAL(321)に近い場所に居座った。PG 18にはさらに2つの注意点がある。ネイティブなuuidv7()は、rand_aをサブミリ秒のタイムスタンプの端数で埋めるから(ドキュメント)、値はより順序立つ方向に動く、ランダムになる方向じゃない。そして、同一ミリ秒のコホートが複数ページにまたがって本当に負荷を分散するには、10の5乗以上のinserts/secくらいのオーダーが必要になる。
だから、v7を選ぶなら本物の利点のためにしよう — 分散生成(シーケンスへの往復がいらない、アプリやエッジでIDを作れる)、シャーディングとの相性、時間順序、推測されにくさ。リーフ競合の緩和は、極端なレートでしか届かないボーナスだと思っておくこと。コストは残る。16バイトキー(インデックスサイズ+40%、計測済み)、それが全部の外部キーで積み重なること、そして今回の実行ではBIGSERIALに対して約15%のスループット低下だ。
選択肢4:UUID v4 — 分散は最高、選択は最悪 #
v4は、上位waitからBufferContentを完全に消し去った、唯一の変種だ。それでも、ほとんど全員にとって間違ったプライマリキーであることに変わりはない。時間成分なし、順序なし、局所性なし — すべてのINSERTがランダムなページに着地するから、インデックス全体が作業セットになって、範囲スキャンはノイズまで劣化する。予測不可能な公開IDが必要なら、v4はセカンダリのユニークカラムに置いて、PKはまともなものを維持しよう。API設計のためにB-treeを苦しめる必要はない。
比較表 #
| キータイプ | サイズ | 書き込み分散 | 範囲スキャン | キャッシュ局所性 | シャーディング適性 |
|---|---|---|---|---|---|
| BIGSERIAL | 8B | ❌ 最右端リーフのみ | ✅ 自然 | ✅ 小さなホットセット | ❌ ホットシャード |
| BIGSERIAL + ハッシュパーティション | 8B | ✅ N個の最右端リーフ | ⚠️ パーティションごと | ✅ N個の小さなホットセット | ❌ ホットシャード |
| UUID v4 | 16B | ✅ 完全ランダム | ❌ なし | ❌ インデックス全体がホット | ✅ 均等 |
| UUID v7 | 16B | ⚠️ 同一ms内のみ | ✅ 時間順序 | ⚠️ 最新データに良好 | ✅ 均等 |
| ULID | 16B | ⚠️ 同一ms内のみ | ✅ 時間順序 | ⚠️ 最新データに良好 | ✅ 均等 |
| Snowflake | 8B | ⚠️ ワーカーごとの範囲 | ✅ 時間順序 | ✅ ワーカーごとに連続 | ⚠️ ワーカー数に依存 |
Snowflakeには補足が要る。複数のアプリインスタンスがあれば、各ワーカーのIDビットが独自の範囲を持つから、バースト時の競合はツリーのいろんな領域に分散する — でも値はグローバルには依然として増加していくから、書き込みは相変わらず右端に向かう傾向がある。軽減するのはバーストであって、構造そのものじゃない。
俺が本当に聞きたかった答え #
面接の質問に戻ろう。強い答えは、診断の順番を正しく並べる。
「インフラをスケーリングする前に、ピーク時の書き込みでバックエンドが何を待っているか確認します。WALSyncやIOPSなら、それはストレージの天井です — Performance Insightsに映りますし、速いコミットパスかもっと良いストレージで直ります。もしPKインデックスの最右端ブロックにLWLock:BufferContentが居座っていて、しかもインデックスが完全にキャッシュされているなら、それはデータ構造のボトルネックです — どれだけ水平スケーリングしても、1枚の8KBページの待ち行列は直りません。代わりに、キーの分散を変えます。」
インフラのスケーリングはクレジットカードで買えるものだから、みんな真っ先に手を伸ばす。データ構造の病理は、クラウドプロバイダーのダッシュボードには映らない — でも、さっきの実行が示すとおり、誰でも実行できる2つのクエリにはちゃんと映る。料金計算機より先にwait eventを確認するエンジニア。それが俺のチームに欲しい人材だ。
意見としてのまとめ #
- デフォルトはBIGSERIAL(今どきの書き方なら
GENERATED ALWAYS AS IDENTITY)。小さくて、速くて、シンプル — そして今回の実行では、この記事のテーマである競合を「抱えながら」、UUID v7よりたくさんINSERTできた。 - この順番で診断する。 wait-eventのループ → I/O waitならストレージの話、そこで終わり →
BufferContentが居座っている → ホットブロックのクエリ → 最右端リーフを確認。両方のクエリとも、実際の負荷下でループさせる必要がある。アイドル状態のスナップショットは嘘をつく。 - 単調キーにはfillfactorを使わない — 計測済み。競合は変わらず、インデックスは27%太り、分割は増える。これは別のワークロード向けの対策だ。
- キーを維持しなきゃいけないなら、ハッシュパーティション — 競合とスループットの両方でベースラインに勝った、唯一の対策だ。時間範囲パーティショニングはライブINSERTの競合を直さないし、こっそりシンプルなPKを奪っていく。
- UUID v7を選ぶなら、リーフ競合のためじゃなくアーキテクチャのため — 分散したID発行とシャーディングとの相性は本物だ。書き込みの分散が実際に現れるのは約10の5乗以上のinserts/secからで、PG 18なら依存ゼロ(
uuidv7())。 - v4を絶対にPKにしない。 常にセカンダリカラムに。
- この手の記事に出てくる閾値の数字は、全部信用しないこと — 俺の古い「500/sec」も含めて、もう削除した。君の箱でのwait-eventループこそが、その閾値だ。
自分で再現する #
podman run -d --name pg18 -p 5432:5432 -e POSTGRES_PASSWORD=x \
docker.io/library/postgres:18-alpine \
-c shared_buffers=512MB -c synchronous_commit=off -c max_connections=200
# seed 3M rows into each variant table, then per table:
pgbench -U postgres -n -f insert.sql -c 40 -j 6 -T 20 postgres
# insert.sql: \set cid random(1, 1000000)
# INSERT INTO orders_serial (customer_id, total) VALUES (:cid, 9.99);
# and in a second session during the run, sample twice a second:
psql -c "SELECT wait_event_type||':'||wait_event, count(*) FROM pg_stat_activity
WHERE state='active' AND pid <> pg_backend_pid() GROUP BY 1 ORDER BY 2 DESC" \
-c "\watch 0.5"
synchronous_commit=offは、このハーネスの中で本当に仕事をしている。WALフラッシュの待ちを取り除くから、インデックスの経路が見えるようになる。オンにすると — 大半の本番環境ではこれが現実だけど — WALが支配的になって、プライマリキーが問題になる可能性はさらに下がる。この非対称性こそが、この記事の結論だ。移行の前に計測を、処方の前に診断を。そして、自分のワークロードにはそもそも存在しないかもしれない競合パターンを、PostgreSQLのせいにするのはやめよう。