Skip to content
触发器

触发器

触发器是挂在表(或视图)上的钩子:当 INSERT / UPDATE / DELETE / TRUNCATE 这类事件发生时,数据库自动调用你事先注册好的函数。它和 13-函数与存储过程.md 里讲的普通函数是配套关系——触发器本身不写逻辑,它只做三件事:什么事件、什么时机、什么粒度,逻辑写在"触发器函数"里(声明为 RETURNS trigger 的特殊函数,PL/pgSQL 语法细节见 13 章,本章不重复)。

它的价值一句话:把一致性约束做进表内部。CHECK 和唯一索引管得住单行,外键管得住两行之间的关系,但"每次更新必须刷新更新时间"“状态只能按白名单迁移"“所有删除必须留审计"这类规则,只有触发器能在数据库层面兜底——绕过应用层、直接拿 psql 改数据也拦得住。

代价同样明确:隐式执行(执行计划里看不见它)、每行一次函数调用的性能税、排障时的"看不见的手”(一条 UPDATE 明明只写了 balance,updated_at 和审计表怎么也动了)。本章的路线就是:先讲清楚它按什么规则运行,再给出哪些事值得交给它、哪些不该。

你在 02 章已经见过第一个触发器——set_updated_at() 函数挂在 users / products / orders 三张表上做 BEFORE UPDATE FOR EACH ROW。本章从它出发,把底层的规则全部展开。

分类矩阵:时间 × 粒度

触发器声明有两个正交的维度:时机(BEFORE / AFTER / INSTEAD OF)和粒度(FOR EACH ROW / FOR EACH STATEMENT)。两者组合出来的语义完全不同:

时机 \ 粒度 FOR EACH ROW FOR EACH STATEMENT
BEFORE 行落盘前执行,可以改 NEW,返回 NULL 则跳过这一行 语句开始前执行一次,哪怕最终影响 0 行也会执行
AFTER 行落盘后执行,NEW/OLD 只是给你读的最终值 语句结束后执行一次,配合 transition table 可一次拿到整批行
INSTEAD OF 替代事件本身发生,只能定义在视图上 不支持(INSTEAD OF 必须是行级)

