PostgreSQLのLISTEN/NOTIFYは最小限の設定でライブダッシュボードやアラートを実現できます。適した用途、限界、ブローカー導入のタイミングを解説します。

プロダクトのUIで「ライブ更新」と言うと、ユーザーがリフレッシュしなくても何かが起きてすぐ画面が変わることを意味します。ダッシュボードの数値が増える、受信箱に赤いバッジが付く、管理者が新しい注文を目にする、あるいは「ビルド完了」や「支払い失敗」のトーストが出る、といった具合です。肝心なのはタイミングで、実際には1〜2秒でも即時に感じられます。
多くのチームはまずポーリングから始めます:ブラウザが数秒ごとにサーバーに「新しいものは?」と聞く方法です。ポーリングは動きますが、二つの一般的な欠点があります。
まず、次のポーリングまで変化が見えないので遅く感じます。
次に、何も変わっていないときにも繰り返しチェックするためコストがかかります。これを何千人ものユーザーに掛け合わせるとノイズになります。
PostgreSQLのLISTEN/NOTIFYはもっとシンプルなケースのためにあります:「何か変わったら教えて」。繰り返し聞く代わりに、アプリは待機してデータベースが小さな合図を送ったときに反応できます。
UIでちょっとした合図が十分な場合に適しています。例えば:
トレードオフはシンプルさと保証の間にあります。LISTEN/NOTIFYはPostgresに組み込まれていて追加が簡単ですが、完全なメッセージシステムではありません。通知はヒントであり、永続化された記録ではないため、リスナーが切断されていると信号を見逃す可能性があります。
実用的な使い方の1つは:NOTIFYでアプリを目覚めさせ、アプリ側でテーブルから真の状態を読みに行く、というものです。
PostgreSQLのLISTEN/NOTIFYは、データベースに内蔵されたシンプルな呼び鈴のようなものです。アプリはベルが鳴るのを待てますし、システムの別の部分が何かが変わったときに鳴らせます。
通知はチャネル名と任意のペイロードの2つで構成されます。チャネルはトピックラベルのようなもので(例:orders_changed)、ペイロードは短いテキストメッセージ(例:注文ID)です。PostgreSQLは構造を強制しないので、小さなJSON文字列を送ることが一般的です。
通知はアプリケーションコードから(あなたのAPIサーバーがNOTIFYを実行)でも、データベース内のトリガーからでも発行できます(INSERT/UPDATE/DELETE後にトリガーがNOTIFYを実行)。
受信側では、アプリサーバーがデータベース接続を開いてLISTEN channel_nameを実行します。その接続は開いたままになります。NOTIFY channel_name, 'payload'が発行されると、PostgreSQLはそのチャネルをリッスンしている全ての接続にメッセージをプッシュします。アプリはそれを受けて反応します(キャッシュ更新、変更行のフェッチ、ブラウザへのWebSocketイベント送信など)。
NOTIFYは配信サービスではなく合図として理解するのが最適です:
このように使えば、PostgreSQLのLISTEN/NOTIFYは追加インフラなしでライブUI更新を支えられます。
LISTEN/NOTIFYは、UIが必要とするのが「ちょっとした合図(何か変わった)」であり、完全なイベントストリームが不要な場合に強みを発揮します。"このウィジェットをリフレッシュ"や"新しいアイテムがある"のような用途を想像してください。クリックを順序通りに処理するといった使い方には向きません。
データベースがすでにソース・オブ・トゥルースであり、UIをそれに同期させたいときに最もうまく機能します。一般的なパターンは:行を書き、IDを含む小さな通知を送り、UI(またはAPI)が最新状態を取得する、という流れです。
LISTEN/NOTIFYが通常十分な条件は次の通りです:
具体例:社内サポートダッシュボードで「未処理チケット」と「新しいノート」バッジを表示する場合。エージェントがノートを追加するとバックエンドがPostgresに書き込み、ticket_changedをチケットID付きでNOTIFYします。ブラウザはWebSocket経由で受け取り、そのチケットカードだけを再取得します。追加インフラ不要でUIはライブに感じられます。
LISTEN/NOTIFYは初めは素晴らしく感じられますが、限界があります。それらは通知をメッセージシステムのように扱ったときに顕在化します。
最大のギャップは耐久性です。NOTIFYはキュージョブではありません。誰もリッスンしていないときの通知は失われます。リスナーが接続中でもクラッシュ、デプロイ、ネットワークの瞬断、データベースの再起動で接続が切れると、その接続に向けられた通知は戻ってきません。
切断はユーザー向け機能では特に厄介です。ダッシュボードが新しい注文を表示する場合を想像してください。ブラウザタブがスリープし、WebSocketが再接続されると、数件のイベントを見逃してUIが"止まっている"ように見えることがあります。対策は可能ですが、それはもはや「ただLISTEN/NOTIFYするだけ」ではなく、NOTIFYをリフレッシュのヒントにして状態を再構築する設計になります。
ファンアウトも一般的な問題です。1つのイベントが何百、何千ものリスナーを起こす(多くのアプリサーバー、たくさんのユーザー)可能性があります。ordersのような騒がしい1つのチャネルを使うと、1人のユーザーしか気にしていないイベントでも全員が起きます。これがCPUや接続に一斉負荷を与えることがあります。
ペイロードサイズと頻度も罠です。NOTIFYのペイロードは小さく、高頻度のイベントはクライアントが処理するより早く積み重なります。
以下の兆候を監視してください:
その段階では、NOTIFYを「ポーク(軽い合図)」として残し、信頼性をテーブルや専用のメッセージブローカーに移すことを検討してください。
信頼性の高いパターンは、NOTIFYを合図にし、本当のソースはデータベースの行であると扱うことです。通知は見るべきタイミングを伝え、データ自体はテーブルから取得します。
書き込みはトランザクション内で行い、データ変更がコミットされた後に通知を送ります。早すぎる通知はクライアントが起きてデータを見つけられない原因になります。
一般的なセットアップは、INSERT/UPDATEでトリガーが発火して小さなメッセージを送るものです。
NOTIFY dashboard_updates, '{\\\"type\\\":\\\"order_changed\\\",\\\"order_id\\\":123}'::text;
チャネル名は人がシステムをどう考えるかに合わせるとよいです。例:dashboard_updates、user_notifications、またはテナントごとにtenant_42_updatesのようにします。
ペイロードは小さく保ち、識別子とタイプだけ入れ、フルレコードは入れないでください。役立つデフォルトの形は:
type(何が起きたか)id(何が変わったか)tenant_idやuser_idこの形にすると帯域を抑え、通知ログに機密データが漏れるリスクも避けられます。
接続は切れます。計画してください。
接続時に必要なチャネルすべてでLISTENを実行します。切断時は短いバックオフで再接続し、再接続後に再度LISTENします(サブスクリプションは持ち越されません)。再接続後は最近の変更を素早く再取得して、見逃しをカバーしてください。
多くのライブUI更新では再取得が最も安全です:クライアントは{type, id}を受け取り、その後サーバーに最新状態を要求します。
インクリメンタルにパッチを当てると速くできますが、順序のずれや部分失敗で間違いやすいです。中間的な妥協案として、小さなスライス(1件の注文行、1つのチケットカード、1つのバッジ数)だけを再取得し、重い集計は短いタイマーに任せる、という方法があります。
管理ダッシュボードが1つから多数のユーザーが同じ数値を監視する形に移ると、賢いSQLよりも良い運用が重要になります。LISTEN/NOTIFYは引き続き有効ですが、データベースからブラウザへのイベントの流れを整形する必要があります。
一般的なベースラインは:各アプリインスタンスが1つの長寿命接続を開いてLISTENし、接続クライアントに更新をプッシュする構成です。この「インスタンスごとに1リスナー」はシンプルで、小規模なサーバー数であれば十分なことが多いです。
多くのアプリインスタンス(またはサーバーレスワーカー)がある場合は、共有のリスナーサービスを作る方が楽です。1つの小さなプロセスが1度だけリッスンし、その後スタックの他の部分にファンアウトします。ここでバッチ、メトリクス、バックプレッシャーを一ヶ所で扱えます。
ブラウザへのプッシュは通常WebSocket(双方向、インタラクティブUI向け)かSSE(一方向、ダッシュボード向けでシンプル)を使います。どちらでも「全部更新」ではなくorder 123 changedのようなコンパクトなシグナルを送って、UIが必要なものだけ再取得するようにしてください。
UIが動作不良を起こさないように、いくつかのガードレールを追加します:
チャネル設計も重要です。1つのグローバルチャネルの代わりにテナント、チーム、機能で分割するとクライアントは関連するイベントだけ受け取れます。例:notify:tenant_42:billingやnotify:tenant_42:ops。
LISTEN/NOTIFYは簡単に見えるため、チームは素早く導入して本番で驚くことが多いです。ほとんどの問題はそれを耐久的なメッセージキューのように扱うことに起因します。
アプリが再接続すると(デプロイ、ネットワーク切れ、DBのフェイルオーバーなど)、切断中に送られたNOTIFYは失われます。対処法は通知を信号として扱い、データベースを再チェックすることです。
実用的なパターン:実際のイベントをテーブルに保存し(idとcreated_atを付ける)、再接続時に最後に見たid以降のものをフェッチする。
LISTEN/NOTIFYのペイロードは大きなJSON向けではありません。大きいペイロードは解析コストを増やし、制限に当たるリスクを高めます。
ペイロードは\"order:123\"のような小さなヒントにして、詳細はデータベースから読みます。
ペイロードの内容をそのままソース・オブ・トゥルースとしてUI設計するのはよくないです。スキーマ変更やクライアントのバージョン差が問題になります。
クリーンな分離を保ってください:何かが変わったことを通知し、その後通常のクエリで現在のデータを取得します。
行単位の変更で常にNOTIFYするトリガーはシステムを洪水状態にします。特にアクセスの多いテーブルでは深刻です。
意味のある遷移(例:ステータス変更)だけで通知する、騒がしい更新はバッチ処理する(トランザクションごと、または時間窓ごとに1回)か、通知パスから外すことを検討してください。
データベースが通知を送れても、UI側が耐えられないなら意味がありません。イベントごとに再レンダリングするダッシュボードは固まることがあります。
クライアントでデバウンスし、バーストをまとめ、"無効化して再取得"の方針を優先してください。例えば通知ベルは即時更新、ドロップダウンリストは数秒ごとに最大1回の更新に抑えるなどです。
LISTEN/NOTIFYは「何か変わった」の小さな信号でアプリが新しいデータを取りに行く用途に適していますが、フルなメッセージシステムではありません。
構築前に次の質問に答えてください:
LISTEN/NOTIFYで十分なことが多いです。実用的なルール:NOTIFYをペイロードそのものではなく「行を再読み込みして」とする合図として扱えるなら、安全圏にいます。
例:管理ダッシュボードが新しい注文を示す場合、通知を見逃しても次のポーリングやページリロードで正しい数が表示されるなら適合します。しかし"カード請求"や"出荷指示"のような、見逃すと重大な事故になるイベントはNOTIFYだけに頼るべきではありません。
小さな営業アプリを想像してください:ダッシュボードが本日の売上、合計注文数、"最近の注文"リストを表示する一方で、各営業担当者は自分の受注が支払済みや出荷済みになったときに素早い通知を受け取りたいとします。
シンプルなアプローチはPostgreSQLをソース・オブ・トゥルースとして扱い、LISTEN/NOTIFYは「変化があったよ」という軽い合図だけに使うことです。
注文が作成されるかステータスが変わると、バックエンドは同一リクエストで二つのことを行います:行を書き込み(または更新し)、小さなペイロード(通常は注文IDとイベントタイプ)でNOTIFYします。UIはNOTIFYペイロードをフルデータの代わりにはせず、必要なデータを再取得します。
実用的なフローは次の通りです:
orders_eventsへ{\\\"type\\\":\\\"status_changed\\\",\\\"order_id\\\":123}のようなNOTIFYを出す。こうすることでNOTIFYを軽量に保ち、高コストなクエリを制限できます。
トラフィックが増えると限界が見えてきます:イベントのスパイクが単一リスナーを圧倒したり、再接続で通知が見逃されたり、配信と再生が必要になります。そのときはアウトボックステーブル+ワーカー、あるいはブローカーを追加しつつPostgresをソース・オブ・トゥルースとして残すのが一般的です。
LISTEN/NOTIFYは簡単に"何か変わった"と知らせるのに良いですが、フルなメッセージシステムではありません。イベントを配信のソースとして頼り始めたらブローカーを追加すべきです。
次のいずれかが出てきたらブローカーを導入すべきです:
LISTEN/NOTIFYはメッセージを後で保存しません。プッシュ信号であって永続ログではありません。ダッシュボードのリフレッシュ用途には完璧ですが、"請求を実行"や"パッケージを出荷"のような不可欠なワークフローにはリスクがあります。
ブローカーは実際のメッセージフローのモデルを提供します:キュー(作業)、トピック(多対多のブロードキャスト)、保持(数分〜数日のメッセージ保持)、および確認(コンシューマーが処理を確認)。これにより「データベースが変わった」ことと「それによって起きる全てのこと」を切り離せます。
最も複雑なツールを選ぶ必要はありません。一般的に評価される選択肢はRedis(pub/subやstreams)、NATS、RabbitMQ、Kafkaなどです。どれを選ぶかは、単純なワークキューが欲しいのか、多くのサービスへのファンアウトが必要か、履歴をリプレイしたいかに依存します。
大きな書き換えなしに移行できます。実用的なパターンはNOTIFYを目覚まし信号として残しつつ、ブローカーを配信のソースにすることです。
同じトランザクション内で"イベント行"をテーブルに書き込み、そのイベントをワーカーがブローカーに公開するようにします。移行期間中はNOTIFYがUI層に「新しいイベントを確認して」と伝え、バックグラウンドワーカーがブローカーからリトライや監査付きで消費します。
こうするとダッシュボードはスナッピーに保たれ、重要なワークフローはベストエフォート通知に依存しなくなります。
1つの画面(ダッシュボードタイル、バッジ数、"新着通知"のトーストなど)を選んでエンドツーエンドで配線してください。LISTEN/NOTIFYを使えば小さなスコープで役立つ結果を素早く得られますが、範囲を狭く保ち実際のトラフィックで何が起きるかを計測してください。
まずは最もシンプルで信頼できるパターンを採用しましょう:行を書き、コミットし、その後に何かが変わったという小さな信号を出す。UIはその信号を受けて最新状態(または必要なスライス)を取りに行きます。こうするとペイロードを小さく保ち、メッセージの順序が乱れたときの微妙なバグを避けられます。
早めに基本的な可観測性を追加してください。高級ツールは不要ですが、システムが騒がしくなったときに答えが必要です:
契約はシンプルに保ち、文書化してください。チャネル名、イベント名、ペイロードの形(たとえIDだけでも)をリポジトリ内の短い「イベントカタログ」にまとめることでドリフトを防げます。
もし素早く作りたいなら、Koder.ai (koder.ai)のようなプラットフォームを使えば、React UI、Goバックエンド、PostgreSQLを使った最初のバージョンを迅速に出し、要件が明確になってから反復できます。
LISTEN/NOTIFYは、バッジ数やダッシュボードタイルの更新など「何かが変わった」という短い合図があれば十分な場面で使います。通知は再取得のきっかけと考え、実際のデータはテーブルから読み直してください。
ポーリングは定期的にサーバーに「新しいものは?」と聞くため、更新が遅れて見えたり、何も変わっていないのに多くのリクエストを発生させます。LISTEN/NOTIFYは変更時に小さな信号をプッシュするので、通常はより速く感じ、無駄なリクエストを減らせます。
いいえ。ベストエフォートです。リスナーがNOTIFYが発行されたときに切断されていると、その信号を見逃す可能性があります。通知は後で再生されるように保存されません。
小さくしてヒントとして使ってください。一般的なデフォルトはtypeとidを含む小さなJSONです。その後アプリがPostgresに問い合わせて最新状態を取得します。
一般的なパターンは、書き込みがコミットされた後に通知を送ることです。早すぎる通知は、クライアントが起きてデータを見つけられない原因になります。
アプリ側コードは明示的でテストしやすく、理解もしやすいです。トリガーは複数のライターが同じテーブルに書き込む場合や、誰が変更しても一貫した動作を望むときに便利です。
再接続は普通のこととして計画してください。再接続時には必要なチャネルで再度LISTENを実行し、オフライン中に見逃した可能性のある最近の状態を素早く再取得してください。
すべてのブラウザを直接Postgresに接続させないでください。典型的にはバックエンドインスタンスごとに1つの長寿命接続を開き、そこからWebSocketやSSEでブラウザに転送します。UIは必要なデータを再取得します。
適切な消費者だけが起きるようにチャネルを狭くし、騒がしいバーストはバッチ処理してください。クライアント側で数百ミリ秒のデバウンスや重複の集約を行えば、UIやバックエンドの過負荷を避けられます。
配信の耐久性やリトライ、コンシューマーグループ、順序保証、監査や再生が必要になったら切り替えどきです。通知を見逃すと課金や出荷など重大な問題になる場合は、アウトボックス+ワーカーや専用ブローカーを使ってください。