PL/pgSQL 存储过程与触发器:函数、事务控制与性能陷阱

PL/pgSQL 是 PostgreSQL 内置的过程语言。本文系统讲解 CREATE FUNCTION/PROCEDURE 语法、变量与控制流、异常处理、事务控制(COMMIT/ROLLBACK)、触发器(BEFORE/AFTER/INSTEAD OF、行级/语句级)、触发器常见陷阱、以及避免「过程化陷阱」的性能最佳实践与缓存计划问题。

引言

「逻辑放数据库还是放应用」是架构争论,但有些场景 PL/pgSQL 不可替代:数据完整性强制(触发器校验)、批量数据处理(避免应用与数据库往返)、审计日志(触发器自动记录)。PostgreSQL 的 PL/pgSQL 成熟稳定,支持完整的过程化语法、异常处理与事务控制。

本文系统讲解存储过程/函数的创建与调用、变量与控制流、异常处理、事务控制、触发器全家族(BEFORE/AFTER/INSTEAD OF、行级/语句级/事件触发器),并重点剖析 PL/pgSQL 的性能陷阱——尤其是缓存执行计划与触发器级联这两个生产事故高发点。

前置:/postgres-sql-advanced/(SQL 进阶基础)、/postgres-transaction-isolation/(事务与锁)。


目录


1. 函数 vs 存储过程:CREATE FUNCTION 与 CREATE PROCEDURE

函数(FUNCTION):返回值的纯查询扩展,可在 SQL 中直接调用;存储过程(PROCEDURE):独立执行、可含事务控制(PostgreSQL 11+)。

-- 函数:返回标量,可在 SELECT 中调用
CREATE FUNCTION add_numbers(a int, b int)
RETURNS int AS $$
BEGIN
    RETURN a + b;
END;
$$ LANGUAGE plpgsql;

-- 存储过程:无返回值,可 COMMIT
CREATE PROCEDURE batch_archive(days int) AS $$
BEGIN
    INSERT INTO archive SELECT * FROM events WHERE age > days;
    COMMIT;
END;
$$ LANGUAGE plpgsql;

-- 调用
SELECT add_numbers(1, 2);        -- 3
CALL batch_archive(30);          -- 过程用 CALL
维度FUNCTIONPROCEDURE
返回值必须有无
调用方式SELECT fn()CALL pr()
事务控制不可 COMMIT可 COMMIT/ROLLBACK
SQL 中使用可用作表达式不行

选型:纯计算/查询逻辑用函数;需要分批提交、控制事务边界的用存储过程。


2. PL/pgSQL 语法基础:变量、控制流与返回值

CREATE OR REPLACE FUNCTION process_order(order_id bigint)
RETURNS numeric AS $$
DECLARE
    total numeric := 0;
    item_count int;
    row RECORD;
BEGIN
    -- 变量赋值(用 := 或 INTO)
    SELECT count(*) INTO item_count FROM order_items WHERE oid = order_id;
    IF item_count = 0 THEN
        RAISE EXCEPTION '订单 % 无明细', order_id;
    END IF;

    -- 循环
    FOR row IN SELECT price, qty FROM order_items WHERE oid = order_id LOOP
        total := total + row.price * row.qty;
    END LOOP;

    RETURN total;
END;
$$ LANGUAGE plpgsql;

核心语法对照:

语法用途
DECLARE ... :=变量声明与初值
SELECT ... INTO单行查询赋值
IF/ELSIF/ELSE分支
FOR ... LOOP / WHILE循环
RAISE EXCEPTION/NOTICE抛错 / 日志
RETURN / RETURN QUERY返回值 / 返回查询结果
PERFORM执行无返回的 SQL

返回结果集:

CREATE FUNCTION list_orders(cid bigint) RETURNS SETOF orders AS $$
BEGIN
    RETURN QUERY SELECT * FROM orders WHERE customer_id = cid;
END;
$$ LANGUAGE plpgsql;

3. 异常处理与事务控制

异常块:捕获错误并处理,不中断整个事务:

CREATE FUNCTION safe_insert(payload jsonb) RETURNS text AS $$
BEGIN
    BEGIN
        INSERT INTO ledger(data) VALUES (payload);
        RETURN 'ok';
    EXCEPTION
        WHEN unique_violation THEN
            RETURN 'duplicate';       -- 捕获唯一冲突
        WHEN OTHERS THEN
            RETURN 'error: ' || SQLERRM;
    END;
