- Postgres LISTEN/NOTIFY のグローバル排他ロックは単純な実装のスループットを制限するが、通知をバッファリングして一括送信すれば、単一サーバーで最大毎秒6万件のストリーム書き込みを処理できる
- NOTIFY を呼び出したトランザクションは、通知のコミット順序を保証するため、コミットと
fsync()が完了するまでグローバルロックを保持し、その結果コミットが直列化され、グループコミットも活用できない - ストリームテーブルへのすべての書き込みごとにトリガーで NOTIFY を呼び出す初期実装は低レイテンシを提供したが、CPU・メモリ・IOPS を十分に使い切れないまま、毎秒2,900件でボトルネックに達した
- 通知ではなくデータベーステーブルを信頼できる情報源とし、メモリに集めた通知を定期的に1つのトランザクションで送信すれば、ロック取得回数を大幅に減らせる
- プロセス障害によりバッファ内の通知が失われる可能性は、低頻度のポーリングで補完し、同時読み取り環境でも15〜100msのレイテンシと従来比20倍のスループットを達成する
Postgres で実装した低レイテンシのストリーム
- Postgres ベースのストリームは、各ストリーム片を
streamsテーブルの新しい行として保存し、LLM 応答トークンも1つの片になり得る - 読み取り側では次の片がいつ到着するか分からないため、単純なクエリだけでは効率よく待機しにくい
- 定期ポーリングは、間隔が長いとオンラインチャットのような対話型用途でレイテンシが大きくなり、短いと同時ポーラーがデータベースを圧倒する
- LISTEN/NOTIFY を使うと、読み取りプロセスはブロック状態で待機し、新しい片が書き込まれたという通知とともに即座に起床するため、不要なポーリングを避けられる
書き込みごとに送った NOTIFY のボトルネック
- 初期実装では、
streamsテーブルに新しい片が記録されるたびにトリガーが関数を実行してNOTIFY を1回ずつ送信し、読み取りプロセスは通知を待ってから新しい片を読む - 正確性と低レイテンシは確保できたが、大規模な Postgres データベースでも毎秒2,900件を超えるストリーム書き込みを維持できなかった
- ボトルネック発生中も CPU・メモリ・IOPS 使用率は目立って高くならず、原因は NOTIFY のコミット経路にあるグローバルロックだった
コミット順序を保証するグローバルロック
- NOTIFY を呼び出したトランザクションは、コミット開始時にグローバル排他ロックを取得し、完全にコミットされて内容が
fsync()によりディスクへ書き込まれるまで解放しない - Postgres は通知がトランザクションのコミット順に配信されることを保証し、すべての送信通知を、コミット順序と正確に一致しなければならないグローバルな内部キューに保存する
- 通知をキューに追加する処理もコミットの一部としてトランザクション的に扱う必要があるが、トランザクションごとにコミット時間が異なるため、コミット完了前には順序を決められない
- グローバルロックは、通知を含むトランザクションのコミットを直列化して順序を事前に確定し、内部通知キューにも同じ順序で追加されるようにする
コミットの直列化がスループットを制限する仕組み
- すべてのストリーム書き込みがトリガー経由で NOTIFY を呼び出すため、各書き込みトランザクションはコミット全体とディスクフラッシュの間、グローバルロックを保持する
- トランザクションが順番にコミットされるため、複数のトランザクションを1回の
fsync()で処理する Postgres のグループコミットを活用できない - スループットは Postgres が個別トランザクションをコミットする速度を超えられず、処理がロック待ちになるため CPU とディスクも十分に使われない
- Postgres 19 に含まれる予定のパッチはグローバルロックを除去しないため、このボトルネックを解消できない
- 代わりに、多数の通知チャネルがあり、各リスナーが特定の1チャネルだけを待つ限定的なケースを最適化する
通知のバッファリングと一括送信
- ストリームをはじめとする多くの LISTEN/NOTIFY 用途では、通知は信頼できる情報源ではなく、実データが保存されたテーブルを確認せよというシグナルにすぎない
- この構造では通知自体に完全なグローバル順序や完全な耐久性を持たせる必要がないため、メモリにバッファリングしたうえで、定期的に1つのバッチトランザクションで送信できる
- グローバルロックは個々のストリーム書き込みごとではなく、バッファを空にするときだけ取得する
- 個々の書き込みはバックグラウンドでの通知送信から分離されて高速に進み、グループコミットなどの Postgres 最適化を活用してスループットを高められる
通知喪失を補完する低頻度ポーリング
- 通知がメモリに残っている間にプロセスが停止すると、その通知が配信されない可能性がある
- 読み取りプロセスは通知を待ちながら、データベースを定期的に問い合わせて、通知なしに記録されたストリームデータがあるかを確認する
- このポーリングは失われた通知だけを復旧する補助手段なので、低頻度で実行でき、性能への影響も大きくない
スループットとレイテンシ
- 最適化された実装は、同時読み取りプロセスがある環境で最大毎秒6万件のストリーム書き込みを処理し、初期実装より20倍高いスループットを記録した
- スループットを高めた状態でも、レイテンシは15〜100msの範囲を維持する
- 最大スループットでは Postgres の CPU が完全に使われており、ロック競合ではなくデータベース自体が実際に飽和状態に達したことを示している
- ベンチマークコード全体は dbos-postgres-benchmark で確認できる
1件のコメント
Hacker News の意見
スケーラビリティは連続的な尺度であり、二分法ではありません。毎秒6万件は、あるシステムにとっては必要量の10万倍多く、別のシステムにとっては10万分の1しかないかもしれません。よくある開発者のミスとして、「早すぎる最適化」よりも、スケーリング特性が合わない技術選定を挙げたいです
小さすぎる技術は限界を超えると失敗が明確ですが、過度にスケール可能な技術も運用負荷や制約をもたらします。よりリッチなモデルで開発工数を大きく減らせる小規模システムに、そうした技術を導入するのも悪い選択です
LISTEN/NOTIFY の限界は注意すべき程度には低いので、悲観的な最大負荷を計算したあとでも最低10倍の余裕を持たせるのがよいですが、多くのプロジェクトには十分です。データベースとの統合性、可用性、別サービスの運用が不要という利点があるため、無条件に除外すべき選択肢ではなく、以前示されていた毎秒2千件でさえ、1つのメッセージを秒単位で処理するシステムには大きな数字です
実際に想定される規模に適度な余裕を加えて設計し、追加のスケーラビリティが実質的に無料で得られる場合にだけ、それ以上を選ぶのがよいです。数千ドルでより大きなハードウェアを買える場合や、スケーラビリティ以外は同等の選択肢なら、大きいほうを選べばよいでしょう
LISTEN/NOTIFY と Rust GraphQL サブスクリプションブローカーを組み合わせて大きな成功を収めました。サブスクリプションは数万件ありましたが、LISTEN 接続はホストごとに1つ、合計で3〜4個だけでした
すべての変更を各ホストに送り、ホストが実際のユーザーサブスクリプションを管理し、何を発行するかを決めていました。Ruby や Node のホスト数百台を Rust ホスト数台に置き換えると構造を大きく単純化でき、スケールしないと思われがちな方式でもかなりうまく機能します
CTO として働いていた当時、全サービスで1日あたり約10万件を処理していたものが、数百万件、最終的には数千万件まで成長しました。その過程で、あるエンジニアがデータモデルとの強い一貫性を活用しようとして、LISTEN/NOTIFY のセマンティクス上にキューを構築しました。理解しにくいものではなく、別の保存・転送レイヤーも不要にできたので、当時は合理的に見えました
しかし、自作した機能をスケールさせる中で PostgreSQL の内部挙動を回避しなければならず、非常に扱いづらくなり、もっと早く別のシステムへ移るべきでした。スケーラビリティも良くなく、RDS で原因を特定しにくいディスク競合が激しくなり、そのテーブルの VACUUM も悪夢でした。馴染みのあるキューを PostgreSQL の内部機能で見慣れない形に実装したため、他のエンジニアは怖がってデバッグやオーナーシップを避けました
スキーマやインデックスといった細部を除いた核心的な教訓は、常にシンプルで予測可能な技術から選ぶべきだということです。非常に強いデータ一貫性が必須でないなら、インフラ構成要素が1つ増えたとしても、SQS や Redis キューのように API 契約上シンプルなキューを使い、残りをそれに合わせるほうがよいです。中核データストア1つが担う機械的責務は少ないほどよかったです
分散システムのエコシステムが発展するにつれ、各構成要素に何ができるかを理解できるようになります。最初は少ない構成要素で始め、実際に必要になったときに追加するほうが、より健全なシステムを作れます
Postgres、そして今では SQLite まで適切に活用する DBOS がずっと気に入っています。既存の CRUD スタックにもほとんど手間なく導入できます
耐久性のあるワークフローを使い始めると、適用できる場所が次々に見えてきます。最近はメール1通1通を耐久性のあるワークフローと見なし、ユーザーと相手、エージェント、GitHub や Attio のようなツールが順番に流れに参加するような実験をしています
https://housecat.com/blog/gmail-durable-workflows-sandbox-vm
こうした記事はたいてい、それぞれの問題、理解、解決策を独立して評価した結果です。ツールのデフォルト設定で特定の性能を期待したという理由だけで、専門性の不足と呼ぶのは微妙で、誰もが失敗を通じて学び続けます
実験に96コア・384GB RAMのデータベースサーバーを使った点(https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...)は非常に重要で、明確に示すべきでした。データベースは垂直スケールできますが、それにも限界があります。誰がどこから接続するかも性能と全体の遅延に影響します
毎秒6万件は大きく見えるかもしれませんが、実際にシステムを壊すのは平常時のトラフィックではなく瞬間的なトラフィック急増です。大企業でなければ、このような大型サーバーから始めることはないでしょう。リードレプリカやリージョン間冗長化を含めると、本番データベースクラスター1つに10万ドルを超える費用がかかります
関連記事と思われる: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - 2025年7月、コメント321件
記事で最も重要な部分である、コンシューマーがどこまで読んだかを追跡し、代替経路で新しいメッセージを取得できるようにする オフセットまたはシーケンス番号の割り当て 方法が抜けているように思う。いくつか方法はあるが、複雑さやロック競合なしに解決するのは簡単ではなく、実装を誤るとコンシューマーとの競合状態も起きる。たいてい書き込み側は、状態テーブルの単一行などにロックをかけてイベントトピックの次の番号を割り当てることになるだろう
最善の方法が何なのか気になる。変更データキャプチャ(CDC)を読んで、割り当てられたイベント番号を別テーブルに書き込むツールは悪くないかもしれないが、レイテンシが長くなる可能性があり、そのCDC処理系がNOTIFYも実行する必要がある
コンシューマー側では、バッチ処理でスループットを大きく高められる用途もある。その場合はLISTEN/NOTIFYなしでコンシューマーを繰り返し実行し、毎回未処理の新しいメッセージをすべて処理して、反復の間に最後のシーケンス番号を保存すればよい
LISTEN/NOTIFYを初めてサポートしたリリースにはロック実装に問題があり、性能問題があったと記憶している。ここで批判されている以前の記事も、最初の段落の直後の訂正文でその点を修正していた
訂正日が5月8日なら、7月24日の記事は、この機能はスケールしないと述べた有名記事が悪意をもって書かれたものでもなく、当時の基準では間違っていなかった可能性もあることを認める必要がある
代わりに、通知チャネルが多く、各受信者が特定の1チャネルだけを待つ、より限定的なケースを最適化するものだ
最後に確認したとき、LISTEN/NOTIFYには通知データに 8,000バイトの上限 があり、明らかにスケールしない側面があった。通知を行として保存してIDだけを渡せないデータなら使いにくい
Webゲームのイベントは状態変更を説明する一時的なデータで、データベースに保存する理由がなく、8,000バイトを超える可能性もあったので、この用途には合わなかった
記事ではグローバルキューのロック競合を扱っているが、固定サイズのグローバルキューのもう一つの問題には触れていないように思う。あるチャネルの遅い受信者が1ついるだけで、全チャネルへの書き込みを止められる可能性があった。少なくとも数年前にはそのような障害パターンが可能で、今は変わっているかもしれない