Rules 與 LISTEN/NOTIFY:查詢改寫與即時事件通知 | PostgreSQL

2026/07/14
Rules 與 LISTEN/NOTIFY:查詢改寫與即時事件通知 | PostgreSQL

PostgreSQLRule SystemLISTEN/NOTIFY 是兩個截然不同的事件導向機制。Rule System 在查詢改寫階段攔截並變換 SQL 語句,而 LISTEN/NOTIFY 則提供輕量級的非同步訊息通道,讓資料庫主動推送事件給應用程式。本文完整解析這兩個機制的原理、實作與最佳實踐。

Rule System 原理

PostgreSQL 在執行一條 SQL 語句時,會經歷多個處理階段:

用戶端 SQL
    │
    ▼
Parser(語法剖析)
    │
    ▼
Rewriter(查詢改寫)  ← Rule System 在此介入
    │
    ▼
Planner/Optimizer(查詢計畫)
    │
    ▼
Executor(執行)

Rule System 在 Rewriter 階段運作,根據預先定義的規則將原始查詢樹(Query Tree)轉換成新的查詢樹。這與 Trigger 在 Executor 階段介入完全不同。

Rule 的介入方式有三種:

  • DO INSTEAD:完全替換原始查詢,原查詢不執行
  • DO ALSO:在執行原查詢的同時,額外執行規則中定義的查詢
  • DO NOTHING:忽略原始查詢(用於條件性攔截)

關鍵特性:Rule 在**查詢層級(Statement Level)**運作,一個 UPDATE 影響 100 列,Rule 只觸發一次;而 Trigger 預設每列觸發一次。

Rules vs Triggers 比較

特性Rule SystemTrigger
運作階段Rewriter(查詢改寫)Executor(執行期)
觸發粒度Statement LevelRow Level(可選 Statement)
支援操作SELECT/INSERT/UPDATE/DELETEINSERT/UPDATE/DELETE/TRUNCATE
條件過濾WHERE(基於查詢條件)WHEN(可存取 NEW/OLD 值)
回傳值可修改資料列
實作語言只能是 SQL任何程序語言
現代推薦度謹慎使用推薦為主要機制

CREATE RULE 語法與範例

CREATE [ OR REPLACE ] RULE rule_name AS
    ON { SELECT | INSERT | UPDATE | DELETE }
    TO table_name
    [ WHERE condition ]
    DO [ ALSO | INSTEAD ] { NOTHING | command | ( command ; command ... ) }

DO INSTEAD:DELETE 重導向為歸檔

-- 建立歸檔資料表
CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);

-- 所有 DELETE 改為移動到歸檔表
CREATE OR REPLACE RULE archive_on_delete AS
    ON DELETE TO orders
    DO INSTEAD (
        INSERT INTO orders_archive
            SELECT OLD.*;
    );

-- 執行 DELETE 時,資料不會被刪除,而是複製到 orders_archive
DELETE FROM orders WHERE id = 42;

DO ALSO:INSERT 時同步記錄日誌

CREATE TABLE orders_audit_log (
    audit_id   BIGSERIAL PRIMARY KEY,
    order_id   INT,
    action     TEXT,
    logged_at  TIMESTAMPTZ DEFAULT now()
);

CREATE OR REPLACE RULE log_on_insert AS
    ON INSERT TO orders
    DO ALSO (
        INSERT INTO orders_audit_log (order_id, action)
        VALUES (NEW.id, 'INSERT');
    );

DO NOTHING:條件性攔截

-- 禁止刪除狀態為 active 的訂單
CREATE OR REPLACE RULE protect_active_orders AS
    ON DELETE TO orders
    WHERE (OLD.status = 'active')
    DO INSTEAD NOTHING;

Rule 實現 Updatable View

Rule 最典型的現代用途是讓 View 支援 INSERT / UPDATE / DELETE:

-- 建立底層資料表
CREATE TABLE employees (
    id         SERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    department TEXT NOT NULL,
    salary     NUMERIC,
    is_active  BOOLEAN DEFAULT true
);

-- 只顯示在職員工的 View
CREATE VIEW active_employees AS
    SELECT id, name, department, salary
    FROM employees
    WHERE is_active = true;

-- 讓 View 支援 INSERT(自動設定 is_active = true)
CREATE OR REPLACE RULE active_employees_insert AS
    ON INSERT TO active_employees
    DO INSTEAD (
        INSERT INTO employees (name, department, salary, is_active)
        VALUES (NEW.name, NEW.department, NEW.salary, true);
    );

-- 讓 View 的 DELETE 轉為軟刪除
CREATE OR REPLACE RULE active_employees_delete AS
    ON DELETE TO active_employees
    DO INSTEAD (
        UPDATE employees
        SET is_active = false
        WHERE id = OLD.id;
    );

注意:PostgreSQL 9.3 之後,簡單的 View 已支援自動更新(Auto-Updatable View)。Rule 方式主要保留用於需要複雜轉換的場景。

Rules 的限制與陷阱

