Web開発
Multigresは、プールされた接続全体でListen/Notifyをサポートします
Multigres Supports Listen/Notify Across Pooled Connections (multigres.com)
要約
PostgresのLISTEN/NOTIFY機能は、多数のクライアントがリスニングするとパフォーマンスが大幅に低下するというスケーリングの問題を抱えています。Multigresはこの問題を解決し、接続プーリングを使用しながらも通知速度を一定に保ちます。この記事では、Multigresがどのようにしてこのスケーリング問題を克服し、Postgresネイティブの動作と完全に互換性のあるリスニング/通知メカニズムを実現しているのか、その仕組みとエッジケースについて解説しています。
全文翻訳
PostgresのLISTEN/NOTIFYにはスケーリングの問題があります。リスナーが増えるほど、すべての通知が遅くなり、数千に達すると桁違いに遅くなります。
Multigresは、クライアントから接続をプールするという、この機能が依存する唯一のものを維持しながら、そのラインをフラットに保ちます。
それがどのようにそれを実現するか、そしてPostgresと全く同じように動作させるために正しく行う必要があるエッジケースが、この記事の主題です。
LISTEN/NOTIFYの簡単な復習
LISTEN/NOTIFYはPostgresの組み込みパブリッシュ/サブスクライブメカニズムです。
セッションは名前付きチャネルをサブスクライブします:LISTEN events;
別のセッションがそれに発行します:NOTIFY events, 'cache invalidated: user:42';
現在 events をリスニングしているすべてのセッションは、通知元のバックエンドのPID、チャネル名、およびペイロードを含む非同期メッセージを受け取ります。
ワイヤ上では、サーバーは通常のリクエスト/レスポンスフローの外で、NotificationResponse(メッセージタイプ 'A')としてクライアントにプッシュします。
アプリケーションは、ポーリングを回避できるため、キャッシュ無効化、ジョブキュー、リアルタイムファンアウトのためにこれに依存しています。
LISTENは現在のセッションをリスナーとして登録し、その登録はそのセッションが終了するとすぐにクリアされます。これは接続ごとの状態です。
一方、配信はより広範です。NOTIFYは、どのバックエンド接続上にあるかに関わらず、チャネルをリスニングしている同じデータベース内のすべてのセッションに到達します。
Postgresは、ディスク上の単一のクラスタワイドキュー(pg_notify/)を使用してこれを実装し、各通知に送信元のデータベースOIDをタグ付けしてデータベースローカルに保ち、リスニングしているすべてのバックエンドにそれを読み取るように信号を送ります。
要するに、登録はセッションごと、配信はデータベースごとです。
なぜプーリングがそれを壊すのか
Postgresがこの機能を何に結びつけているかに注意してください:セッションです。
そして、プーラーの仕事は、クライアントが1つを所有するのを止めることです。
Multigresは、クライアント接続を単一のPostgresバックエンドから意図的に切り離します。クライアントのクエリは、ステートメントごとに異なるバックエンド接続に着地する可能性があります。
永続的なセッションが存在しないため、LISTENはアタッチするものがありません:LISTENは、たまたま利用可能だったバックエンドで実行することはできません。
もしLISTEN events が任意のバックエンドで実行された場合、次のステートメントは別の場所にルーティングされる可能性があり、登録はクライアントがもはや保持していない接続上に stranded されます。
通知はクライアントが見ることができない場所に届くでしょう。
Postgresは、同じインスタンス上の同じデータベースを共有している限り、異なるバックエンド接続間で通知を完璧に配信します。
Multigresはまさにその動作に依存していますが、上記のセッション所有権の問題を解決した後です。
ナイーブな修正(リスニングしている各クライアントを専用のバックエンド接続にピン留めする)は、プーリングが最も重要なワークロード(多数の長寿命リスナー)のためにプーリングを無効にします。
リスナーをプール可能に保つ必要がありました。
共有リスナー接続
コアアイデア:クライアントはリスニングのためのPostgresセッションを所有しません。プーラーが所有します。
各プーラーは、クエリプールとは別に、Postgresバックエンドへの単一の長寿命リスナー接続を維持します。
これは遅延して取得され(いずれかのクライアントが最初にLISTENを発行したとき、それより前ではない)、予約されているため、プールは誰かがリスニングしている限り永久にチェックアウトされているとみなし、決してリサイクルしません。
この1つの接続は、そのプーラーに接続されているすべてのクライアントの代わりにリスニングします。
リスナーはrefcountでチャネルを追跡します:チャネルの最初のサブスクライバーはバックエンドで実際のLISTEN channel をトリガーし、最後のアンサブスクライブは対応するUNLISTENをトリガーします。そのため、クライアントがそれを求めているクライアントの数に関わらず、各チャネルに対して正確に1つのバックエンドサブスクリプションを発行します。
リスナー接続は、サブスクリプション変更のセットを変更するたびに接続をダウンさせて再確立することを避けるために、サブスクリプション変更のセットを変更するたびに接続をダウンさせて再確立することを避けるために、単一ソケット上で読み書きモデルを分割して実行する必要があります。
これにより、チャネルのセットが変更されるたびに接続をダウンさせて再確立することを回避し、通知の損失のウィンドウになります。
これでNOTIFYは自然に発生します。
Multigresは実行時にそれを特別なものとして扱いません — プランナーはそれを通常のクエリとして単一の固定ターゲットにルーティングします。
その固定ルーティングは意図的です:Postgres通知は1つのインスタンスから別のインスタンスにクロスしないため、すべてのNOTIFYはリスナー接続が監視しているのと同じインスタンスに着地する必要があります。そうすれば、2つが出会えます。
NOTIFYは通常のバックエンド接続で実行され、Postgresは同じインスタンス上の別のバックエンドにすぎないプーラーの共有リスナーを含む、そのデータベース内のすべてのセッションに通知を配信します。
これは、Postgresのネイティブなクロスバックエンド配信に完全に依存するステップです:発行者とリスナーは異なる物理接続であり、Postgresがそれらをブリッジするものです。
通知はリスナー接続に着地し、そこからMultigresが配信を引き継ぎます。
ファンアウトパス
Postgresバックエンドから適切なクライアントへの通知を取得するのは、2段階のファンアウトです:一度プール内で、一度ゲートウェイ内で。
下から上へ歩く:
プール → ゲートウェイ。
ゲートウェイは、長寿命のgRPCストリーム、StreamNotificationsを介してプールにサブスクライブします。これはチャネルのセットを受け入れます。
プーラーのリスナーが通知を受信すると、そのチャネルに登録されているすべてのサブスクライバーにファンアウトし、ストリームはそれを各関心のあるゲートウェイに運びます。
ゲートウェイ → クライアント。
ゲートウェイは、各クライアントの接続ごとの状態を保持します。
各リスニング接続には独自の通知キューがあり、ゲートウェイの通知マネージャーは、そのチャネルにサブスクライブしている接続に受信ストリームをファンアウトします。
各キューはバウンドされており、数百の保留中の通知が深いです。
通知配信は非同期であり、遅いクライアントは他のすべての人の共有配信パスをブロックしてはなりません。
接続のバッファがいっぱいになると、オーバーフローはドロップされ、ログに記録されます。これは、Postgres自身のNOTIFYに対するファイアアンドフォーゲットの姿勢(通知は発行されるとベストエフォートであり、永続的なメッセージではない)に一致します。
ワイヤへ。
各クライアント接続は、通知チャネルからプルしてNotificationResponse 'A'メッセージを発行するバックグラウンドライターを実行します。
クエリの途中でさえ、通知はいつでも到着する可能性があるため、ライターはパケットごとに接続のバッファロックを取得して、'A'メッセージがソケットに書き込まれている他のものとインターリーブするのを防ぎます。
クエリが実行中であった間にバッファリングされた通知は、接続がReadyForQueryを送信する前にフラッシュされるため、クライアントはトランザクション境界でサブスクリプションの古いビューを決して見ません。
エッジを正しく取得する
共有リスナーとファンアウトツリーは、ハッピーパスを機能させます。
残りの作業は、コーナーでPostgresと全く同じように動作させることであり、そこで互換性が通常壊れます。
トランザクション内のLISTEN
Postgresでは、LISTENとUNLISTENはトランザクショナルです:BEGIN内で発行すると、トランザクションがコミットされた場合にのみ効果があり、ロールバックすると、実行しなかったかのように扱われます。
Multigresは、実際のサブスクリプション作業がクライアントのトランザクション内には全くない共有接続上で行われるにもかかわらず、それを尊重する必要があります。
ゲートウェイはこれをバッファリングで解決します。
トランザクション内で発行されたLISTEN/UNLISTENはすぐに適用されません — それは接続の状態に対する保留中のアクションとして記録されます。
コミット時に、Multigresは保留中のアクションを順番にリプレイし、トランザクション前のサブスクリプションセットに対する正味の変更を計算します。
したがって、LISTEN a; LISTEN b; UNLISTEN a; を1つのトランザクション内で実行すると、コミット時にbへの単一の正味サブスクライブに解決され、ロールバックは