PL/pgSQL 程式語言:PostgreSQL 伺服器端程式設計完全指南 | PostgreSQL

2026/07/01
PL/pgSQL 程式語言:PostgreSQL 伺服器端程式設計完全指南 | PostgreSQL

PL/pgSQLPostgreSQL 內建的過程化語言(Procedural Language),讓你在資料庫伺服器端撰寫複雜的業務邏輯。它結合了 SQL 的資料操作能力與程式語言的流程控制能力——變數宣告條件判斷迴圈例外處理,搭配 Plan Caching 機制讓執行效率媲美原生 SQL。從 PG11 開始更引入了 Stored Procedure,支援在程序內進行 Transaction 控制

PL/pgSQL 定位與優勢

PL/pgSQL 是 PostgreSQL 最常用的伺服器端語言,預設已安裝(CREATE EXTENSION 不需要)。它的核心定位是:

  • 減少 Client ↔ Server 往返:將多步驟邏輯封裝在伺服器端,一次呼叫完成
  • Plan Caching:函數內的 SQL 語句在首次執行時編譯並快取執行計畫,後續呼叫直接重用
  • 安全封裝:透過 SECURITY DEFINER 讓使用者透過函數存取資料,而非直接授予表級權限
  • 觸發器邏輯:Trigger Function 必須用 PL/pgSQL(或其他 PL 語言)撰寫
PL/pgSQL 在 PostgreSQL 架構中的位置:

  Client Application
       │
       ▼
  SQL Parser → Planner → Executor
       │                     ▲
       ▼                     │
  PL/pgSQL Interpreter ──────┘
    ├─ Plan Cache(快取 SQL 執行計畫)
    ├─ Variable Management(變數管理)
    └─ Control Flow(流程控制)

Block 結構

PL/pgSQL 的基本執行單位是 Block,語法類似 Oracle PL/SQL:

[ <<label>> ]
[ DECLARE
    -- 變數宣告
]
BEGIN
    -- 執行邏輯
[ EXCEPTION
    -- 例外處理
]
END [ label ];

Block 可以嵌套,內層 Block 可存取外層的變數:

DO $$
<<outer_block>>
DECLARE
    v_total INT := 0;
BEGIN
    RAISE NOTICE '外層 total = %', v_total;

    <<inner_block>>
    DECLARE
        v_total INT := 100;  -- 遮蔽外層同名變數
    BEGIN
        RAISE NOTICE '內層 total = %', v_total;       -- 100
        RAISE NOTICE '外層 total = %', outer_block.v_total;  -- 0(用 label 存取)
    END inner_block;

    RAISE NOTICE '回到外層 total = %', v_total;  -- 0
END outer_block;
$$;

變數宣告

基本型別

DECLARE
    v_id        INT := 0;
    v_name      TEXT;
    v_price     NUMERIC(10,2) := 0.00;
    v_is_active BOOLEAN := TRUE;
    v_created   TIMESTAMPTZ := now();
    v_tags      TEXT[] := '{}';           -- 陣列
    v_config    JSONB := '{}';            -- JSONB

%TYPE、%ROWTYPE、RECORD

宣告方式說明適用場景
v_name table.column%TYPE繼承特定欄位的型別需要與表結構同步
v_row table%ROWTYPE繼承整張表的行結構SELECT * INTO 結果
v_rec RECORD動態結構,型別在賦值時決定結構不固定的查詢結果
CREATE OR REPLACE FUNCTION get_product_info(p_id INT)
RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE
    v_name   products.name%TYPE;       -- 繼承 products.name 的型別
    v_row    products%ROWTYPE;          -- 整行結構
    v_rec    RECORD;                    -- 動態結構
BEGIN
    -- %TYPE:只取一個欄位
    SELECT name INTO v_name FROM products WHERE id = p_id;

    -- %ROWTYPE:取整行
    SELECT * INTO v_row FROM products WHERE id = p_id;

    -- RECORD:動態查詢
    SELECT id, name, price INTO v_rec
    FROM products WHERE id = p_id;

    RETURN format('Name: %s, Price: %s', v_row.name, v_row.price);
END;
$$;

