July 24, 2026

Postgres の LISTEN/NOTIFY は実際にスケールします | DBOS

0
Postgres の LISTEN/NOTIFY は実際にスケールします | DBOS


Postgres LISTEN/NOTIFY は、拡張性がないと述べた人気のあるブログ投稿のせいで、悪い評価を受けています。もし本当なら、それは残念なことです。LISTEN/NOTIFY は、Postgres データベースを使用して低遅延の永続的な通知、ストリーム、パブリッシュ/サブスクライブを可能にする強力なツールだからです。この主張は間違っていません。NOTIFY には、グローバル ロックの使用から生じる、直感的ではなく文書化されていないパフォーマンス特性があります。ただし、「非直感的な動作」は「スケーラブルではない」ことと同じではありません。このブログ投稿では、LISTEN/NOTIFY ベースのストリームを大規模に最適化し、単一の Postgres サーバー上でミリ秒スケールのレイテンシで 1 秒あたり 60,000 件の書き込みを達成した方法を示します。

LISTEN/NOTIFYによる低遅延ストリーミング

Postgres でサポートされるストリームの基本設計は単純です。ストリームの各部分 (LLM 応答トークンなど) が新しい行となるストリーム テーブルを作成し、テーブルに挿入してストリームに書き込みます。

Postgres の LISTEN/NOTIFY は実際にスケールします | DBOS

難しいのは、次のチャンクがいつ到着するかわからないため、ストリームから読み取ることです。解決策の 1 つはポーリングです。フィードの最後に各リーダーに新しいビットをポーリングしてもらいます。ただし、世論調査の規模はあまり大きくありません。ポーリング間隔の設定が長すぎると、対話型の使用例 (オンライン チャットなど) に対して遅延が長すぎます。ただし、クエリ間隔の設定が小さすぎると、競合する対話者がデータベースを圧倒します。

より良い解決策は、LISTEN/NOTIFY です。これにより、読者は、新しい作品がストリームに公開されたという作家からの通知を待つのをブロックできます。そうすれば、読者はアンケートにリソースを無駄にせず、新しいフィードが到着したときにすぐに目を覚ますことができます。

LISTEN/NOTIFY ベースのストリームの元の実装では、ストリーム テーブルのトリガーによって、新しいストリームが書き込まれるたびに通知を送信する関数が起動されました。読者はこれらの通知を待ち、新しいフィードで目覚めました。

この実装は正しく、低遅延を実現しましたが、大規模な場合はスループットが低下しました。大規模な Postgres データベースを使用しても、1 秒あたり 2.9K を超えるストリーム書き込みをサポートできませんでした。興味深いことに、Postgres リソース (CPU、メモリ、IOPS) を目に見えて消費することなくクラッシュしました。ご想像のとおり、根本原因は元の「LISTEN/NOTIFY がスケーラブルではない」問題、つまり Postgres が NOTIFY 中に発生するグローバル デッドロックでした。しかし、なぜ Postgres はこれを行うのでしょうか?また、Postgres 通知の利点を失わずにこれを最適化するにはどうすればよいでしょうか?

排他的な LISTEN/NOTIFY ブロック

この問題を理解するには、Postgres の LISTEN/NOTIFY が実際にどのように動作するかを調べる必要があります。

パフォーマンス低下の主な原因は、Postgres で NOTIFY を呼び出すトランザクションをコミットするときにグローバル排他ロックを取得する必要があることです。このロックは、トランザクションがコミットを開始すると取得され、トランザクションが完全にコミットされ、その内容が fsync() でディスクにフラッシュされるまで解放されません。

Postgres では、通知がトランザクションのコミット順に送信されることが保証されているため、このロックが必要です。これを強制するために、すべての出力通知をグローバル内部キューに保存します。その順序は、それらの通知を送信するトランザクションのコミット順序と正確に一致する必要があります。このキューへの通知の追加は、コミットの一部としてトランザクション的に実行する必要があります。ただし、コミットにかかる時間は変動する可能性があるため、Postgres はトランザクションが完了するまでコミット順序をトランザクションに割り当てません。

