跳至内容

13.3 触发器与约束触发器

trigger 是“当某类事件发生时,在同一 PostgreSQL 事务中自动调用函数”的 对象。自动不等于异步,也不等于免费:

original DML
  + trigger function SQL
  + trigger locks
  + trigger WAL
  + trigger errors
= caller latency and transaction outcome

设计 trigger 时,必须同时说明事件、粒度、时机、返回语义、权限、顺序、 批量成本和失败合同。

13.3.1 行级、语句级与 transition table

行级:一次处理一对 OLD/NEW

FOR EACH ROW 对每个受影响行调用一次:

CREATE TRIGGER a_guard_order_transition
BEFORE UPDATE OF status, version
ON shop_ch13.sales_order
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.guard_order_transition();

一条更新三行的 SQL,会进入 trigger function 三次。PL/pgSQL trigger function 通过特殊变量取得上下文:

变量作用
TG_OPINSERT / UPDATE / DELETE / TRUNCATE
TG_WHENBEFORE / AFTER / INSTEAD OF
TG_LEVELROW / STATEMENT
TG_TABLE_SCHEMATG_TABLE_NAME触发关系
TG_ARGV[]CREATE TRIGGER 传入的文本参数
OLDUPDATE/DELETE 的旧行
NEWINSERT/UPDATE 的新行

本章 guard 比较:

IF NEW.status IS DISTINCT FROM OLD.status THEN
    IF NOT shop_ch13.allowed_transition(
               OLD.status,
               NEW.status
           ) THEN
        RAISE ... ERRCODE = 'P3613';
    END IF;

    IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
        RAISE ... ERRCODE = 'P3615';
    END IF;
END IF;

这是行级 trigger 的合适形状:判断只依赖一对旧、新行和纯 transition matrix,没有为每行扫描整张表。

语句级:一次处理整个命令

FOR EACH STATEMENT 对一条符合事件的语句调用一次,即使最终影响零行也可能 调用。它没有单行 OLD/NEW。如果需要看到受影响集合,使用 transition relations:

CREATE TRIGGER z_audit_order_transition
AFTER UPDATE
ON shop_ch13.sales_order
REFERENCING
    OLD TABLE AS old_rows
    NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION shop_ch13.audit_order_transition();

trigger function 将它们当只读关系使用:

INSERT INTO shop_ch13.order_history (...)
SELECT ...
FROM old_rows
JOIN new_rows USING (order_id)
WHERE old_rows.status IS DISTINCT FROM new_rows.status;

随后写一条 statement audit:

INSERT INTO shop_ch13.statement_audit (...)
SELECT
    pg_current_xact_id(),
    actor,
    session_user,
    count(*)::integer,
    array_agg(new_rows.order_id ORDER BY new_rows.order_id),
    statement_timestamp()
FROM old_rows
JOIN new_rows USING (order_id)
WHERE old_rows.status IS DISTINCT FROM new_rows.status;

实验中:

UPDATE shop_ch13.sales_order
SET status = 'canceled', version = version + 1
WHERE order_id IN (105, 106, 107);

得到:

affected_count=3
order_ids={105,106,107}
statement_audit rows added=1
order_history rows added=3

这比 row trigger 内每行再做聚合更符合集合模型。

transition table 的边界

transition relations:

  • 只用于 AFTER trigger;
  • 捕获一条原始 SQL 对该关系形成的旧/新行集合;
  • 可以给 AFTER ROWAFTER STATEMENT trigger 使用;
  • 不能与 constraint trigger 结合;
  • PostgreSQL 当前不允许带 transition relations 的 UPDATE trigger 同时使用 UPDATE OF column_list
  • 会物化变更集合,因此大批量语句要评估内存、临时文件与延迟。

它们不是跨事务 change stream,也不是 logical decoding 的替代品。

constraint trigger