几个官方明确规定的边界,建表评审时容易踩:

  • 视图上:只有 INSTEAD OF 触发器可以是行级;视图上的 BEFORE/AFTER 触发器只能声明为 STATEMENT 级。
  • TRUNCATE 事件:只支持语句级(FOR EACH STATEMENT),没有行级可用——TRUNCATE 本来就没有"这一行"的概念,也拿不到 NEW/OLD。
  • 不写 FOR EACH 子句时默认是 STATEMENT,行级语义务必显式写 FOR EACH ROW。一条语句的执行顺序:BEFORE STATEMENT → 每行的 BEFORE ROW → 语句动作本身 → 每行的 AFTER ROW → AFTER STATEMENT;同类内部的顺序是触发器名字母序(见下文"多个触发器的执行顺序”)。

另外,PostgreSQL 的外键、主键/唯一/排他约束,底层就是内部约束触发器(pg_trigger.tgisinternal = true)。这不是闲笔——后文"批量导入时的触发器成本"里禁用触发器时,“连约束触发器一起关"是个危险开关,到时回头来看这条。

NEW 与 OLD:谁存在、谁能改

触发器函数里有两个特殊变量(PL/pgSQL 自动注入):

  • INSERT:只有 NEW(要写入的行),OLD 为 NULL;
  • DELETE:只有 OLD(被删的行),NEW 为 NULL;
  • UPDATE:两者都有,OLD 是修改前、NEW 是修改后;
  • STATEMENT 级:两者都没有行内容——官方表述是"在语句级触发器里这个变量是 null”。不要试图读它的字段:PG 11 以前引用未赋值的 OLD/NEW 直接报错,11 起读作 NULL,在一个"无"的 record 上取字段运行期照样失败。总之一句话:语句级触发器里没有行可拿,WHEN (condition) 里也一样引用不到表的行值(官方原话:条件不能引用表中的任何值,所以语句级 WHEN 基本没用)。

NEW 能不能改,取决于时机:

  • BEFORE ROW:可以。直接对 NEW.col := value 赋值,或返回一条不同的 record,都会改变最终落盘的行。这是 set_updated_at() 的全部机制。
  • AFTER ROW:读得到,改了也白改——行已经写完了,AFTER 触发器里对 NEW/OLD 的修改不会影响存储结果。

返回值惯例(官方规则整理):

CREATE FUNCTION demo_trg() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN      -- 只有 NEW,BEFORE 时可改
    ELSIF TG_OP = 'UPDATE' THEN   -- OLD / NEW 都在
    ELSE                          -- 'DELETE':只有 OLD
    END IF;
    RETURN NULL;   -- AFTER 与语句级:返回值被忽略,惯例 return null
    -- BEFORE INSERT/UPDATE 要 RETURN NEW;(返回 NULL = 丢弃这一行)
    -- BEFORE DELETE 的返回值不起作用但必须非空,惯例 RETURN OLD
END $$;

关键规则:BEFORE ROW 触发器返回 NULL,等于对这一行说"不"——这一行的 INSERT/UPDATE/DELETE 直接跳过,后续触发器也不会跑。这是数据库层"行级否决"的原生手段;官方审计文档的示例则用 RETURN NULL; -- result is ignored since this is an AFTER trigger 表达 AFTER 触发器的惯例。

精确控制触发时机:WHEN 与 UPDATE OF

只在意 nickname 变化,不想让用户改 email 时也白跑一趟函数?两种收窄手段,语义不同,务必分清:

-- 手段一:SET 清单里出现了 status 列就触发
CREATE TRIGGER trg_status_guard
    BEFORE UPDATE OF status ON orders
    FOR EACH ROW EXECUTE FUNCTION guard_order_status();

-- 手段二:值真的变了才触发(NULL 安全)
CREATE TRIGGER trg_nickname_audit
    AFTER UPDATE ON users
    FOR EACH ROW
    WHEN (OLD.nickname IS DISTINCT FROM NEW.nickname)
    EXECUTE FUNCTION audit_nickname();

UPDATE OF col 匹配的是 SET 子句里"提到了"这一列,不是"值变了"。官方文档举了两个方向相反的例子:

  • UPDATE ... SET x = x——值没变,触发器照样触发(因为 SET 清单里有 x);
  • BEFORE 触发器内部改掉的列值——没有出现在 SET 清单里,列专有的 UPDATE OF 触发器不认为这一列被更新(官方原文:changes made to the row’s contents by BEFORE UPDATE triggers are not considered)。比如 set_updated_at() 把 updated_at 改了,也不会触发别人定义的 UPDATE OF updated_at。

所以判断"值真的变了",用 WHEN (OLD.col IS DISTINCT FROM NEW.col)(IS DISTINCT FROM 把 NULL 变化也算进去,见 02 章 NULL 一节)。两个手段可以叠加:BEFORE UPDATE OF status ... WHEN (OLD.status IS DISTINCT FROM NEW.status)——先按列清单廉价过滤,再进函数前比值。18 还有一个补充规则:UPDATE OF col 中如果 col 是生成列,它依赖的源列被更新时同样会触发。

注意:INSTEAD OF UPDATE 不允许带列清单;带 transition table 的触发器也不允许带列清单(下一节)。

多个触发器的执行顺序:按触发器名字母序

官方规则只有一句话,但很重要:同一事件上定义的同种触发器,按触发器名字母序执行(“If multiple triggers of the same kind are defined for the same event, they will be fired in alphabetical order by name”)。事件触发器(DDL 钩子)同样如此:同一事件上多个触发器按名字字母序。

也就是说,在这个表上:

CREATE TRIGGER trg_b_notify AFTER UPDATE ON users ...;
CREATE TRIGGER trg_a_log    AFTER UPDATE ON users ...;

每个落盘的行都会先跑 trg_a_log,再跑 trg_b_notify——与创建顺序无关。想控制顺序只有一个合规手段:给触发器起体现先后关系的名字,比如 trg_10_validate / trg_20_normalize / trg_30_audit。

注意:这带来一个隐蔽的耦合——ALTER TRIGGER ... RENAME TO ... 改变的不只是名字,还有执行顺序。重命名触发器应当当作行为变更走评审,而不是纯文案修改。

跨"种"的顺序则是固定规则(上一节的 BEFORE STATEMENT → BEFORE ROW → AFTER ROW → AFTER STATEMENT),不受名字影响。

transition table:一条语句的全部行,一次拿到

PG 10 引入。REFERENCING OLD TABLE / NEW TABLE 让 AFTER 触发器把整条语句改动的行当作两张只读的"过渡表"一次性拿到(官方表述:在函数里像查询只读临时表一样引用它们)。它存在的意义,是把行级触发器"N 行 = N 次函数调用 + N 条 INSERT"摊平成一条集合操作 SQL。官方文档的原话是:当触发语句改了很多行时,这比行级方案快得多(significantly faster)。

写法是 REFERENCING OLD TABLE AS 别名 NEW TABLE AS 别名 声明过渡表,函数体内像普通表一样 SELECT ... FROM 别名(完整用例见下文"实战二:审计表模式"的批量版触发器)。创建之前先对照规则清单(全部来自官方 CREATE TRIGGER 文档):

  • 只能用在 AFTER 触发器、普通表上(外部表不行),不能用于约束触发器;
  • OLD TABLE 只能给 UPDATE/DELETE 触发器声明,NEW TABLE 只能给 UPDATE/INSERT 声明,各只能声明一次;
  • 事件不能用 OR 合并:文档明确"请求过渡关系时不能用 OR 组合多个事件"——因为每种事件该声明哪张过渡表不同,INSERT / UPDATE / DELETE 必须各建一个触发器;
  • UPDATE 触发器带过渡表时不能写 UPDATE OF 列清单;
  • 行级 AFTER 触发器也能声明过渡表,但不能挂在分区或继承子表上;
  • 外键级联动作(ON UPDATE CASCADE 等)会被视为原语句的一部分、照常触发受影响表上的触发器;级联场景下过渡表可能被拆成多批,语句级触发器随之每批触发一次——官方保证每个受影响行恰好出现在一组过渡关系里,不重不漏。

实战一:updated_at 为什么只能是触发器

回头解释 02 章那个 set_updated_at(),因为它是"为什么非触发器不可"的最佳教材。

第一反应是用默认值。DEFAULT now() 只解决 INSERT——UPDATE 时 DEFAULT 根本不参与,行改一百次,updated_at 还躺在创建时刻。MySQL 的 ON UPDATE CURRENT_TIMESTAMP 在 PG 没有对应语法。

第二反应是用生成列(02 章)。不行,两条理由叠加:

  • 生成列表达式必须是 immutable(只依赖本行输入、值恒定)。now() 是 STABLE——同一行在不同时刻读它值不同(波动性体系见 13-函数与存储过程.md),建列时就被拒了;
  • 即便用别的手段绕出来,18 默认 VIRTUAL 的语义是"每次读时重算"——updated_at 会变成"查询时间",语义完全错乱。改 STORED 也不行:生成列只在它依赖的列变化时重算,updated_at 不依赖任何业务列。另外提醒:GENERATED 列的值由系统表达式决定,触发器也不能代写(02 章)。

所以正确答案只剩 BEFORE UPDATE 行级触发器,也就是 02 章的三句 CREATE TRIGGER trg_*_updated ... BEFORE UPDATE ... FOR EACH ROW EXECUTE FUNCTION set_updated_at()。

接受两个小脾气:① 它挂在 UPDATE 事件上而不是某列上,UPDATE users SET balance = balance WHERE ... 这种"什么都没改"的语句同样会把 updated_at 推高;② 它只管 UPDATE——想让 INSERT 后也刷新,DEFAULT 已经兜住;想在批量导入时不刷、或反过来导入时也刷,见"批量导入时的触发器成本"的开关手段。

实战二:审计表模式——行级起步,批量升级

给 orders 做变更审计。先给审计表(新表,与 02 章底座并列):

CREATE TABLE orders_audit (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    table_name text        NOT NULL,
    user_name  text        NOT NULL DEFAULT current_user,
    when_time  timestamptz NOT NULL DEFAULT now(),
    action     text        NOT NULL,          -- INSERT / UPDATE / DELETE
    old_json   jsonb,                          -- 改前的整行快照
    new_json   jsonb                           -- 改后的整行快照
);

行级版本(每改一行,审计表进一行):

CREATE FUNCTION audit_orders_row() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO orders_audit(table_name, action, new_json)
        VALUES (TG_TABLE_NAME, TG_OP, row_to_json(NEW)::jsonb);
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO orders_audit(table_name, action, old_json, new_json)
        VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
    ELSE  -- DELETE
        INSERT INTO orders_audit(table_name, action, old_json)
        VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD)::jsonb);
    END IF;
    RETURN NULL;   -- AFTER 触发器,返回值被忽略
