#DevOps

PostgreSQL 触发器与存储函数

有些逻辑放数据库(触发器/函数)比放应用层高效——自动审计、级联更新、数据校验。但滥用触发器会让逻辑隐式、难调试。本文讲清 PG 触发器/函数的写法、适合的场景、不适合的场景,以及"触发器是双刃剑"的克制原则。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月8日·更新于 2026年7月23日

PostgreSQL 触发器与存储函数:数据层逻辑的双刃剑

写过业务系统的人多半遇到过这类需求:每次更新订单要记一条审计、users.updated_at 要自动刷新、删父记录要把子记录一起处理。这些东西放应用层写起来又啰嗦又容易漏——一个忘了调用的入口就破坏一致性。

PostgreSQL 给了另一条路:触发器 + 存储函数。把逻辑下沉到数据层,数据库自己保证它执行。写得好是利器,写得泛滥则是后人的灾难。这篇讲清楚怎么写、什么时候用、什么时候坚决别用。

一、先把概念分清:函数、过程、触发器

很多人把这三个混在一起说,其实层次不同。

存储函数(Stored Function):用 PL/pgSQL(或 PL/Python、PL/V8 等)写的、跑在数据库进程内的函数,可以像内置函数一样被 SQL 调用。

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 把它挂上去才会被触发。

sql
-- 触发器函数:固定签名,返回 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 多少行。适合维护整表的汇总。
sql
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

sql
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 遍,漏一个就破洞。放数据库,所有路径都逃不掉。

sql
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 表的冗余/缓存字段"。

sql
-- 订单状态变化时,刷新用户的"最近订单时间"冗余字段
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,不能跳过"
sql
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;
$$;

这类约束属于数据完整性,放数据库和应用层谁更合适?——放数据库更合适,因为不管谁来写都得遵守。

四、坚决不要用触发器的场景

反过来,下面这些场景我见过无数团队踩坑。

业务核心逻辑。订单金额怎么算、促销怎么叠加、风控规则——这些放触发器里是灾难。原因:

  1. 不可测试:你没法给一个触发器写单元测试,集成测试又慢又脆。
  2. 不可版本化:数据库函数定义散在迁移脚本里,和代码里的业务逻辑两套版本,没人对得上。
  3. 不可见:新来的开发 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

相关推荐