PostgreSQLのシーケンス:その「舞台裏」と、高負荷環境での付き合い方
PostgreSQLを長く触っていると、`SERIAL`や`IDENTITY`列は空気のような存在になりますよね。`CREATE TABLE`の際に深く考えずにおまかせで書く。でも、ふと「大規模トラフィックが押し寄せる環境で、こいつがボトルネックになったらどうするか?」と立ち止まったことはありませんか?
今日は、シーケンスの「裏側」を少し掘り下げてみたいと思います。教科書には載っていない、現場でハマりがちなポイントの話です。
シーケンスは「トランザクションの外」にいる
まず基本の確認ですが、シーケンスの最大の特徴は「トランザクションの影響を受けない」という点です。
例えば、`nextval()`を呼んだ後にトランザクションを`ROLLBACK`しても、シーケンスの値は元に戻りません。これはデータの一貫性というより、「同時実行性の確保」を優先した結果です。もしシーケンスがトランザクション管理下にあれば、誰かが値を確保した瞬間にロックが掛かり、高並列環境ではアプリケーションのレスポンスが劇的に低下してしまいます。
この設計思想のおかげで、PostgreSQLは非常にスケーラブルな採番を実現できているわけですが、同時に「シーケンスの値に欠番が出るのは仕様である」という事実も受け入れなければなりません。
なぜ「キャッシュ」がパフォーマンスを左右するのか
`CREATE SEQUENCE`の構文を見たとき、皆さんは`CACHE`オプションを意識していますか?
デフォルトでは `CACHE 1` となっていますが、高負荷なシステムではこの設定が命取りになります。シーケンスはメモリ上に現在の状態を保持していますが、`CACHE 1`だと、`nextval()`が呼ばれるたびに、それをディスク上のシステムカタログ(`pg_sequence`)に書き戻す必要があります。
つまり、「採番のたびにI/Oが発生する」のです。
もし秒間数千のINSERTが走るテーブルでこれを行うと、シーケンスへのアクセスだけでCPUとI/Oが飽和します。これを回避するために、`CACHE 50`や`CACHE 100`といった設定を検討すべきです。メモリ上でまとめて値を確保しておくことで、ディスクI/Oを劇的に削減できます。
ただし、注意点が一つ。サーバーがクラッシュした際、キャッシュされていた値は「未使用のまま」消滅します。結果として、IDに大きな飛び番が発生します。「IDは連番であるべき」というアプリケーション側のロジックが強い場合は要注意です。
高並列環境での「ロック競合」という壁
キャッシュを増やしてもなお、ボトルネックになることがあります。それは、複数のバックエンドが同じシーケンスオブジェクトを激しく更新し合うことで発生する、共有メモリ上のラッチ(Lock)競合です。
現代のマルチコア環境では、シーケンスへのアクセスが競合のホットスポットになることが珍しくありません。このレベルまで到達したら、以下のようなアーキテクチャの変更を検討すべきです。
- シーケンスを分割する: 役割ごとにシーケンスを分ける、あるいは水平分割(シャーディング)のキーとしてIDを扱う設計にする。
- UUIDの採用: そもそも「中央集権的な採番」をやめる。`gen_random_uuid()`はPostgreSQL 13以降、非常に高速です。採番の競合を完全に排除できるため、高並列な分散システムではUUIDがデファクトスタンダードになりつつあります。
最後に:僕たちが意識すべきこと
シーケンスは単なる「自動採番マシン」ではありません。それは、「一貫性とパフォーマンスのトレードオフ」を調整するためのレバーです。
- 小規模・中規模なら `IDENTITY` 列で十分。
- 負荷が高いなら `CACHE` を見直す。
- 超高負荷、あるいは分散環境なら UUID や Snowflake ID のような分散採番アルゴリズムへ移行する。
技術選定において、「なんとなく」で決めるのではなく、その背景にあるアーキテクチャの制約を理解して選ぶ。そんなエンジニアであり続けたいですね。
皆さんの現場では、シーケンスの設計で苦労したことはありますか? もし「こんな地雷を踏んだ」というエピソードがあれば、ぜひ共有してください。データベースの深淵は、まだまだ面白い場所ですよ。
コメント