用户定义的 constraint trigger:

  • 使用 CREATE CONSTRAINT TRIGGER
  • 必须是 plain table 上的 AFTER ROW trigger;
  • 可声明 DEFERRABLEINITIALLY DEFERRED
  • 可被 SET CONSTRAINTS 调整到事务末尾或立即检查;
  • 同样在当前事务中执行。

本章分别挂在订单与支付表:

CREATE CONSTRAINT TRIGGER z_validate_paid_order
AFTER INSERT OR UPDATE OF status, total_minor
ON shop_ch13.sales_order
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.validate_paid_order();

CREATE CONSTRAINT TRIGGER z_validate_payment
AFTER INSERT OR UPDATE OR DELETE
ON shop_ch13.payment
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION shop_ch13.validate_paid_order();

command function 先插入 captured payment,再把订单改为 paid。两个中间瞬间 分别不满足最终关系,但提交点满足:

inside transaction:
  payment captured + order created   -- 暂时不一致
  payment captured + order paid      -- 最终一致
COMMIT:
  deferred checks run                -- 通过

只把订单改成 paid,则提交点返回 P3614,订单更新、history 和 statement audit 全部回滚。

延迟不等于并发安全

constraint trigger 能检查当前事务看到的最终状态,却不会自动选择正确锁。 例如两个事务并发改变同一聚合的不同明细,如果没有共同仲裁行、适当锁或 serializable 协议,双方可能基于不完整视图判断。

本章 capture_payment() 先:

SELECT ...
FROM shop_ch13.sales_order
WHERE order_id = p_order_id
FOR UPDATE;

同一订单的支付命令在订单行上串行化。这是显式并发设计,不是 deferred 关键字赠送的能力。复杂跨行断言必须单独做并发测试。

13.3.2 BEFORE、AFTER 与 INSTEAD OF

BEFORE:拒绝、规范化或改写当前行

row-level BEFORE 在行写入前运行,可以:

  • 检查 OLD/NEW
  • 修改 INSERT/UPDATE 的 NEW
  • 返回 NEW 继续;
  • 返回 NULL 跳过该行。

本章在合法状态变化时统一:

NEW.updated_at := statement_timestamp();
RETURN NEW;

返回 NULL 会让当前行操作被静默跳过,还会影响后续 row trigger 和命令 影响行数。除非“跳过”本身就是明确合同,通常应抛出带 SQLSTATE 的错误, 而不是让调用方误以为写入成功。

row-level BEFORE DELETE 返回 OLD 才能继续删除。trigger function 若要 复用于多个事件,必须逐个写清返回规则。

UPDATE OF 看 SET 列表,不看最终差异

BEFORE UPDATE OF status

status 出现在 SET 目标列表时触发,即使:

SET status = status

它也会触发。反过来,另一个 BEFORE trigger 修改 NEW.status 并不会让原本 未列出 status 的 column-specific trigger 补触发。

真正判断值是否变化要使用:

WHEN (OLD.status IS DISTINCT FROM NEW.status)

或在 body 内判断。IS DISTINCT FROM 对 NULL 有确定语义。

AFTER:观察已完成变化

AFTER 运行时:

  • 当前行操作和即时约束已经完成;
  • 其他 trigger 造成的变化可见;
  • 返回值被忽略;
  • 抛错仍会回滚原语句和事务。

适合:

  • 同事务 audit/history;
  • 基于最终行值派生另一张表;
  • transition table 集合处理;
  • deferred constraint check。

不适合远端 I/O,原因仍是它属于原事务同步延迟。

INSTEAD OF:为 view 定义写语义

INSTEAD OF 只用于 view 的 row trigger。它收到 view 的 OLD/NEW,由 trigger function 决定对底表做什么。

先确认 view 是否已经自动可更新。对简单单表 view,PostgreSQL 可以自动把 DML 映射到底表;不需要 trigger。只有复杂 join、聚合或有意设计的 view command surface 才考虑 INSTEAD OF

示意:

CREATE VIEW order_command AS
SELECT order_id, status, version
FROM private_order;