END $$;

CREATE TRIGGER trg_orders_audit_row
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_orders_row();

单触发器三事件用 OR 合并在这里是合法的(不带过渡表)。row_to_json(NEW) 把整行存成 jsonb 快照,表结构以后加列也不影响历史审计记录——这是"宽表逐列镜像"和"json 快照"两种审计设计里后者更常见的原因。

批量版本(transition table 写法):

CREATE FUNCTION audit_orders_set() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO orders_audit(table_name, action, new_json)
        SELECT TG_TABLE_NAME, 'INSERT', row_to_json(n)::jsonb FROM new_rows n;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO orders_audit(table_name, action, old_json, new_json)
        SELECT TG_TABLE_NAME, 'UPDATE', row_to_json(o)::jsonb, row_to_json(n)::jsonb
        FROM old_rows o JOIN new_rows n USING (id);
    ELSE  -- DELETE
        INSERT INTO orders_audit(table_name, action, old_json)
        SELECT TG_TABLE_NAME, 'DELETE', row_to_json(o)::jsonb FROM old_rows o;
    END IF;
    RETURN NULL;
END $$;

-- 三种事件必须各声明一个触发器(带过渡表的事件不能用 OR 合并)
CREATE TRIGGER trg_orders_audit_ins AFTER INSERT ON orders
    REFERENCING NEW TABLE AS new_rows
    FOR EACH STATEMENT EXECUTE FUNCTION audit_orders_set();