END;
$$ LANGUAGE plpgsql;

事务控制(存储过程内):

CREATE PROCEDURE process_batch(n int) AS $$
BEGIN
    FOR i IN 1..n LOOP
        INSERT INTO jobs(id) VALUES (i);
        IF i % 100 = 0 THEN
            COMMIT;      -- 每 100 条提交一次,防长事务膨胀
        END IF;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

常见异常类型:

异常场景
unique_violation唯一约束冲突
foreign_key_violation外键破坏
division_by_zero除零
invalid_text_representation类型转换失败
lock_not_available锁等待超时
OTHERS兜底(尽量用具体类型)

注意:函数内不能用 COMMIT(无独立事务);长事务放 PROCEDURE 分批提交。


4. 触发器家族:BEFORE/AFTER/INSTEAD OF

触发器 = 表上「某事件发生时自动执行函数」的钩子。

-- 创建触发函数
CREATE FUNCTION audit_order_changes() RETURNS trigger AS $$
BEGIN
    INSERT INTO order_audit(oid, old_status, new_status, changed_at)
    VALUES (NEW.id, OLD.status, NEW.status, now());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 绑定触发器
CREATE TRIGGER trg_order_audit
AFTER UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION audit_order_changes();

三种触发器时机:

时机执行点用途
BEFORE变更前校验/改写数据、阻止操作(RAISE)
AFTER变更后审计、日志、级联操作(不改本行)
INSTEAD OF替代操作视图上的可写逻辑(view 触发器)

触发器访问的伪记录:

NEW —— 新行的值(INSERT/UPDATE)
OLD —— 旧行的值(UPDATE/DELETE)
TG_OP —— 'INSERT'/'UPDATE'/'DELETE'/'TRUNCATE'
TG_TABLE_NAME —— 表名

5. 行级 vs 语句级触发器

触发器触发频率适用
FOR EACH ROW每行触发逐行审计、行级校验
FOR EACH STATEMENT每条 SQL 触发一次汇总统计、批处理钩子

行级触发器的批量陷阱:UPDATE ... WHERE 影响 10 万行 → 行级触发器执行 10 万次函数。

-- 语句级:整批只触发一次,性能更好
CREATE TRIGGER trg_stat_audit
AFTER UPDATE ON orders
FOR EACH STATEMENT
EXECUTE FUNCTION audit_batch();

选择建议:

□ 需要看 OLD/NEW 每行值 → 行级
□ 只需要「这语句执行了」 → 语句级
□ 大批量导入时的钩子 → 语句级(或直接不要触发器)

记忆:行级触发器看得到每行变化但逐行开销;语句级一次搞定但看不到行细节——按需选,别默认行级。


6. 触发器与审计/校验实战

场景一:强制数据完整性(不变量由数据库兜底):

CREATE FUNCTION check_order_total() RETURNS trigger AS $$
BEGIN
    IF NEW.amount <= 0 THEN
        RAISE EXCEPTION '订单金额必须为正';
    END IF;
    -- 服务端强制,绕过应用也不破
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_check_total BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION check_order_total();

场景二:审计日志(谁在什么时候改了):

CREATE FUNCTION audit_users() RETURNS trigger AS $$
BEGIN
    IF (TG_OP = 'UPDATE' AND OLD IS DISTINCT FROM NEW) OR TG_OP = 'DELETE' THEN
        INSERT INTO user_audit(uid, action, old_json, new_json, who, at)
        VALUES (COALESCE(NEW.id, OLD.id), TG_OP,
                to_jsonb(OLD), to_jsonb(NEW),
                current_user, now());
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

场景三:更新 updated_at 时间戳:

CREATE FUNCTION touch_updated() RETURNS trigger AS $$
BEGIN
    NEW.updated_at := now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

应用技巧:审计/校验用触发器「兜底」,即使业务方绕过 ORM 直接写库,不变量依然成立。


7. 缓存执行计划与函数性能陷阱

陷阱:PL/pgSQL 函数默认缓存执行计划——同一函数用不同参数跑,可能复用首个参数的计划,导致参数嗅探(parameter sniffing)问题:

CREATE FUNCTION find_orders(cid bigint, lim int) RETURNS SETOF orders AS $$
BEGIN
    RETURN QUERY SELECT * FROM orders
        WHERE customer_id = cid LIMIT lim;   -- 计划按首次调用参数缓存
END;
$$ LANGUAGE plpgsql;

对策:

对策做法
SQL 层面提示用 sqlstandard / 重写查询形态
动态 SQL`EXECUTE ‘… '
拆分函数把不同参数形态拆成不同函数
内联化让函数成为普通 SQL 可内联表达式

其他性能要点:

□ 函数内避免大循环逐行处理 —— 改用一条集合 SQL(set-based)
□ RAISE NOTICE 频繁会拖慢 —— 生产降级
□ 触发器内禁止慢查询/外部 IO —— 阻塞主事务
□ 大批量写入用 PROCEDURE 分批 COMMIT,防膨胀

记忆:PL/pgSQL 的性能敌人不是语言,而是「逐行循环」与「缓存计划」——优先集合 SQL,敏感查询用动态 SQL 规避计划缓存。


8. 触发器级联与递归陷阱

陷阱:触发器触发另一个触发器,无限递归。

-- 危险:A 表 UPDATE 触发器更新 B 表,B 表触发器又更新 A 表
CREATE FUNCTION a_trg() RETURNS trigger AS $$
BEGIN
    UPDATE b SET a_id = NEW.id WHERE a_id = OLD.id;   -- 触发 B 的触发器
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

防护措施:

措施说明
session_replication_role会话级禁用触发器
触发器函数内标志全局变量/会话变量标记「已处理」
CREATE CONSTRAINT TRIGGER延迟到提交,抑制中间态级联
谨慎设计触发器内尽量只读不写其他表
-- 会话变量标记法
CREATE FUNCTION safe_a_trg() RETURNS trigger AS $$
BEGIN
    IF current_setting('myapp.in_a_trg', true) = 'on' THEN
        RETURN NEW;
    END IF;
    PERFORM set_config('myapp.in_a_trg', 'on', false);
    UPDATE b SET a_id = NEW.id WHERE a_id = OLD.id;
    PERFORM set_config('myapp.in_a_trg', 'off', false);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

记忆:触发器级联是隐蔽的生产事故源——写触发器先问「这个触发器会不会再触发别人」,并给递归留后门。


9. 函数与触发器的运维注意

□ 变更触发器前先 DISABLE,灰度后 ENABLE
CREATE TRIGGER ... ; ALTER TABLE ... DISABLE TRIGGER trg_xxx;
□ 备份/迁移时注意函数与触发器随 schema 一起导出(pg_dump --schema-only)
□ 监控触发器开销:长函数在 pg_stat_statements 中可见
□ 版本管理:把函数/触发器脚本纳入迁移工具(Prisma/Alembic)
□ 权限:函数与触发器需所有者权限,避免提权链

10. 速查表与一句话记忆

需求语法/手段
函数CREATE FUNCTION ... RETURNS ... AS $$ ... $$ LANGUAGE plpgsql
存储过程CREATE PROCEDURE ... CALL pr(...)
变量DECLARE x int := 0
单行查询SELECT ... INTO
异常EXCEPTION WHEN unique_violation THEN ...
事务控制PROCEDURE 内 COMMIT/ROLLBACK
触发器CREATE TRIGGER ... BEFORE/AFTER/INSTEAD OF ...
行值NEW/OLD/TG_OP
批量钩子FOR EACH STATEMENT
防递归会话标志 / 禁用触发器

一句话记忆:函数算、过程控事务、触发器守不变量;审计校验用触发器兜底、批量用语句级、防级联留后门;性能上集合 SQL 优先、缓存计划用动态 SQL 规避——过程化只是补充,别把数据库写成蜘蛛网。


延伸阅读

  • /postgres-sql-advanced/ — 函数内常用的窗口/CTE/JSON 查询
  • /postgres-transaction-isolation/ — 事务边界与锁(过程控制的基础)
  • /postgres-performance-tuning/ — 慢函数与触发器开销调优
  • /postgres-monitoring-diagnostics/ — 追踪函数执行与锁等待
  • [[postgresql]] — PostgreSQL 数据库专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 数据迁移实战:从 MySQL/MongoDB 迁到 PostgreSQL
  2. 云托管与 Serverless PostgreSQL:RDS/Aurora/Neon/Supabase 选型与实战
  3. PostgreSQL 数据库设计规范:范式、类型选择与迁移演进