現代 PostgreSQL 開發中,Rule System 普遍被視為較難維護的機制:

  1. 難以除錯:Rule 靜默改寫查詢,EXPLAIN 顯示的是改寫後的計畫
  2. OLD/NEW 語義不同:Rule 中的 OLD/NEW 指的是查詢條件引用的值,而非每列的實際值
  3. RETURNING 相容性問題DO INSTEADRETURNING 的交互容易產生非預期結果
  4. 遞迴風險:Rule 觸發 Rule,可能造成無限迴圈

官方建議:複雜邏輯使用 Trigger 通常比 Rule 更可預測且易於維護。


LISTEN/NOTIFY 即時通知

LISTEN/NOTIFY 是 PostgreSQL 內建的**發布/訂閱(Pub/Sub)**機制:

應用程式A(發布者)           應用程式B(訂閱者)
      │                              │
      │  NOTIFY channel, 'payload'   │  LISTEN channel
      │                              │
      ▼                              │
┌─────────────────────────────┐      │
│      PostgreSQL Server      │      │
│  pg_notify buffer           │◄─────┘
│  (per channel queue)        │
│                             │──────► 非同步推送 ──► 應用程式B
└─────────────────────────────┘

重要特性:

  • 非同步:NOTIFY 在交易 COMMIT 後才送出
  • 連線範圍:通知只送達正在 LISTEN 的活躍連線
  • 無持久化:訂閱者離線時,通知會遺失
  • 自動去重:同一交易中對同一 channel 發送相同 payload,只傳送一次

LISTEN/NOTIFY 基本使用

-- 訂閱者(連線A)
LISTEN order_events;

-- 發布者(連線B)
NOTIFY order_events;

-- 發送含 Payload 的通知(通常使用 JSON 格式)
NOTIFY order_events, '{"action":"created","order_id":42}';

在交易中使用:

BEGIN;

INSERT INTO orders (customer_id, total_amount, status)
VALUES (101, 1500.00, 'pending')
RETURNING id;

-- NOTIFY 被記錄,但要等到 COMMIT 後才真正送出
NOTIFY order_events, '{"action":"order_created","order_id":123}';

COMMIT;
-- 此時訂閱者才會收到通知

pg_notify() 與 Trigger 整合

pg_notify(channel, payload) 是 NOTIFY 的函式版本,可在 PL/pgSQL Trigger 中使用:

-- 通用事件通知 Trigger 函式
CREATE OR REPLACE FUNCTION notify_table_change()
RETURNS TRIGGER AS $$
DECLARE
    payload JSON;
    record_id BIGINT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        record_id := OLD.id;
    ELSE
        record_id := NEW.id;
    END IF;

    -- 組裝 JSON Payload
    payload := json_build_object(
        'table',     TG_TABLE_NAME,
        'schema',    TG_TABLE_SCHEMA,
        'action',    TG_OP,
        'id',        record_id,
        'timestamp', EXTRACT(EPOCH FROM clock_timestamp())
    );

    -- 發送通知到以資料表名稱命名的頻道
    PERFORM pg_notify(TG_TABLE_NAME || '_events', payload::TEXT);

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 將 Trigger 綁定到 orders 資料表
CREATE TRIGGER orders_notify_trigger
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION notify_table_change();

Payload 格式設計

-- 推薦的 Payload 結構
CREATE OR REPLACE FUNCTION build_event_payload(
    p_action     TEXT,
    p_entity     TEXT,
    p_entity_id  BIGINT,
    p_data       JSONB DEFAULT NULL
) RETURNS TEXT AS $$
BEGIN
    RETURN json_build_object(
        'version',   1,                                    -- 版本號
        'action',    p_action,                             -- created / updated / deleted
        'entity',    p_entity,                             -- order / user / product
        'entity_id', p_entity_id,
        'data',      COALESCE(p_data, 'null'::JSONB),
        'timestamp', to_char(now() AT TIME ZONE 'UTC',
                             'YYYY-MM-DD"T"HH24:MI:SS"Z"')
    )::TEXT;
END;
$$ LANGUAGE plpgsql;

Payload 大小限制:單一 Payload 上限為 8000 bytes(約 8KB)。資料量大時,Payload 只放 ID,讓訂閱者自行查詢。

Node.js 監聽實作

const { Client } = require('pg');

async function startOrderListener() {
    // LISTEN/NOTIFY 必須使用獨立的長連線,不能使用 Pool
    const client = new Client({
        connectionString: process.env.DATABASE_URL,
    });

    await client.connect();
    await client.query('LISTEN order_events');

    // 綁定通知事件處理器
    client.on('notification', async (msg) => {
        try {
            const event = JSON.parse(msg.payload);
            console.log(`收到 ${msg.channel} 事件:`, event);

            switch (event.action) {
                case 'INSERT':
                    await handleOrderCreated(event);
                    break;
                case 'UPDATE':
                    await handleOrderUpdated(event);
                    break;
                case 'DELETE':
                    await handleOrderDeleted(event);
                    break;
            }
        } catch (err) {
            console.error('處理通知時發生錯誤:', err);
        }
    });

    // 處理連線錯誤與重連
    client.on('error', async (err) => {
        console.error('資料庫連線錯誤:', err);
        setTimeout(() => startOrderListener(), 5000);
    });
}

