プログラミング
PostgresのLISTEN/NOTIFYは実際にスケーリングする
Postgres LISTEN/NOTIFY actually scales (dbos.dev)
要約
PostgresのLISTEN/NOTIFY機能は、グローバルロックに起因するスケーラビリティの問題で悪評がありますが、本記事ではこの問題を解決し、60K件/秒のスループットとミリ秒単位のレイテンシを実現する最適化手法を解説しています。通知のバッファリングとバッチ処理により、グローバルロックの競合を大幅に削減し、Postgresのパフォーマンスを最大限に引き出す方法を示しています。
全文翻訳
PostgresのLISTEN/NOTIFYは、それがスケーリングしないと主張する有名なブログ記事の影響もあり、悪い評判を受けています。もしそれが本当なら、LISTEN/NOTIFYは低レイテンシで耐久性のある通知、ストリーム、Pub/SubにPostgresデータベースを使用できる強力なツールであるため、残念なことです。その非難は間違っていません。NOTIFYは、グローバルロックの使用に起因する、直感的ではなく文書化されていないパフォーマンス特性を持っています。しかし、「直感的でない動作」は「スケーリングしない」ことと同じではありません。この記事では、PostgresデータベースでLISTEN/NOTIFYベースのストリームを大規模に最適化し、ミリ秒単位のレイテンシで単一のPostgresサーバーで毎秒60,000件の書き込みを達成する方法を示します。
LISTEN/NOTIFYによる低レイテンシストリーミング
Postgresベースのストリームの基本的な設計はシンプルです。ストリームテーブルを作成し、各ストリームチャンク(例えば、LLM応答トークン)を新しい行として格納します。そして、テーブルに行を挿入することでストリームに書き込みます。
難しいのはストリームの読み取りです。なぜなら、次のチャンクがいつ到着するか分からないからです。一つの解決策はポーリングです。各リーダーに新しいチャンクのためにストリームの終端をポーリングさせます。しかし、ポーリングはスケーラビリティが悪いです。ポーリング間隔が高すぎると、インタラクティブなユースケース(例:オンラインチャット)にはレイテンシが高すぎます。しかし、ポーリング間隔が低すぎると、同時実行されるポーラーがデータベースを圧倒してしまいます。
より良い解決策はLISTEN/NOTIFYです。これにより、リーダーは新しいチャンクがストリームに公開されたことをライターからの通知を待ってブロックできます。そのようにして、リーダーはポーリングでリソースを無駄にせず、新しいストリームチャンクが到着したときにすぐに起動します。
LISTEN/NOTIFYベースのストリームの初期実装では、ストリームテーブルのトリガーが、新しいストリームチャンクが書き込まれるたびに通知を送信する関数を発火させていました。リーダーはこれらの通知を待ち、新しいストリームチャンクが到着すると起動しました。
この実装は正しく、低レイテンシを実現しましたが、大規模になるとスループットが低くなりました。大きなPostgresデータベースを使用しても、毎秒2.9K件のストリーム書き込みしか維持できませんでした。興味深いことに、Postgresのリソース(CPU、メモリ、IOPS)をほとんど消費せずにボトルネックになりました。ご推測の通り、根本原因は元の「LISTEN/NOTIFYはスケーリングしない」という問題でした。つまり、NOTIFY中にPostgresが取得するグローバルロックです。しかし、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ベースのストリームをより高速にするには、このボトルネックを回避する必要があります。重要な観察点は、ストリームやLISTEN/NOTIFYの他の多くのアプリケーションでは、通知自体が真実の情報源ではないということです。代わりに、それらは単にリーダーに新しいデータをチェックするためにデータベーステーブル(実際の真実の情報源)をpingするだけです。結果として、通知はグローバルに順序付けられたり、完全に耐久性がある必要はありません。そのため、通知をメモリ内でバッファリングし、単一のバッチトランザクションで定期的にフラッシュすることでNOTIFYを最適化できます。これにより、グローバルロックの競合が大幅に削減されます。
NOTIFYのバッファリングとバッチ処理は、ボトルネックを回避します。なぜなら、グローバルロックはバッファがフラッシュされるときにのみ取得する必要があり、個々のストリーム書き込みごとではないからです。これは、個々のストリーム書き込みが高速に進むことができ、グループコミットのようなPostgresの最適化を利用して高スループットを得られる一方で、バッファはバックグラウンドでフラッシュされることを意味します。
バッファを採用すると、新しい複雑さが生じます。それは、通知がバッファリングされている間にプロセスがクラッシュすると、それらの通知が決して配信されないということです。この問題を解決するために、ストリームリーダーにフォールバックを追加します。通知を待つことに加えて、ストリームが通知なしで書き込まれたかどうかをチェックするために、定期的にデータベースをポーリングします。このポーリングの頻度は低くすることができます(配信されなかった通知のフォールバックとしてのみ機能するため)、パフォーマンスに大きな影響を与えません。
この最適化されたソリューションのベンチマークでは、大幅なパフォーマンスの向上が見られます。同時実行リーダーが存在する場合、毎秒最大60,000件のストリーム書き込み(以前の20倍)を実行でき、同時に15〜100ミリ秒のレイテンシを実現します。最大スループットでは、PostgresのCPUが完全に利用され、データベースが実際に飽和状態であり、競合によるボトルネックではないことを示しています。
詳細はこちら
すべてのベンチマークコードはGitHubで利用可能です: github.com/dbos-inc/dbos-postgres-benchmark
スケーラブルで信頼性の高いシステムを構築するのが好きなら、ぜひお声を聞かせてください。DBOSでは、Postgresベースの耐久性のある実行を可能な限りシンプルかつ高性能にすることを目指しています。ぜひチェックしてみてください:
クイックスタート: https://docs.dbos.dev/quickstart
GitHub: https://github.com/dbos-inc
Discordコミュニティ: https://discord.gg/eMUHrvbu67
PostgresのLISTEN/NOTIFYは、それがスケーリングしないと主張する有名なブログ記事の影響もあり、悪い評判を受けています。もしそれが本当なら、LISTEN/NOTIFYは低レイテンシで耐久性のある通知、ストリーム、Pub/SubにPostgresデータベースを使用できる強力なツールであるため、残念なことです。その非難は間違っていません。NOTIFYは、グローバルロックの使用に起因する、直感的ではなく文書化されていないパフォーマンス特性を持っています。しかし、「直感的でない動作」は「スケーリングしない」ことと同じではありません。この記事では、PostgresデータベースでLISTEN/NOTIFYベースのストリームを大規模に最適化し、ミリ秒単位のレイテンシで単一のPostgresサーバーで毎秒60,000件の書き込みを達成する方法を示します。低レイテンシストリーミングLISTEN/NOTIFYPostgresベースのストリームの基本的な設計はシンプルです。ストリームテーブルを作成し、各ストリーム