CREATE TRIGGER route_order_command
INSTEAD OF UPDATE ON order_command
FOR EACH ROW
EXECUTE FUNCTION route_order_command();

trigger function 必须:

  • 定义哪些 view 列可写;
  • 拒绝其余列;
  • 处理并发 version;
  • 返回符合 view 形状的 NEW
  • 给出稳定 SQLSTATE;
  • 保持权限边界。

如果实际意图是一个显式命令,SELECT transition_order(...) 往往比伪装成 view UPDATE 更清楚。

同类 trigger 的顺序

同一表、同一事件、同一时机的多个 trigger 按名字字母顺序执行。这个事实可 用于确定性,但不应构建脆弱流水线:

a_normalize
b_validate
c_audit

一旦正确性依赖命名,重命名、extension trigger 或迁移合并都可能改变行为。 更稳妥的选择:

  • 合并强耦合逻辑到一个 trigger function;
  • 让各 trigger 彼此独立、幂等;
  • 用约束表达真正的最终条件;
  • 在目录测试中冻结 trigger inventory。

官方顺序与语义见 CREATE TRIGGER

运行角色

trigger 与触发语句属于同一事务。PostgreSQL 18 对 queued trigger 明确保留 排队时的 active role;若 trigger function 是 SECURITY DEFINER,则以 function owner 执行。14–17 的延迟触发角色细节必须按目标版本验证。

本章把会写保护表的 trigger function 显式设为 SECURITY DEFINER,固定 search_path,撤销应用对内部函数的 EXECUTE。这样权限意图不依赖嵌套 command function 返回后的角色状态。

创建 trigger 时,创建者需要表的 TRIGGER privilege 和 trigger function 的 EXECUTE privilege。运行态权限设计还必须结合 function 的 SECURITY INVOKER/DEFINER

13.3.3 递归、顺序、批量写入与隐藏成本

trigger 是写路径的一部分

评估成本不要只看原 SQL:

rows affected
× row triggers per row
× SQL issued per trigger
+ statement triggers
+ deferred trigger queue
+ indexes/WAL on derived tables
+ contention introduced by trigger queries

一条 COPY 或无过滤 UPDATE 可能把平时每次一行的隐藏成本放大百万倍。

递归不会自动终止

trigger function 再写同一表,可能再次触发自己:

UPDATE t
  -> trigger
       -> UPDATE t
            -> trigger
                 -> ...

PostgreSQL 允许 cascading trigger;终止责任在设计者。

pg_trigger_depth() 能告诉当前嵌套深度,适合诊断。把:

IF pg_trigger_depth() > 1 THEN
    RETURN NEW;
END IF;

当作主要正确性机制往往掩盖模型问题:另一个合法 trigger 链也可能让深度 大于一,而真正递归仍可能从其他路径进入。优先:

  • 不在 trigger 中更新触发表;
  • BEFORE 中直接修改 NEW
  • 将派生写放到不同关系;
  • 让操作幂等并用明确状态终止;
  • 对递归反例做受控测试。

ON CONFLICT 与 MERGE 会组合多个事件

INSERT ... ON CONFLICT DO UPDATE 可能先运行 row-level BEFORE INSERT, 冲突后再运行 BEFORE UPDATE。statement-level INSERT/UPDATE trigger 也有 定义好的组合顺序,即使 UPDATE 分支最终没有影响行。

因此:

  • INSERT normalization 必须考虑其结果会进入 EXCLUDED
  • 两组 trigger 不应重复不可幂等副作用;
  • 测试要覆盖 insert 成功、conflict update、conflict no-op;
  • 不能从“最终是 UPDATE”推断只执行 UPDATE trigger。

PostgreSQL 15+ 的 MERGE 同样需要按实际 action 路径测试,不凭类比; 14 环境没有该语句。

每行查询导致 N+1

反模式:

-- 每个更新行都扫描一次 history
SELECT count(*)
INTO n
FROM order_history
WHERE order_id = NEW.order_id;