注意%ROWTYPE 在函數建立時綁定表結構,若之後 ALTER TABLE 新增欄位,需重新建立函數。RECORD 則無此限制,但存取不存在的欄位會在執行時報錯。

控制流程

IF / ELSIF / ELSE

CREATE OR REPLACE FUNCTION classify_score(p_score INT)
RETURNS TEXT
LANGUAGE plpgsql AS $$
BEGIN
    IF p_score >= 90 THEN
        RETURN 'A';
    ELSIF p_score >= 80 THEN
        RETURN 'B';
    ELSIF p_score >= 70 THEN
        RETURN 'C';
    ELSIF p_score >= 60 THEN
        RETURN 'D';
    ELSE
        RETURN 'F';
    END IF;
END;
$$;

CASE

-- 簡單 CASE
CASE v_status
    WHEN 'active'   THEN v_label := '啟用';
    WHEN 'inactive' THEN v_label := '停用';
    WHEN 'pending'  THEN v_label := '待審';
    ELSE v_label := '未知';
END CASE;

-- 搜尋 CASE
CASE
    WHEN v_amount > 10000 THEN v_tier := 'VIP';
    WHEN v_amount > 5000  THEN v_tier := 'Gold';
    WHEN v_amount > 1000  THEN v_tier := 'Silver';
    ELSE v_tier := 'Standard';
END CASE;

迴圈

-- 基本 LOOP(無窮迴圈 + EXIT 條件)
LOOP
    v_counter := v_counter + 1;
    EXIT WHEN v_counter > 10;
END LOOP;

-- WHILE 迴圈
WHILE v_balance > 0 LOOP
    v_balance := v_balance - v_payment;
    v_months := v_months + 1;
END LOOP;

-- FOR 整數範圍
FOR i IN 1..10 LOOP
    RAISE NOTICE 'i = %', i;
END LOOP;

-- FOR 反向
FOR i IN REVERSE 10..1 LOOP
    RAISE NOTICE 'countdown: %', i;
END LOOP;

-- FOR 查詢結果(最常用)
FOR v_rec IN
    SELECT id, name, price FROM products WHERE category = 'Electronics'
LOOP
    RAISE NOTICE 'Product: % ($ %)', v_rec.name, v_rec.price;
END LOOP;

-- FOREACH 陣列元素
DECLARE
    v_tags TEXT[] := ARRAY['urgent', 'important', 'review'];
    v_tag  TEXT;
BEGIN
    FOREACH v_tag IN ARRAY v_tags LOOP
        RAISE NOTICE 'Tag: %', v_tag;
    END LOOP;
END;

CONTINUE 可跳過當前迭代:

FOR v_rec IN SELECT * FROM orders LOOP
    CONTINUE WHEN v_rec.status = 'cancelled';
    -- 處理非取消的訂單
    PERFORM process_order(v_rec.id);
END LOOP;

RAISE 訊息與日誌

RAISE 用於輸出訊息或拋出錯誤:

等級說明行為
DEBUG除錯資訊預設不顯示,需調整 client_min_messages
LOG伺服器日誌寫入伺服器 log,不傳給 client
INFO資訊傳給 client
NOTICE提示傳給 client(最常用的除錯手段)
WARNING警告傳給 client,不中斷執行
EXCEPTION錯誤中斷執行,回滾到最近的 EXCEPTION Block 或整個 Transaction
-- 格式化訊息(% 為佔位符)
RAISE NOTICE '處理訂單 #%,金額 $%', v_order_id, v_amount;

-- 拋出自訂錯誤
RAISE EXCEPTION '無效的折扣率:%(必須在 0-100 之間)', v_discount
    USING ERRCODE = 'check_violation',
          HINT = '請檢查 discount 欄位的值';

-- 使用 SQLSTATE 錯誤碼
RAISE EXCEPTION SQLSTATE '23505'
    USING MESSAGE = '訂單編號重複',
          DETAIL = format('order_no = %s 已存在', v_order_no);

EXCEPTION 錯誤處理

CREATE OR REPLACE FUNCTION safe_transfer(
    p_from_id INT, p_to_id INT, p_amount NUMERIC
)
RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE
    v_from_balance NUMERIC;