CREATE TRIGGER trg_orders_audit_upd AFTER UPDATE ON orders
    REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
    FOR EACH STATEMENT EXECUTE FUNCTION audit_orders_set();
CREATE TRIGGER trg_orders_audit_del AFTER DELETE ON orders
    REFERENCING OLD TABLE AS old_rows
    FOR EACH STATEMENT EXECUTE FUNCTION audit_orders_set();

UPDATE 的 old/new 过渡表行集相同,用主键 id 对齐即可。注意语句级触发器在"0 行受影响"时也会执行,过渡表为空集——INSERT ... SELECT 自然插 0 行,无害。切换时别留两套:DROP TRIGGER trg_orders_audit_row ON orders; 之后再建批量版,否则同一变更被记两次。

运维上两件事必须提前想好:

  • 写放大与膨胀:每一次业务行变更都至少多出一条审计 INSERT。orders 改一百万行,orders_audit 就多一百万行——这直接抬高 WAL、autovacuum 压力和死元组数量(膨胀机制与观测见 06-MVCC与Vacuum.md)。
  • 分区与保留窗口:审计表天然适合按月分区(声明式分区见 07-高级SQL.md),保留期到了直接 DROP TABLE orders_audit_2026_09,秒级清空且零死元组,比 DELETE 一千万行体面得多。

实战三:状态机守卫

订单状态只允许沿白名单迁移。数据库层兜底写法:

CREATE FUNCTION guard_order_status() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF (OLD.status = 'pending' AND NEW.status IN ('paid', 'cancelled'))
    OR (OLD.status = 'paid'    AND NEW.status IN ('shipped', 'cancelled'))
    OR (OLD.status = 'shipped' AND NEW.status IN ('completed', 'cancelled'))
    THEN
        RETURN NEW;                      -- 白名单内,放行
    END IF;
    RAISE EXCEPTION '非法订单状态迁移: % -> % (order %)',
        OLD.status, NEW.status, OLD.id;
END $$;

CREATE TRIGGER trg_orders_status_guard
    BEFORE UPDATE OF status ON orders
    FOR EACH ROW
    WHEN (OLD.status IS DISTINCT FROM NEW.status)
    EXECUTE FUNCTION guard_order_status();

