PostgreSQL 触发器与存储函数
有些逻辑放数据库(触发器/函数)比放应用层高效——自动审计、级联更新、数据校验。但滥用触发器会让逻辑隐式、难调试。本文讲清 PG 触发器/函数的写法、适合的场景、不适合的场景,以及"触发器是双刃剑"的克制原则。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
PostgreSQL 触发器与存储函数:数据层逻辑的双刃剑
写过业务系统的人多半遇到过这类需求:每次更新订单要记一条审计、users.updated_at 要自动刷新、删父记录要把子记录一起处理。这些东西放应用层写起来又啰嗦又容易漏——一个忘了调用的入口就破坏一致性。
PostgreSQL 给了另一条路:触发器 + 存储函数。把逻辑下沉到数据层,数据库自己保证它执行。写得好是利器,写得泛滥则是后人的灾难。这篇讲清楚怎么写、什么时候用、什么时候坚决别用。
一、先把概念分清:函数、过程、触发器
很多人把这三个混在一起说,其实层次不同。
存储函数(Stored Function):用 PL/pgSQL(或 PL/Python、PL/V8 等)写的、跑在数据库进程内的函数,可以像内置函数一样被 SQL 调用。
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id bigint)
RETURNS SETOF orders
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT * FROM orders
WHERE user_id = p_user_id
ORDER BY created_at DESC;
END;
$$;
-- 调用
SELECT * FROM get_user_orders(42);
存储过程(Stored Procedure):PG 11+ 引入 PROCEDURE,和函数最大的区别是支持事务控制(COMMIT/ROLLBACK 在过程内可用),用 CALL 调用。函数则不能自己管事务。
触发器(Trigger):一个"钩子"——当某张表发生 INSERT/UPDATE/DELETE 时,自动执行一个标记为 RETURNS trigger 的函数。函数本身不会跑,得 CREATE TRIGGER 把它挂上去才会被触发。
-- 触发器函数:固定签名,返回 trigger
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- 挂到表上
CREATE TRIGGER trg_users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
记住一句话:函数是被调用的,触发器是被点燃的。前者显式,后者隐式——这正是后面所有取舍的源头。
二、触发器的四个维度
理解触发器行为,看四个维度就够了。
1. 时机:BEFORE vs AFTER
BEFORE:在行实际写入前跑。可以修改NEW、可以返回NULL跳过这一行。适合数据规范化、校验、默认值计算。AFTER:在行已经落盘后跑。不能改数据,适合副作用——写审计表、发通知、同步冗余字段。
新手最容易踩的坑:在 AFTER 触发器里改 NEW.updated_at,改了也没用,行已经写完了。
2. 粒度:行级 vs 语句级
FOR EACH ROW:每一行 affected 都跑一次。INSERT 1000 行就跑 1000 次。FOR EACH STATEMENT:整条语句跑完只跑一次,不管 affected 多少行。适合维护整表的汇总。
CREATE TRIGGER trg_recount_tags
AFTER INSERT OR UPDATE OR DELETE ON articles
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_tag_counts();
3. 事件:INSERT / UPDATE / DELETE / TRUNCATE
可以组合,还能用 UPDATE OF col1, col2 限定只有某些列变化才触发——避免无关更新点燃触发器。
4. 条件:WHEN
CREATE TRIGGER trg_log_price_change
AFTER UPDATE OF price ON products
FOR EACH ROW
WHEN (OLD.price IS DISTINCT FROM NEW.price)
EXECUTE FUNCTION log_price_change();
WHEN 在触发器层面过滤,比在函数体里 IF 早一步短路,省 CPU。
三、值得用触发器的四个场景
说"触发器是双刃剑"不是不用,是用在该用的地方。下面四个是触发器的主场。
场景 1:审计日志
业务应用写审计,问题是有 N 个入口就要写 N 遍,漏一个就破洞。放数据库,所有路径都逃不掉。
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO user_audit(user_id, action, old_data, new_data, changed_at, changed_by)
VALUES(
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END,
CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END,
now(),
current_setting('app.current_user_id', true)
);
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_user_change();
current_setting('app.current_user_id', true) 配合应用层 SET LOCAL app.current_user_id = ... 把"谁干的"传进来——这是数据层审计的标准姿势。
场景 2:自动 updated_at
前面已经给过示例。这类"机械的、永远要做、做了不影响业务语义"的操作,正是触发器的甜区。
场景 3:级联式数据维护
不是外键级联那种(那个 PG 原生支持),而是"改了 A 表,顺手维护 B 表的冗余/缓存字段"。
-- 订单状态变化时,刷新用户的"最近订单时间"冗余字段
CREATE OR REPLACE FUNCTION touch_user_last_order()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
UPDATE users
SET last_order_at = now()
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$;
省一次应用层往返,且不会因为应用忘记调用而出错。
场景 4:CHECK 表达不了的复杂约束
CHECK 约束只能针对单行、且必须是 immutable 表达式。下面这些 CHECK 干不了,触发器能:
- "同一个用户未完成的订单不能超过 5 个"(跨行约束)
- "时间区间不能与已有记录重叠"(排他约束也能用
EXCLUDE+ GiST,但更复杂的逻辑触发器更直观) - "状态机只能从
pending转到paid,不能跳过"
CREATE OR REPLACE FUNCTION check_order_status_transition()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status = 'pending' AND NEW.status NOT IN ('paid','cancelled') THEN
RAISE EXCEPTION '非法状态跳转: % -> %', OLD.status, NEW.status;
END IF;
RETURN NEW;
END;
$$;
这类约束属于数据完整性,放数据库和应用层谁更合适?——放数据库更合适,因为不管谁来写都得遵守。
四、坚决不要用触发器的场景
反过来,下面这些场景我见过无数团队踩坑。
业务核心逻辑。订单金额怎么算、促销怎么叠加、风控规则——这些放触发器里是灾难。原因:
- 不可测试:你没法给一个触发器写单元测试,集成测试又慢又脆。
- 不可版本化:数据库函数定义散在迁移脚本里,和代码里的业务逻辑两套版本,没人对得上。
- 不可见:新来的开发
UPDATE一行看到金额变了,根本不知道是哪个触发器在算。
跨服务逻辑。微服务架构里,A 服务的表挂个触发器去同步 B 服务——这是把服务边界撕开。一旦 B 服务挂了,触发器里的逻辑要么吞掉异常(数据不一致),要么抛错(A 服务的写也失败),两头不讨好。
需要外部调用。http_get、RPC、发邮件、写消息队列——哪怕你装了各种 plughog/pg_cron/pgsql-http 扩展也别这么干。数据库事务是宝贵的,外部调用一旦慢,锁就堆起来,整库雪崩。
一条铁律:触发器里只做数据库内、同步、可回滚的事。一旦触到进程边界,就该回到应用层。
五、双刃剑:为什么触发器多了就失控
触发器的根本问题是隐式执行。代码里 UPDATE users SET name = ...,字面上只改了一个字段,实际可能点燃 5 个触发器,每个又可能再触发别的——users 改 audit 写 audit_log,audit_log 又挂了触发器……
后果:
- 行为难追:线上某条记录莫名变化,翻应用日志翻不到,因为根本不经过应用。
- 调试难:
psql里\d+ users看不到触发器到底干了啥,得一个个翻函数定义。EXPLAIN也不会告诉你触发器消耗了多少时间。 - 性能盲区:行级触发器在批量场景下放大 N 倍。
INSERT 100 万行挂个行级触发器,等于跑 100 万次函数调用。很多人到生产才发现。 - 迁移难:换 ORM、换语言、甚至分库分表,触发器全是隐形依赖,搬走后逻辑就没了。
我自己定过一条团队规矩:任何线上触发器必须在表注释或独立的 TRIGGERS.md 里登记,写清楚"这个触发器干什么、为什么不能放应用层、谁负责"。Code Review 时看到新建触发器,默认 challenge,不通过则退回。
六、存储函数 vs 应用层:更老生常谈的争论
存储函数和触发器不完全一回事——存储函数是显式调用的,没有"隐式执行"的原罪。但它有自己的权衡。
函数快在哪:
- 无网络往返:复杂报表在数据附近算完,只回传结果。千万行的聚合,应用层拉过来再算不现实。
- 可以被任何客户端复用:BI 工具、psql、不同语言的服务都直接
SELECT my_report()。 - 事务一致性天然在数据库内。
函数痛在哪:
- 语言生态窄:PL/pgSQL 的工具链、依赖管理、测试框架远不如主流语言。
- 难 Review、难重构:很多团队的函数定义是一坨 SQL 字符串拼出来的,IDE 支持差。
- 绑死数据库:换 PG 版本要小心,换非 PG 数据库基本重写。
我的实践判断:
- 报表、ETL、性能瓶颈下的大数据量计算:用存储函数,值。
- 业务逻辑、可变的领域规则:放应用层。哪怕慢一点,可控性远比性能重要。
- 介于两者之间的:优先应用层,只有在证明往返是真瓶颈时才下沉。
一个具体信号:当你发现某段逻辑在应用层跑要 30 秒、且主要是因为拉了 100 万行过来——这是存储函数的提示。其它情况默认走应用层。
小结
- 触发器/函数把逻辑放数据层,核心价值是"所有写入路径都强制执行",这是应用层做不到的。
BEFORE改数据、AFTER做副作用;行级按行跑、语句级按语句跑;WHEN提前过滤——四个维度组合清楚再下手。- 适合放触发器的四类:审计日志、自动
updated_at、级联冗余维护、复杂完整性约束。 - 坚决不碰的三类:业务核心逻辑、跨服务边界、外部调用。一触到进程边界就回应用层。
- 存储函数优先用于报表/ETL,业务逻辑默认放应用层——可控性永远比那点往返开销值钱。
金句:触发器不是优化手段,是约束手段。该用它时是因为"漏一处就破",不该用它时是因为"看不见就失控"。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。