BEGIN
    -- 檢查餘額
    SELECT balance INTO v_from_balance
    FROM accounts WHERE id = p_from_id FOR UPDATE;

    IF v_from_balance < p_amount THEN
        RAISE EXCEPTION '餘額不足:帳戶 % 餘額 $%,轉帳 $%',
            p_from_id, v_from_balance, p_amount;
    END IF;

    -- 執行轉帳
    UPDATE accounts SET balance = balance - p_amount WHERE id = p_from_id;
    UPDATE accounts SET balance = balance + p_amount WHERE id = p_to_id;

    RETURN 'OK';

EXCEPTION
    WHEN numeric_value_out_of_range THEN
        RETURN '金額超出範圍';
    WHEN undefined_table THEN
        RETURN '表不存在';
    WHEN OTHERS THEN
        -- 捕獲所有未預期的錯誤
        RAISE WARNING '轉帳失敗:% (SQLSTATE: %)', SQLERRM, SQLSTATE;
        RETURN format('ERROR: %s', SQLERRM);
END;
$$;

GET STACKED DIAGNOSTICS 取得詳細錯誤資訊:

EXCEPTION
    WHEN OTHERS THEN
        GET STACKED DIAGNOSTICS
            v_message    = MESSAGE_TEXT,
            v_detail     = PG_EXCEPTION_DETAIL,
            v_hint       = PG_EXCEPTION_HINT,
            v_context    = PG_EXCEPTION_CONTEXT,
            v_sqlstate   = RETURNED_SQLSTATE;

        INSERT INTO error_log (message, detail, hint, context, sqlstate, created_at)
        VALUES (v_message, v_detail, v_hint, v_context, v_sqlstate, now());

        RAISE;  -- 重新拋出原始錯誤

重要:EXCEPTION Block 內部會建立一個隱式 Subtransaction(SAVEPOINT),效能開銷不小。不要在高頻迴圈內使用 EXCEPTION。

SELECT INTO 與 FOUND

SELECT INTO 將查詢結果存入變數,FOUND 指示是否有結果:

SELECT name, price INTO v_name, v_price
FROM products WHERE id = p_id;

IF NOT FOUND THEN
    RAISE EXCEPTION '找不到產品 ID: %', p_id;
END IF;

-- STRICT 模式:結果必須恰好一行,否則報錯
SELECT name INTO STRICT v_name
FROM products WHERE category = p_category;
-- 0 行 → NO_DATA_FOUND
-- 多行 → TOO_MANY_ROWS

FOUND 的時機:每條 SQL 語句都會更新 FOUND,所以要在目標 SQL 之後立即檢查,不要插入其他 SQL。

-- 錯誤示範
SELECT ... INTO v_row FROM table_a WHERE ...;
PERFORM some_function();       -- 這會覆蓋 FOUND!
IF NOT FOUND THEN ...          -- 此 FOUND 是 some_function() 的結果

-- 正確示範
SELECT ... INTO v_row FROM table_a WHERE ...;
IF NOT FOUND THEN              -- 立即檢查
    ...
END IF;
PERFORM some_function();

動態 SQL:EXECUTE … USING

當 SQL 語句需要在執行時動態組合(表名、欄位名不固定),使用 EXECUTE

CREATE OR REPLACE FUNCTION dynamic_count(
    p_table TEXT,
    p_where TEXT DEFAULT NULL
)
RETURNS BIGINT
LANGUAGE plpgsql AS $$
DECLARE
    v_sql   TEXT;
    v_count BIGINT;
BEGIN
    v_sql := format('SELECT count(*) FROM %I', p_table);

    IF p_where IS NOT NULL THEN
        v_sql := v_sql || ' WHERE ' || p_where;
    END IF;

    EXECUTE v_sql INTO v_count;
    RETURN v_count;
END;
$$;

-- 使用
SELECT dynamic_count('orders', 'status = ''active''');

防 SQL Injection 三寶