批量更新 N 行就产生 N 次查询。替代方案:

  • 用原生约束;
  • 在原 UPDATE 中 join/CTE;
  • 用 transition table 一次集合处理;
  • 为不可避免的 lookup 建正确索引;
  • 把可延后的分析移到异步 worker。

审计不是“复制整行就完成”

可靠 audit 要定义:

  • 记录业务变化还是所有 UPDATE;
  • old/new 哪些列,是否包含敏感数据;
  • actor 是认证主体、数据库 session 还是服务;
  • request/trace ID 如何传递和防伪;
  • transaction ID 与 statement 时间是什么语义;
  • 审计表谁能改、保留多久、如何分区;
  • 失败时是否必须与主写入一起回滚。

本章保存 actorsession_actor,但 actor 来自受控 command function 设置 的 transaction-local custom setting。由于应用没有底表 DML,不能仅靠 设置该值伪造一次写入;真正系统还要把 actor 与认证层可信上下文绑定。

分区表的额外行为

在 partitioned table 上创建 row trigger,会在已有和后续 partition 上建立 clone trigger。attach/detach、同名冲突和 major version 行为都需要目录测试。行因更新 partition key 被移动时,源 partition 的 DELETE 与目标 partition 的 INSERT trigger 也会参与。

不要只在 root table 的 \d 输出上推断所有 partition 的实际 trigger。

禁用 trigger 是高风险动作

ALTER TABLE ... DISABLE TRIGGER、replication role 或恢复路径可能绕开 业务 trigger。批量导入前“先关 trigger 提速”意味着暂时取消不变量,必须 有:

  • 明确授权与维护窗口;
  • 隔离写入口;
  • 导入后全量验证;
  • 恢复 trigger 的 finally 路径;
  • 失败时数据修复方案;
  • 目录与配置证据。

若规则应是不可绕过的约束,优先用原生 constraint,而不是依赖所有人永不 禁用 trigger。

从目录取得事实

trigger-catalog.sql 读取:

SELECT
    c.relname,
    t.tgname,
    t.tgfoid::regprocedure,
    t.tgdeferrable,
    t.tginitdeferred,
    t.tgoldtable,
    t.tgnewtable,
    pg_get_triggerdef(t.oid, true)
FROM pg_trigger AS t
JOIN pg_class AS c ON c.oid = t.tgrelid
WHERE NOT t.tgisinternal;

实验冻结四个 user trigger:

payment:
  z_validate_payment          AFTER ROW, deferred

sales_order:
  a_guard_order_transition    BEFORE ROW
  z_audit_order_transition    AFTER STATEMENT, old_rows/new_rows
  z_validate_paid_order       AFTER ROW, deferred

tgisinternal 过滤了外键等系统内部 trigger;不要把它们误认成“没有 trigger”。 用户定义 constraint trigger 还会在 pg_constraint 中留下 contype='t' 记录。

发布检查表

  1. 为什么不是原生 constraint 或原 SQL?
  2. event、row/statement、timing 与返回语义是什么?
  3. 零行、单行、批量和 ON CONFLICT 路径是否测试?
  4. transition table 会物化多少数据?
  5. deferred check 的锁与并发协议是什么?
  6. 有没有写触发表或递归链?
  7. 同类 trigger 是否依赖名字顺序?
  8. 运行角色和 definer owner 是否最小权限?
  9. 错误是否有稳定 SQLSTATE?
  10. bulk load、partition、复制和恢复行为是否明确?
  11. pg_trigger inventory 是否进入 release gate?
  12. 回退时是撤销新调用、禁用、替换还是删除,顺序是什么?

trigger 只有在这些问题都能回答时,才称得上数据库护栏。


上一节:SQL 与 PL/pgSQL 函数 · 返回本章目录 · 下一节:过程、任务与事务控制 · 查看全书目录 · 查看索引中心

最后更新于