BEFORE UPDATE OF status 先按列清单过滤,WHEN 再保证只有真变化的行进函数;BEFORE 时机意味着 RAISE 会连同整条语句一起中止,非法状态根本落不了盘。测试一下:

UPDATE orders SET status = 'shipped' WHERE order_no = 'SO-1';  -- pending→shipped,报错
UPDATE orders SET status = 'paid'    WHERE order_no = 'SO-1';  -- pending→paid,放行

这类约束放触发器还是放应用层,是一次真实的取舍:

  • 放应用层的理由:状态机往往和业务动作绑定(发通知、扣库存、调用支付回调),触发器只该做"否决",不该编排流程;应用层写状态机更好测试、更好改。
  • 放触发器的理由:它是不变量。DBA 手滑、脚本写错、老代码漏改,任何入口都绕不过去。UPDATE ... SET status = 'completed' 直接从 psql 发出来也会被拦。
  • 务实组合:应用负责正常流转,触发器负责"白名单之外的都算事故"——RAISE 出来的异常日志顺带就是审计线索。

INSTEAD OF:把视图变成可写接口

PG 的联接视图默认不可写(不像某些数据库对简单视图自动可写)。INSTEAD OF INSERT/UPDATE/DELETE 触发器的语义就是"以它为准,否则不发生"——官方规则:INSTEAD OF 只能是行级、只能建在视图上。造一个订单视图演示最小机制:

CREATE VIEW orders_detail_view AS
SELECT o.id, o.order_no, o.user_id, o.product_id, o.quantity, o.status,
       u.email, u.nickname
FROM orders o
JOIN users u ON u.id = o.user_id;

CREATE FUNCTION orders_detail_insert() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE uid bigint;
BEGIN
    IF NEW.user_id IS NULL THEN      -- 没给 user_id,先按邮箱查建用户
        INSERT INTO users (email, nickname, password_hash)
        VALUES (NEW.email, COALESCE(NEW.nickname, split_part(NEW.email, '@', 1)),
                'TBD-reset-on-register')          -- 密码留给注册流程,纯演示
        ON CONFLICT (email) DO UPDATE SET nickname = EXCLUDED.nickname
        RETURNING id INTO uid;
        NEW.user_id := uid;
    END IF;
    INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price)
    VALUES (NEW.order_no, NEW.user_id, NEW.product_id, NEW.quantity, 0)
    RETURNING id INTO NEW.id;
    RETURN NEW;   -- 非空 = 告诉执行器"这一行我已经处理了"
END $$;

CREATE TRIGGER orders_detail_ins
    INSTEAD OF INSERT ON orders_detail_view
    FOR EACH ROW EXECUTE FUNCTION orders_detail_insert();

-- 从调用方视角:一行插进视图,底下写进了 users 和 orders 两张表
INSERT INTO orders_detail_view (order_no, product_id, quantity, email, nickname)
VALUES ('SO-2026-1020-001', 1, 2, 'new@example.com', '新用户');

要点解释:视图的"行"是多表拼出来的虚构行,NEW.id / NEW.user_id 进来时都是 NULL,触发函数的职责就是把它拆解成对基表的真实写入,再把生成值回写进 NEW——INSERT ... RETURNING id INTO NEW.id 让外层 RETURNING 也能拿到值。返回值语义(官方):INSTEAD OF 返回 NULL 表示"我没做这个操作",该行不计入受影响行数、也不进入后续触发器;做完了就按事件返回 NEW(INSERT/UPDATE)或 OLD(DELETE)。

两个补充规则:视图上的语句级触发器只有在 INSTEAD OF 行级触发器接管时才会触发;没有触发器时 INSERT INTO orders_detail_view 会直接报 “cannot insert into view”。UPDATE/DELETE 方向同理发一个 INSTEAD OF UPDATE / DELETE 即可,生产逻辑(改价快照、级联校验)留给业务侧,这里不再展开。

触发器递归与跨表死循环