これにより、順序付けの問題が発生します。通知を含むトランザクションはコミット順序でキューに入れられる必要がありますが、コミット順序はコミットが完了するまで定義されません。解決策は、通知を含むトランザクションのコミットをシリアル化するグローバル ロックです。これにより、コミット順序が事前に定義され、内部通知キュー内で適切に順序付けられるようになります。

この排他的ロックにより、観察されたパフォーマンスの低下が説明されます。ストリーム テーブル内のトリガーから NOTIFY を呼び出すため、すべてのストリーム書き込みには NOTIFY への呼び出しが含まれます。コミットするには、各ストリーム書き込みでグローバル ロックを取得し、ディスクへのフラッシュを含むコミット期間中そのロックを保持する必要があります。これは、バッチ コミット (単一の fsync() で多くのトランザクションをまとめてコミットする) などの一般的な Postgres 最適化を除き、ストリーム書き込みを順次コミットする必要があることを意味します。その結果、ストリームへの書き込みは Postgres がトランザクションを実行できる速度よりも速く完了できず、このデッドロックが発生します。これは、CPU やディスクなどの重大な Postgres リソースの消費が見られなかった理由も説明しています。すべてのトランザクションがグローバル ロックを介してシリアル化されていたため、消費はありませんでした。

余談ですが、この問題に関連する Postgres パッチについてオンラインで議論がありました。このパッチ (Postgres 19 でリリースされる予定) では、グローバル デッドロックは解消されず、私が観察したデッドロックも修正されません。代わりに、多くの通知チャネルがあり、各リスナーが特定のチャネルのみを待機するという狭いケースに最適化されます。

リッスン/通知の最適化

LISTEN/NOTIFY でサポートされるストリームを高速化するには、このボトルネックを解決する必要があります。重要な観察は、ストリームや他の多くの LISTEN/NOTIFY アプリケーションでは、通知自体が真実の情報源ではないということです。代わりに、リーダーに ping を送信して、データベース テーブル (実際の信頼できる情報源) に新しいデータがあるかどうかを確認するだけです。その結果、通知はグローバルに順序付けされたり、完全に永続的である必要がなくなり、通知をメモリに保存し、単一のバッチ トランザクションで定期的にクリアすることで NOTIFY を最適化し、グローバル ロックの競合を大幅に削減できます。

バッファリングと NOTIFY バッチ処理により、ブロックが回避されます。グローバル ロックは、個別のストリーム書き込みごとではなく、バッファーがフラッシュされるときにのみ取得する必要があるためです。これは、バックグラウンドでバッファが解放されている間、バッチ コミットなどの Postgres の最適化を利用して、高いスループットを達成しながら、個々のスレッドの書き込みを迅速に続行できることを意味します。

バッファーを採用すると、新たな問題が発生します。つまり、通知がバッファリングされている間にプロセスがクラッシュすると、通知が配信されなくなります。この問題を解決するために、ストリーム リーダーにフォールバックを追加します。ストリーム リーダーは、通知を待つだけでなく、定期的にデータベースをポーリングして、ストリームが通知なしで書き込まれたかどうかを確認します。このポーリングの頻度は低くてもよいため (配信されなかった通知のフォールバックにすぎないため)、パフォーマンスに大きな影響を与えることはありません。

この最適化されたソリューションを分析すると、パフォーマンスが大幅に向上していることがわかります。同時リーダーが存在する場合、15 ~ 100 ミリ秒の遅延を達成しながら、1 秒あたり最大 60,000 のストリーム書き込み (以前の 20 倍) を実行できます。最大スループットでは、Postgres CPU が最大限に活用されており、データベースが競合でロックされているのではなく、実際には飽和していることがわかります。

もっと詳しく知る

すべてのベンチマークは GitHub で入手できます: github.com/dbos-inc/dbos-postgres-benchmark

スケーラブルで信頼性の高いシステムの構築に興味がある方は、ぜひご連絡をお待ちしております。 DBOS の目標は、Postgres を活用した持続可能な実行を可能な限りシンプルかつパフォーマンスの高いものにすることです。チェック:



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *