引言
「逻辑放数据库还是放应用」是架构争论,但有些场景 PL/pgSQL 不可替代:数据完整性强制(触发器校验)、批量数据处理(避免应用与数据库往返)、审计日志(触发器自动记录)。PostgreSQL 的 PL/pgSQL 成熟稳定,支持完整的过程化语法、异常处理与事务控制。
本文系统讲解存储过程/函数的创建与调用、变量与控制流、异常处理、事务控制、触发器全家族(BEFORE/AFTER/INSTEAD OF、行级/语句级/事件触发器),并重点剖析 PL/pgSQL 的性能陷阱——尤其是缓存执行计划与触发器级联这两个生产事故高发点。
前置:/postgres-sql-advanced/(SQL 进阶基础)、/postgres-transaction-isolation/(事务与锁)。
目录
- 1. 函数 vs 存储过程:CREATE FUNCTION 与 CREATE PROCEDURE
- 2. PL/pgSQL 语法基础:变量、控制流与返回值
- 3. 异常处理与事务控制
- 4. 触发器家族:BEFORE/AFTER/INSTEAD OF
- 5. 行级 vs 语句级触发器
- 6. 触发器与审计/校验实战
- 7. 缓存执行计划与函数性能陷阱
- 8. 触发器级联与递归陷阱
- 9. 函数与触发器的运维注意
- 10. 速查表与一句话记忆
- 延伸阅读
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
| 维度 | FUNCTION | PROCEDURE |
|---|---|---|
| 返回值 | 必须有 | 无 |
| 调用方式 | 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 数据库专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。