先回答那个绕不开的问题:触发器函数里执行的 DML,会不会再触发触发器?会。 PostgreSQL 没有"同一触发器自动不再嵌套"的保护——函数里的 UPDATE users ... 就是一条普通语句,users 上的触发器在下一层语句里照常执行,包括触发器自己。不加约束的写法:

-- 反例:orders 的行级触发器里 UPDATE orders 同一行 → 无限递归,直到栈深超限报错
UPDATE orders SET updated_at = now() WHERE id = NEW.id;

真正的生产事故通常不是"自己触发自己",而是跨表回环:orders 的触发器写 users 的汇总列,users 上恰好有个"汇总变化回写 orders"的触发器,A 触发 B、B 触发回 A。防护手段按优先级:

  1. 把变更写回 NEW,而不是再发 UPDATE——BEFORE ROW 触发器改 NEW.xxx 天然不会再触发任何触发器;
  2. 收敛触发条件:WHEN (OLD.col IS DISTINCT FROM NEW.col) 让"回写相同值"的那一轮成为不动点,递归自然终止;
  3. 会话内 flag(回环无法用条件收敛时的最后手段)——用事务级自定义 GUC 或临时表标记"本轮已处理":
IF current_setting('app.trg_running', true) = 'on' THEN
    RETURN NULL;                       -- 已在本条语句的触发链里,跳过
END IF;
PERFORM set_config('app.trg_running', 'on', true);   -- true = 事务级,自动复位
-- ... 跨表写 ...
PERFORM set_config('app.trg_running', 'off', true);

注意:自定义 GUC 用 app. 这类带点前缀的名字即可直接使用(current_setting 第二参默认值兜住"未设置")。事务级设置(set_config 第三参为 true)比临时表轻,且异常回滚时自动干净。

批量导入时的触发器成本

行级 AFTER 触发器的账很好算:百万行 UPDATE = 一百万次 PL/pgSQL 调用 + (若是审计触发器)一百万次单行 INSERT。每次函数调用本身不算贵,乘上一百万就是分钟级差距;审计表那一百万行 INSERT 还会连带抬高 WAL 与膨胀。迁移、回填、修数这类百万级语句来临前,有三套官方工具。

做法一:临时禁用,导完恢复

ALTER TABLE orders DISABLE TRIGGER trg_orders_audit_row;
-- ... 百万行批量写 ...
ALTER TABLE orders ENABLE TRIGGER trg_orders_audit_row;

需要表的属主权限。想 DISABLE TRIGGER ALL 把约束触发器一起关掉?先想清楚后果——外键检查本身就是内部约束触发器,禁用的那段时间一致性没有兜底;而且改内部约束触发器的启停需要超级用户权限(文档明说)。只禁用户触发器用 DISABLE TRIGGER USER。

做法二:DROP 掉、导完重建。适合触发器数量多、需要反复倒腾的大型迁移。记得 DROP 时的 CASCADE 依赖链会带走什么;若是配合 pg_dump/大版本升级脚本,重建顺序交给工具即可(11-备份恢复与运维.md);重建索引那一类连锁成本在 04-索引实战.md。

做法三:会话级旁路——session_replication_role

把触发器的"启停"和"什么模式下才响应"拆开,是 PG 的设计精髓。触发器自己的配置存在 pg_trigger.tgenabled,用 ALTER TABLE ... ENABLE [REPLICA | ALWAYS] TRIGGER 调整;会话侧的角色由 GUC session_replication_role 控制(取值 origin(默认)/ replica / local;origin 与 local 内部等价,local 供第三方复制工具标记"本会话的改动不再传播";改它需要超级用户或被授予 SET 权限的角色,且会导致缓存计划失效)。对应关系:

触发器配置(tgenabled) 会话 role = origin / local 会话 role = replica
默认 ENABLE(O,ORIGIN 模式) 触发 不触发
ENABLE REPLICA(R) 不触发 触发
ENABLE ALWAYS(A) 触发 触发
DISABLE(D) 不触发 不触发

于是 SET session_replication_role = replica 就是"批量导入旁路":默认配置的业务触发器全部静默。逻辑复制的 apply 进程内部正是靠这个机制做到"回放不触发"(见下文"逻辑复制与触发器")。但必须记住那行官方警告:外键检查也是触发器实现,replica 模式会把它们一起关掉,数据可能落到不一致状态——只在导入窗口用、导完立刻 RESET,并且只交给信得过的运维账号。