函數/格式用途範例
format('%I', name)識別符(表名、欄名)→ 加雙引號"my_table"
format('%L', value)字面值 → 加單引號並跳脫'O''Brien'
EXECUTE ... USING $1, $2參數化值 → 最安全參數不嵌入 SQL 字串
-- 推薦寫法:format %I 處理識別符,USING 處理值
CREATE OR REPLACE FUNCTION find_by_column(
    p_table  TEXT,
    p_column TEXT,
    p_value  TEXT
)
RETURNS SETOF RECORD
LANGUAGE plpgsql AS $$
BEGIN
    RETURN QUERY EXECUTE
        format('SELECT * FROM %I WHERE %I = $1', p_table, p_column)
        USING p_value;
END;
$$;

禁止直接拼接使用者輸入到 SQL 字串:

-- 危險!SQL Injection 漏洞
v_sql := 'SELECT * FROM ' || p_table || ' WHERE name = ''' || p_name || '''';

-- 安全做法
v_sql := format('SELECT * FROM %I WHERE name = $1', p_table);
EXECUTE v_sql USING p_name;

回傳型別

回傳單一值

CREATE FUNCTION get_count() RETURNS BIGINT ...

回傳複合型別

-- 回傳自訂型別
CREATE TYPE product_summary AS (
    name TEXT,
    total_sales NUMERIC
);

CREATE FUNCTION top_product() RETURNS product_summary ...

RETURNS TABLE(回傳表格)

CREATE OR REPLACE FUNCTION search_products(p_keyword TEXT)
RETURNS TABLE(id INT, name TEXT, price NUMERIC)
LANGUAGE plpgsql AS $$
BEGIN
    RETURN QUERY
        SELECT p.id, p.name, p.price
        FROM products p
        WHERE p.name ILIKE '%' || p_keyword || '%'
        ORDER BY p.price DESC;
END;
$$;

-- 使用
SELECT * FROM search_products('laptop');

RETURN NEXT(逐行回傳)

CREATE OR REPLACE FUNCTION generate_fibonacci(n INT)
RETURNS SETOF BIGINT
LANGUAGE plpgsql AS $$
DECLARE
    a BIGINT := 0;
    b BIGINT := 1;
    temp BIGINT;
BEGIN
    FOR i IN 1..n LOOP
        RETURN NEXT a;
        temp := a + b;
        a := b;
        b := temp;
    END LOOP;
END;
$$;

SELECT * FROM generate_fibonacci(10);

RETURN QUERY EXECUTE(動態回傳)

CREATE OR REPLACE FUNCTION dynamic_search(
    p_table TEXT, p_column TEXT, p_value TEXT
)
RETURNS SETOF RECORD
LANGUAGE plpgsql AS $$
BEGIN
    RETURN QUERY EXECUTE
        format('SELECT * FROM %I WHERE %I = $1', p_table, p_column)
        USING p_value;
END;
$$;

Cursor 操作

Cursor 適用於處理大量資料而不想一次載入記憶體的場景:

CREATE OR REPLACE FUNCTION process_large_table()
RETURNS VOID
LANGUAGE plpgsql AS $$
DECLARE
    cur CURSOR FOR
        SELECT id, data FROM large_table WHERE processed = FALSE;
    v_rec RECORD;
    v_batch INT := 0;
BEGIN
    OPEN cur;
    LOOP
        FETCH cur INTO v_rec;
        EXIT WHEN NOT FOUND;

        -- 處理每一行
        UPDATE large_table
        SET processed = TRUE, result = compute(v_rec.data)
        WHERE CURRENT OF cur;  -- 用 Cursor 定位更新

        v_batch := v_batch + 1;
        IF v_batch % 1000 = 0 THEN
            RAISE NOTICE '已處理 % 行', v_batch;
        END IF;
    END LOOP;
    CLOSE cur;

    RAISE NOTICE '處理完成,共 % 行', v_batch;
END;
$$;

Refcursor(可傳遞的 Cursor 參照):

CREATE OR REPLACE FUNCTION open_orders_cursor(p_status TEXT)
RETURNS REFCURSOR
LANGUAGE plpgsql AS $$
DECLARE
    cur REFCURSOR := 'orders_cur';
BEGIN
    OPEN cur FOR
        SELECT * FROM orders WHERE status = p_status ORDER BY created_at;
    RETURN cur;
END;
$$;

-- Client 端使用
BEGIN;
SELECT open_orders_cursor('pending');
FETCH 10 FROM orders_cur;   -- 取 10 行
FETCH 10 FROM orders_cur;   -- 再取 10 行
CLOSE orders_cur;
COMMIT;

Plan Caching 機制

PL/pgSQL 函數內的 SQL 語句會在首次執行時產生執行計畫並快取:

首次呼叫 my_function():
  SQL-1 → Parse → Plan → Execute → 快取計畫
  SQL-2 → Parse → Plan → Execute → 快取計畫

第二次呼叫 my_function():
  SQL-1 → 取用快取計畫 → Execute  ← 跳過 Parse + Plan
  SQL-2 → 取用快取計畫 → Execute

Generic Plan vs Custom Plan(PG12+)

PostgreSQL 12 起,Planner 會自動在兩種策略間選擇:

策略說明適用場景
Custom Plan每次根據參數值產生最佳計畫參數值分佈不均,不同值需要不同計畫
Generic Plan使用不依賴參數值的通用計畫參數值對計畫影響不大

前 5 次呼叫一律使用 Custom Plan,之後 Planner 比較兩者成本,自動選擇較優的策略。

若需強制使用 Generic Plan(例如 Plan 階段耗時太長):

SET plan_cache_mode = 'force_generic_plan';  -- Session 級設定

函數波動性宣告

宣告意義Planner 行為
IMMUTABLE相同輸入永遠回傳相同輸出(純函數)可在 Plan 階段就執行(常數摺疊)
STABLE同一 Transaction 內相同輸入回傳相同結果可在 Query 中重用結果,不能用於索引
VOLATILE(預設)每次呼叫可能回傳不同結果不做任何最佳化
-- IMMUTABLE:數學計算
CREATE FUNCTION calculate_tax(amount NUMERIC)
RETURNS NUMERIC
LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
    RETURN amount * 0.05;
END;
$$;

-- STABLE:讀取設定(同一 TX 內不變)
CREATE FUNCTION get_exchange_rate(currency TEXT)
RETURNS NUMERIC
LANGUAGE plpgsql STABLE AS $$
DECLARE v_rate NUMERIC;
BEGIN
    SELECT rate INTO v_rate FROM exchange_rates WHERE code = currency;
    RETURN v_rate;
END;
$$;

-- VOLATILE:有副作用(INSERT/UPDATE/DELETE、使用 random()、clock_timestamp())
CREATE FUNCTION log_access(p_user_id INT)
RETURNS VOID
LANGUAGE plpgsql VOLATILE AS $$
BEGIN
    INSERT INTO access_log (user_id, accessed_at) VALUES (p_user_id, now());
END;
$$;

陷阱:錯誤地將 VOLATILE 函數標記為 IMMUTABLE 會導致索引損壞或查詢結果不正確。

PL/pgSQL vs 純 SQL 函數

面向SQL FunctionPL/pgSQL Function
語法純 SQL 語句Block 結構 + 控制流程
Inlining可被 Planner 展開內聯到呼叫者不可內聯,獨立執行
Plan Caching每次重新 Plan快取 Plan
適用場景簡單計算、型別轉換複雜邏輯、多步驟流程
效能簡單查詢更快(因為內聯)複雜邏輯更快(因為快取)
-- SQL Function(簡單計算,可被內聯)
CREATE FUNCTION full_name(first TEXT, last TEXT)
RETURNS TEXT
LANGUAGE SQL IMMUTABLE
AS $$ SELECT first || ' ' || last; $$;

-- PL/pgSQL Function(複雜邏輯)
CREATE FUNCTION process_order(p_order_id INT)
RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE ...
BEGIN
    -- 多步驟流程
    ...
END;
$$;

經驗法則:能用純 SQL 寫的簡單函數就用 SQL(可內聯),需要條件邏輯、迴圈、例外處理時用 PL/pgSQL。

DO 匿名區塊

DO 執行一次性的 PL/pgSQL 程式碼,不建立永久函數:

-- 批次更新範例
DO $$
DECLARE
    v_rec RECORD;
    v_count INT := 0;
BEGIN
    FOR v_rec IN
        SELECT id FROM users WHERE last_login < now() - interval '1 year'
    LOOP
        UPDATE users SET status = 'inactive' WHERE id = v_rec.id;
        v_count := v_count + 1;
    END LOOP;

    RAISE NOTICE '已標記 % 個不活躍使用者', v_count;
END;
$$;

Stored Procedure(PG11+)

PG11 引入了 CREATE PROCEDURE,與 Function 最大的差別是可以在過程內進行 Transaction 控制COMMIT / ROLLBACK):

面向FunctionProcedure(PG11+)
呼叫方式SELECT func()FROM func()CALL proc()
回傳值必須有 RETURNS無回傳值(可用 INOUT 參數模擬)
Transaction 控制不可 COMMIT/ROLLBACK可以 COMMIT/ROLLBACK
SQL 內使用可在 SELECT/WHERE 中使用不可在 SQL 語句中使用
用途計算、查詢、轉換ETL、批次處理、長時間運行的流程
CREATE OR REPLACE PROCEDURE batch_archive_orders(p_batch_size INT DEFAULT 1000)
LANGUAGE plpgsql AS $$
DECLARE
    v_archived INT;
    v_total    INT := 0;
BEGIN
    LOOP
        -- 每批次搬移 p_batch_size 筆
        WITH moved AS (
            DELETE FROM orders
            WHERE status = 'completed'
              AND completed_at < now() - interval '2 years'
            LIMIT p_batch_size
            RETURNING *
        )
        INSERT INTO orders_archive SELECT * FROM moved;

        GET DIAGNOSTICS v_archived = ROW_COUNT;
        v_total := v_total + v_archived;

        -- 每批次後提交,釋放鎖和資源
        COMMIT;
        RAISE NOTICE '已歸檔 % 筆(累計 %)', v_archived, v_total;

        EXIT WHEN v_archived < p_batch_size;  -- 不足一批,處理完畢
    END LOOP;

    RAISE NOTICE '歸檔完成,共 % 筆', v_total;
END;
$$;

-- 呼叫
CALL batch_archive_orders(5000);

注意CALL 必須在 Transaction Block 外(autocommit 模式),或在已經 COMMIT 過的 Procedure 中呼叫。在顯式 BEGIN ... END Transaction 內呼叫包含 COMMIT 的 Procedure 會報錯。

實用設計模式

Upsert 封裝

CREATE OR REPLACE FUNCTION upsert_setting(
    p_key TEXT, p_value TEXT
)
RETURNS VOID
LANGUAGE plpgsql AS $$
BEGIN
    INSERT INTO settings (key, value, updated_at)
    VALUES (p_key, p_value, now())
    ON CONFLICT (key) DO UPDATE
        SET value = EXCLUDED.value,
            updated_at = now();
END;
$$;

分頁查詢函數

CREATE OR REPLACE FUNCTION paginate_products(
    p_page     INT DEFAULT 1,
    p_per_page INT DEFAULT 20,
    p_category TEXT DEFAULT NULL
)
RETURNS TABLE(id INT, name TEXT, price NUMERIC, total_count BIGINT)
LANGUAGE plpgsql STABLE AS $$
BEGIN
    RETURN QUERY
        SELECT
            p.id, p.name, p.price,
            count(*) OVER() AS total_count
        FROM products p
        WHERE (p_category IS NULL OR p.category = p_category)
        ORDER BY p.created_at DESC
        LIMIT p_per_page
        OFFSET (p_page - 1) * p_per_page;
END;
$$;

-- 使用
SELECT * FROM paginate_products(2, 10, 'Electronics');

重試邏輯

CREATE OR REPLACE FUNCTION retry_operation(p_max_retries INT DEFAULT 3)
RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE
    v_attempt INT := 0;
    v_result  TEXT;
BEGIN
    LOOP
        v_attempt := v_attempt + 1;
        BEGIN
            -- 嘗試執行可能失敗的操作
            PERFORM do_something_risky();
            v_result := 'SUCCESS';
            EXIT;  -- 成功就跳出

        EXCEPTION
            WHEN serialization_failure OR deadlock_detected THEN
                IF v_attempt >= p_max_retries THEN
                    RAISE;  -- 超過重試次數,拋出原始錯誤
                END IF;
                RAISE NOTICE '第 % 次嘗試失敗,重試中...', v_attempt;
                PERFORM pg_sleep(0.1 * v_attempt);  -- 指數退避
        END;
    END LOOP;

    RETURN v_result;
END;
$$;

版本演進

版本新功能
PG 8.4VARIADIC 參數、CASE 表達式
PG 9.0DO 匿名區塊、EXECUTE ... USING
PG 9.2GET STACKED DIAGNOSTICS
PG 9.5ASSERT 語句(開發用斷言)
PG 10Transaction ID 64-bit 相關改進
PG 11CREATE PROCEDURE + Transaction 控制(COMMIT/ROLLBACK
PG 12Plan Caching 改進(Custom vs Generic Plan 自動切換)
PG 14多行字串改進、PROCEDURE OUT 參數改進

常見陷阱

SELECT INTO 與 SQL 標準衝突

-- PL/pgSQL 的 SELECT INTO 是賦值
SELECT name INTO v_name FROM users WHERE id = 1;

-- SQL 標準的 SELECT INTO 是建表(不要在 PL/pgSQL 中使用)
-- SELECT name INTO new_table FROM users;  -- 語義不同!

-- 在 PL/pgSQL 中建表用 CREATE TABLE AS
CREATE TABLE new_table AS SELECT name FROM users;

變數與欄位名稱衝突

-- 危險:v_id 同名於 users.id,WHERE 條件永遠為 true!
DECLARE
    id INT := 5;
BEGIN
    SELECT name INTO v_name FROM users WHERE id = id;
    -- PostgreSQL 會把兩個 id 都解讀為欄位 → WHERE users.id = users.id → 永遠 true

    -- 解法 1:變數加 prefix
    -- DECLARE v_id INT := 5;
    -- WHERE id = v_id;

    -- 解法 2:用 label 限定
    -- <<fn>> DECLARE id INT := 5;
    -- WHERE users.id = fn.id;
END;

最佳實踐:所有變數加 v_ 前綴,參數加 p_ 前綴,避免與欄位名衝突。

EXCEPTION Block 的隱式 SAVEPOINT

-- 每次迭代都建立/釋放 SAVEPOINT,效能極差
FOR v_rec IN SELECT * FROM big_table LOOP
    BEGIN
        PERFORM risky_operation(v_rec.id);
    EXCEPTION
        WHEN OTHERS THEN NULL;  -- 吞掉錯誤
    END;
END LOOP;

-- 改進:批次處理,只在外層捕獲
BEGIN
    FOR v_rec IN SELECT * FROM big_table LOOP
        PERFORM risky_operation(v_rec.id);
    END LOOP;
EXCEPTION
    WHEN OTHERS THEN
        -- 整批回滾,記錄錯誤
        RAISE WARNING 'Batch failed: %', SQLERRM;
END;

NULL 比較陷阱

-- 錯誤:NULL 比較結果永遠是 NULL(不是 TRUE 也不是 FALSE)
IF v_name = NULL THEN ...     -- 永遠不會進入
IF v_name != 'test' THEN ...  -- v_name 為 NULL 時也不會進入

-- 正確做法
IF v_name IS NULL THEN ...
IF v_name IS DISTINCT FROM 'test' THEN ...  -- NULL-safe 比較

總結

PL/pgSQL 是 PostgreSQL 伺服器端程式設計的核心武器:

  • Block 結構(DECLARE/BEGIN/EXCEPTION/END)提供完整的程式設計框架
  • %TYPE / %ROWTYPE 讓變數型別與表結構同步,減少維護成本
  • EXECUTE … USING + format(%I, %L) 是安全動態 SQL 的黃金組合
  • EXCEPTION Block 提供完整的錯誤處理,但要注意隱式 SAVEPOINT 的效能開銷
  • Plan Caching 讓重複執行的函數效能優異,PG12+ 自動選擇最佳策略
  • Stored Procedure(PG11+)支援 Transaction 控制,適合 ETL 和批次處理
  • 簡單運算用純 SQL Function(可內聯),複雜邏輯用 PL/pgSQL

下一篇,我們將深入探討 SQL 函數與自訂函數——掌握 PostgreSQL 函數生態系統的完整面貌,包含 SQL Function、Aggregate Function、Window Function 的自訂開發。

BenZ Software Developer

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