触发器
触发器是挂在表(或视图)上的钩子:当 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。防护手段按优先级:
- 把变更写回 NEW,而不是再发 UPDATE——BEFORE ROW 触发器改
NEW.xxx天然不会再触发任何触发器; - 收敛触发条件:
WHEN (OLD.col IS DISTINCT FROM NEW.col)让"回写相同值"的那一轮成为不动点,递归自然终止; - 会话内 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 模式连外键一起关、重命名即改执行顺序、同事务替换触发器不可靠、事件触发器误伤迁移脚本。