ENABLE REPLICA 触发器则适合"只在回放时才该跑"的钩子(官方示例语境:源端已经做过传播,副本端再跑一遍就是重复写入);反之 ENABLE ALWAYS 给"无论谁改都要响"的告警类触发器。旧教程里可能见过 role 取值 override——自 PG 14 起的文档列表里已不存在它,不用再处理。

TRUNCATE 触发器与事件触发器

TRUNCATE 触发器要点只有一句:AFTER TRUNCATE ... FOR EACH STATEMENT,在表被清空后执行一次,拿不到任何行内容,适合用来联动清空/重置附属表(例如把 orders_audit 里对应记录一并归档)。

事件触发器是另一个物种:不挂表上、数据库级生效、监听的是 DDL 事件(需要超级用户创建;PG 9.2 引入)。官方支持的事件:ddl_command_start(命令执行前,报错可拦下整条命令)、ddl_command_end(目录已更新、事务未提交)、sql_drop(对象已从系统目录摘除后触发,不能再查对象定义,但可以报错中止本次 DROP)、table_rewrite(ALTER TABLE/ALTER TYPE 即将重写表时)、login(17 起,登录时触发;standby 上也会触发,所以函数必须避免写库)。函数声明为 RETURNS event_trigger,可用 pg_event_trigger_ddl_commands() / pg_event_trigger_dropped_objects() 等集合函数取本次命令的操作清单(13-函数与存储过程.md 讲触发器函数通用语法,不重复)。

“禁止随手 DROP TABLE"的完整小例子:

CREATE FUNCTION forbid_drop_table() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    obj record;
BEGIN
    FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() LOOP
        IF obj.object_type = 'table' AND NOT obj.is_temporary THEN
            RAISE EXCEPTION '禁止删除表 %(确需删除请先禁用事件触发器)',
                obj.object_identity;
        END IF;
    END LOOP;
END $$;

CREATE EVENT TRIGGER no_drop_table ON sql_drop
    EXECUTE FUNCTION forbid_drop_table();

DROP TABLE users; 立刻报错且什么都没删掉。想做 DDL 审计就换 ddl_command_end + pg_event_trigger_ddl_commands(),把 command_tag、object_identity 逐行插进审计表即可,机制相同。

三个运维提醒:

  • 误伤迁移脚本:pg_dump/restore、ALTER TABLE 大变更流程都会撞上拦截器,执行前 ALTER EVENT TRIGGER no_drop_table DISABLE,事后 ENABLE;
  • 写崩了怎么救:事件触发器把 DDL 搞坏到连触发器本身都删不掉时,单用户模式启动或把 event_triggers 设为 false 重启(17 起的开关),就能进去 DROP EVENT TRIGGER——这是官方文档明写的兜底;
  • 它只对本库生效,跨库治理要每库建一套。

查看与管理触发器

目录就是 pg_trigger,日常排查用一条 SQL 就够:

SELECT t.tgname,
       CASE t.tgenabled WHEN 'O' THEN 'origin'
                        WHEN 'R' THEN 'replica'
                        WHEN 'A' THEN 'always'
                        WHEN 'D' THEN 'disabled' END AS mode,
       pg_get_triggerdef(t.oid) AS definition
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
WHERE NOT t.tgisinternal          -- 过滤掉外键/约束的内部触发器
  AND c.relname = 'orders'
ORDER BY t.tgname;                -- 这个排序就是真实执行顺序

最后一行值得多看一眼:按名字排序列出来,就是同种触发器的执行顺序(见上文"按触发器名字母序"一节)。