startOrderListener().catch(console.error);

Python 監聽實作

psycopg2(同步模式):

import psycopg2
import psycopg2.extensions
import select
import json

def listen_for_events(dsn: str, channel: str):
    conn = psycopg2.connect(dsn)
    # 必須設定 AUTOCOMMIT
    conn.set_isolation_level(
        psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
    )

    cursor = conn.cursor()
    cursor.execute(f"LISTEN {channel};")
    print(f"開始監聽頻道:{channel}")

    try:
        while True:
            if select.select([conn], [], [], 5) == ([], [], []):
                print("等待中... (心跳)")
            else:
                conn.poll()
                while conn.notifies:
                    notify = conn.notifies.pop(0)
                    payload = json.loads(notify.payload)
                    print(f"收到通知 | PID: {notify.pid}")
                    process_event(payload)
    finally:
        conn.close()

psycopg3(非同步模式):

import asyncio
import json
import psycopg

async def listen_async(conninfo: str, channel: str):
    async with await psycopg.AsyncConnection.connect(
        conninfo, autocommit=True
    ) as conn:
        await conn.execute(f"LISTEN {channel}")
        async for notify in conn.notifies():
            payload = json.loads(notify.payload)
            print(f"收到通知:{payload}")
            await process_event_async(payload)

應用場景

即時快取失效

-- 資料更新後通知快取層失效
CREATE OR REPLACE FUNCTION invalidate_cache_on_change()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify(
        'cache_invalidation',
        json_build_object(
            'entity', TG_TABLE_NAME,
            'id',     COALESCE(NEW.id, OLD.id),
            'keys',   ARRAY[
                TG_TABLE_NAME || ':' || COALESCE(NEW.id, OLD.id)::TEXT,
                TG_TABLE_NAME || ':list'
            ]
        )::TEXT
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

WebSocket 橋接架構

資料庫變更 → pg_notify → Node.js LISTEN → WebSocket → 瀏覽器即時更新

相比輪詢(Polling)可顯著降低資料庫負載,達到接近即時的用戶體驗。

微服務事件匯流排

Order Service ──INSERT──► orders table
                              │
                         AFTER INSERT Trigger
                              │
                         pg_notify('domain_events', payload)
                              │
                    ┌─────────┼─────────┐
                    ▼         ▼         ▼
               Inventory   Email     Analytics
               Service    Service    Service
               (LISTEN)   (LISTEN)   (LISTEN)

LISTEN/NOTIFY vs Message Queue

特性LISTEN/NOTIFY專用 MQ(RabbitMQ/Kafka)
訊息持久化
訊息確認(ACK)
訊息重試支援
訊息大小限制8KB通常 MB 級別
多消費者模式廣播可配置為競爭/廣播
基礎設施複雜度極低需額外部署
適合場景輕量即時通知、快取失效高可靠性業務事件

選擇建議

  • 「偶爾遺失可接受」→ LISTEN/NOTIFY
  • 「保證至少一次交付」→ 專用 Message Queue
  • 混合使用(Outbox Pattern):事件寫入資料表(不遺失)+ LISTEN/NOTIFY 觸發消費者讀事件表(避免輪詢)

常見陷阱

陷阱說明解法
Payload 超過 8KBpg_notify 會失敗Payload 只放 ID,訂閱者自行查詢
ROLLBACK 後通知不送出這是正確行為確保事件與資料變更在同一交易
連線中斷通知遺失LISTEN/NOTIFY 無持久化結合 Outbox Pattern
同交易重複去重相同 channel+payload 只送一次若需區分,加入 UUID 或時間戳
LISTEN 需獨立連線不能使用連線池為 LISTEN 建立專用長連線
Rule 的 OLD/NEW 語義與 Trigger 不同複雜邏輯優先使用 Trigger

版本演進

PostgreSQL 版本相關變更
7.xRule System 建立
9.3簡單 View 自動支援更新,減少 Rule 需求
9.4pg_notify() 函式穩定,LISTEN/NOTIFY 效能改善
14LISTEN 通知記憶體管理優化
16大量 NOTIFY 效能持續改善

總結

PostgreSQL 的事件導向機制各有定位:

  1. Rule System 在 Rewriter 階段改寫查詢,主要用於 Updatable View;現代開發中建議優先使用 Trigger
  2. LISTEN/NOTIFY 是輕量級的 Pub/Sub 機制,適合即時快取失效、WebSocket 橋接、微服務事件通知
  3. Payload 上限 8KB,大資料量只放 ID
  4. LISTEN/NOTIFY 無持久化——需要可靠性時搭配 Outbox Pattern
  5. pg_notify() 函式可在 Trigger 中使用,實現「資料變更 → 即時通知」的自動化流程

下一篇,我們將探討 PostgreSQL 的其他程序語言支援——了解如何在 PostgreSQL 中使用 PL/Python、PL/Perl、PL/Tcl 甚至 PL/V8(JavaScript)撰寫 Stored Procedure。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。