Rules 與 LISTEN/NOTIFY:查詢改寫與即時事件通知 | PostgreSQL
PostgreSQL 的 Rule System 與 LISTEN/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 System | Trigger |
|---|---|---|
| 運作階段 | Rewriter(查詢改寫) | Executor(執行期) |
| 觸發粒度 | Statement Level | Row Level(可選 Statement) |
| 支援操作 | SELECT/INSERT/UPDATE/DELETE | INSERT/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 普遍被視為較難維護的機制:
- 難以除錯:Rule 靜默改寫查詢,
EXPLAIN顯示的是改寫後的計畫 - OLD/NEW 語義不同:Rule 中的 OLD/NEW 指的是查詢條件引用的值,而非每列的實際值
- RETURNING 相容性問題:
DO INSTEAD與RETURNING的交互容易產生非預期結果 - 遞迴風險: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 超過 8KB | pg_notify 會失敗 | Payload 只放 ID,訂閱者自行查詢 |
| ROLLBACK 後通知不送出 | 這是正確行為 | 確保事件與資料變更在同一交易 |
| 連線中斷通知遺失 | LISTEN/NOTIFY 無持久化 | 結合 Outbox Pattern |
| 同交易重複去重 | 相同 channel+payload 只送一次 | 若需區分,加入 UUID 或時間戳 |
| LISTEN 需獨立連線 | 不能使用連線池 | 為 LISTEN 建立專用長連線 |
| Rule 的 OLD/NEW 語義 | 與 Trigger 不同 | 複雜邏輯優先使用 Trigger |
版本演進
| PostgreSQL 版本 | 相關變更 |
|---|---|
| 7.x | Rule System 建立 |
| 9.3 | 簡單 View 自動支援更新,減少 Rule 需求 |
| 9.4 | pg_notify() 函式穩定,LISTEN/NOTIFY 效能改善 |
| 14 | LISTEN 通知記憶體管理優化 |
| 16 | 大量 NOTIFY 效能持續改善 |
總結
PostgreSQL 的事件導向機制各有定位:
- Rule System 在 Rewriter 階段改寫查詢,主要用於 Updatable View;現代開發中建議優先使用 Trigger
- LISTEN/NOTIFY 是輕量級的 Pub/Sub 機制,適合即時快取失效、WebSocket 橋接、微服務事件通知
- Payload 上限 8KB,大資料量只放 ID
- LISTEN/NOTIFY 無持久化——需要可靠性時搭配 Outbox Pattern
- pg_notify() 函式可在 Trigger 中使用,實現「資料變更 → 即時通知」的自動化流程
下一篇,我們將探討 PostgreSQL 的其他程序語言支援——了解如何在 PostgreSQL 中使用 PL/Python、PL/Perl、PL/Tcl 甚至 PL/V8(JavaScript)撰寫 Stored Procedure。