其余语句:

  • ALTER TRIGGER 旧名 ON 表名 RENAME TO 新名;——只改名,不改定义;但改名会改执行顺序,慎重。需要表属主权限。
  • DROP TRIGGER [IF EXISTS] 名 ON 表名 [CASCADE | RESTRICT];——需要表属主权限。
  • 启停与触发模式用 ALTER TABLE ... ENABLE/DISABLE/ENABLE REPLICA/ALWAYS TRIGGER(详见"批量导入时的触发器成本”)。
  • CREATE OR REPLACE TRIGGER(PG 14 起)原地替换定义,适合改函数体之外的部分。两个边界:约束触发器暂不支持 OR REPLACE;在已经动过该表的同一事务里替换触发器是危险操作——文档明说已做出的触发决定不会重新评估。

逻辑复制与触发器

两边不对称,记住两句话:发布者侧,触发器产生的行变化照常进复制流;订阅者回放,默认不执行触发器。

  • 发布端:逻辑解码读 WAL 里的行级变更,不问它是哪条语句造成的。触发器写入的行(比如 orders_audit 里新产生的审计行,若该表也在发布集里)、外键级联造成的连锁变更,都会作为正常变更传播。官方在 ALTER TABLE 一节说得很直白:源端触发器传播的数据也会被复制系统一并复制,所以副本端不该"第二遍"触发,否则会重复。
  • 订阅端:apply 进程固定以 session_replication_role = replica 运行——默认配置(ORIGIN 模式)的触发器和规则都不执行;只有 ENABLE REPLICA / ENABLE ALWAYS 的触发器会在回放时触发。另外官方补充了两条细则:持续回放阶段只触发行级触发器,不触发行级以外的语句触发器;初始表同步按 COPY 实现,会触发 INSERT 的行级和语句级触发器。

实务判断:业务只写发布端的话,订阅端保持默认(触发器静默)最稳,避免审计双份、updated_at 双写;确有理由要在订阅端也执行(比如本地告警),再单独 ENABLE REPLICA 并想清楚幂等。逻辑复制的搭建与回放全貌在 10-复制与高可用.md,这里只收边界。

实战建议

适合放进触发器的:

  • 列级一致性:updated_at 这类"每次写必动"的元数据列,一行代码的事,BEFORE ROW 触发器最典型(02 章底座);
  • 审计轨迹:谁、何时、把哪行从什么改成什么。优先 transition table 批量版;审计表从一开始就按月分区、定保留窗口(06/07 章);
  • 不变量兜底:状态机白名单、金额非负、禁止改价历史单——定位是"最后一道闸",业务流转逻辑仍在应用层;
  • 视图写接口:给上层一个可写的聚合视图(INSTEAD OF),别让客户端直接碰多张基表。

不该塞进触发器的:

  • 复杂业务流程编排:多步骤、要调外部服务、要能灰度和单测的,放到应用层或事件管线;需要"数据变了通知出去"的,用逻辑复制 / outbox 模式解耦,可看 15-扩展与生态.md 的 pgmq 一节——不是 NOTIFY 触发器扛;
  • 任何外部 I/O 幻想:触发器函数里没有 HTTP,别为了"通知"引入 dblink 之类的旁路,那会把行锁攥着等外部响应;
  • 难以关停的副作用:一旦上游禁用了触发器(批量导入/回放),逻辑就静默消失——先问这段逻辑丢了要不要紧。

生产习惯,按此自查:

  • 触发器函数与 CREATE TRIGGER 语句全部进版本管理迁移脚本,与表变更同批发布;禁止生产手敲;
  • 百万级批量语句前,先跑 pg_get_triggerdef 查询列出目标表上的所有触发器,按"批量导入时的触发器成本"三套手段评估:禁用 / DROP 重建 / replica 旁路,评估外键检查被一并关闭的后果;
  • 上线前在影子表(CREATE TABLE ... (LIKE ... INCLUDING ALL) + 同款触发器 + 灌等量数据)验证开销,EXPLAIN 对比有无触发器两版(09 章的工具);
  • 触发器命名体现依赖顺序(trg_10_x),改名按行为变更走评审;
  • 数据事故排查第一步:怀疑"看不见的手"时,先执行 WHERE relname='表名' 那段目录查询——先确认有没有触发器、处于什么 firing 模式,再去读函数体;
  • 汇总进 16-常见陷阱与最佳实践.md 的清单:UPDATE OF 不校验值变化、语句级触发 0 行也执行、过渡表触发器不能 OR 合并事件、replica 模式连外键一起关、重命名即改执行顺序、同事务替换触发器不可靠、事件触发器误伤迁移脚本。