玩 PostgreSQL 发布订阅的兄弟基本都会撞上同一个疑问:为什么我在订阅端表上建的触发器,复制过来的数据一点反应都没有?甚至怀疑自己建错了表、写错了函数。其实不是你的 SQL 有问题,而是 PostgreSQL 在逻辑复制这条链路里有一套自己的触发器开关规则,这套规则又跟“复制角色”这个概念绑得很紧。这篇文章我就把原生 pub/sub 场景下订阅端触发器从默认行为、底层原理到控制方法整个串一遍,顺带用一个可复现的实验把几种开关组合的效果对比出来。适合正在搭跨库同步、审计落库、或者维护多套数据副本的 DBA 和后端同学。
1. 先搞清楚订阅端的数据是怎么落地的
1.1 逻辑复制的核心流程
逻辑复制在 PostgreSQL 里走的是“发布端产生变更、订阅端应用变更”的模型。发布端的 WAL 日志被解码成一组组行级操作,通过复制槽推给订阅端的 apply worker,由 apply worker 在本地回放成 INSERT、UPDATE、DELETE。这一步跟流复制完全不同:流复制传的是物理 WAL 日志,逻辑复制传的是逻辑变更记录,所以订阅端表结构可以跟发布端不完全一样,触发器的表现形式也因此变得非常微妙。
订阅端的 apply worker 在回放变更时,会把自己放到一个特殊的会话环境里,这个环境的关键参数就是session_replication_role。发布端的写入是正常业务会话,角色是origin;订阅端回放变更时,角色会切到replica。这个参数是整个触发器控制问题的总开关,搞懂它,后面的一切都顺了。
1.2 触发器默认被“冷藏”的真正原因
PostgreSQL 的触发器有一个“触发模式”的概念。默认建出来的触发器,属于ENABLE ORIGIN模式,意思就是“只在角色为 origin 的会话里触发”。而订阅端 apply worker 来了之后直接把自己切到 replica 角色,普通触发器的触发条件不满足,自然就不执行。
这个设计不是拍脑袋定的,它的核心目的是防止复制风暴。假设你在订阅端表上有一个审计触发器,每当有行写入就再写一条审计日志;如果复制过来的每行都触发一次,审计日志表自己也得开触发器,一层套一层,直接滚雪球。再加上复制本身是重放上游已有事务,触发器在订阅端执行额外逻辑也会拖慢 apply 速度,甚至造成同一份逻辑被重复执行。默认关掉普通触发器,就是让订阅端尽量做“单纯的落库”。
但事情没这么简单,因为 PostgreSQL 也预留了后门:你可以把触发器改成ENABLE REPLICA或ENABLE ALWAYS模式,让它在复制场景下照样跑。这就是所谓“订阅端触发器如何控制”的实操核心。
2. 掌控触发器开关的三把钥匙
2.1 session_replication_role 这层皮
session_replication_role有三个取值:origin、replica、local。默认是origin,普通业务会话就是这个值。replica用于复制回放场景,触发器的触发规则会跟着角色变化。local一般用得少,可以简单理解成跟origin类似但更“私人”,主要用于某些特殊运维操作。
这个参数属于会话级参数,你可以在自己的会话里手动设置,不用重启实例,也不用超级用户权限(如果你是普通用户,在事务内只能从 origin 切到 local?实际这个参数的修改权限有一定限制,生产环境里最好先在测试会话里验证)。用它来模拟订阅端的触发器行为非常方便:你不需要真的搭一套主从复制,直接SET session_replication_role = replica;然后做一条 DML,再查一下触发器有没有动作,就能复现出订阅端的效果。
这里有个容易踩的坑:调完参数别忘了切回origin。我见过有同学在排查完问题之后忘了复位,然后下一个业务事务里触发器集体失联,数据写入没问题但审计逻辑全没了,查了半天才发现是会话角色没切回来。
2.2 ALTER TABLE ... ENABLE REPLICA / ALWAYS / ORIGIN
控制单个触发器模式的语法是:
ALTER TABLE 表名 ENABLE ALWAYS TRIGGER 触发器名; ALTER TABLE 表名 ENABLE REPLICA TRIGGER 触发器名; ALTER TABLE 表名 ENABLE ORIGIN TRIGGER 触发器名; ALTER TABLE 表名 DISABLE TRIGGER 触发器名;四种状态的语义要记牢:
| 设置语句 | 触发行为 |
|---|---|
DISABLE TRIGGER | 任何会话都不触发 |
ENABLE ORIGIN TRIGGER | 只在origin角色下触发,也就是默认行为 |
ENABLE REPLICA TRIGGER | 只在replica角色下触发,典型场景就是订阅端回放 |
ENABLE ALWAYS TRIGGER | 无论origin还是replica都触发,最“激进” |
你可能会想,既然复制场景里 apply worker 角色是replica,那想让触发器在订阅端执行,直接ENABLE REPLICA就够了。这个理解对了一半:ENABLE REPLICA的出发点是“只有复制场景才跑”,如果这个触发器在发布端也拥有业务逻辑,那么发布端的正常写入反而不会触发它,因为发布端业务会话是origin。很多团队想统一管理“发布端也审计、订阅端也审计”的场景,就会直接上ENABLE ALWAYS,两边都跑。到底选哪个,看的是这个触发器要不要在业务写入时同时执行。
2.3 按需使用的判断逻辑
实际项目里怎么选,我给你一个相对清晰的判断思路:
- 这个触发器是纯粹的订阅端增强逻辑,比如给订阅端表补充一个本地字段的更新、写一份订阅端专属的衍生数据,那就
ENABLE REPLICA,因为发布端业务写入跟它没关系。 - 这个触发器是业务主链路的一部分,比如更新某个冗余统计字段,发布端和订阅端各自维护一套,那建议
ENABLE ALWAYS,两边都执行。 - 这个触发器只在业务事务中需要,复制数据落库的时候必须跳过,那就保持默认
ENABLE ORIGIN,或者直接DISABLE也行。 - 如果完全不想让复制触发它,也不用
DISABLE,保持默认即可;DISABLE反而会连带本地业务写入也不触发,影响面更大。
这个选择不能靠猜。我先说结论:触发器能不能被控制,基础原理都藏在pg_trigger表里。查一下tgenabled字段就知道了,它的值对应O、D、R、A四种状态,跟你用 ALTER 设置的模式一一对应。
你还可以直接在 SQL 里查所有复制相关触发器的当前状态,方便批量核对:
SELECT tgname AS 触发器名, tgrelid::regclass AS 所属表, tgenabled, pg_get_triggerdef(t.oid) AS 触发定义 FROM pg_trigger t WHERE NOT t.tgisinternal ORDER BY 所属表, 触发器名;3. 分区表例外和 PG 版本差异
3.1 PG 14 前后的行为变化
订阅端触发器这条路上的第一个隐藏分叉路就是分区表。在 PostgreSQL 14 之前,逻辑复制把数据写到订阅端分区表时,行级触发器是不会被触发的;到了 PostgreSQL 14,官方改了这个行为,让 apply worker 写入分区表时能触发对应的行级触发器。注意这里说的是“行级触发器”,语句级触发器依然不触发。
为什么分区表这么特殊?因为分区表的触发器通常是定义在父表上的,子分区里的行变更会向上匹配父表触发器。换句话说,即使复制事件直接落在某个具体子分区上,PostgreSQL 也要判断这个触发器是否属于父表逻辑的一部分。PG 14 之后,它选择让行级触发器跑起来,核心动机是为了保证外键关联、级联更新这类依赖行级触发的约束逻辑在复制场景下不要静默失效。
如果你的项目里发布端和订阅端都是分区表,并且你希望复制数据也走一遍行级触发器,那么你必须确认实例版本 >= 14。版本太老的话,你按后面章节的方法把触发器设成ENABLE ALWAYS也不会有反应,因为整个触发链路在复制路径上就是断的。
3.2 COPY 初始同步与触发器
逻辑复制创建订阅时,通常会带一个copy_data = true的参数,这个动作会把发布端表的既有数据先全量 COPY 到订阅端。很多人在这一步就踩坑:发布端有几百万存量数据,订阅端建好了触发器等着,结果 COPY 完之后触发器一条都没执行。
这是符合设计的。COPY 命令本身不触发任何行级触发器,这跟是不是复制角色无关。哪怕你给触发器设成ENABLE ALWAYS,COPY 阶段就是硬落盘,不会逐行触发。官方文档里明确提过 COPY 与触发器的关系,我在实际项目里也验证过多次。所以初始同步阶段别指望触发器帮你补数据,应该用初始化脚本或迁移任务来做存量数据加工。
如果你确实需要在存量数据导入后立即跑一遍处理逻辑,常规做法是:等订阅完成之后,用一条普通的 UPDATE 语句“碰一下”这些行,或者写一个批量处理函数手动调用,而不是指望 COPY 去触发。
3.3 外键、检查约束连带影响
订阅端的触发器不跑,不代表订阅端约束也不管。实际上外键约束、检查约束在订阅端仍然是强制校验的,这跟触发器是两套机制。很多人在订阅端建了外键,结果复制大量数据的时候不断报错,就是因为 apply worker 按 WAL 到达顺序逐条应用,而各表之间的外键依赖无法满足。
所以我在这里要提醒:订阅端表上的约束策略要单独设计。通常建议订阅端只保留主键和必要的唯一约束,外键约束能去掉就去掉,或者改成延迟约束。触发器控制本身不解决约束冲突问题,但如果你的触发器在订阅端有额外写操作,它也可能成为新的约束冲突来源,比如触发器中插入外键关联数据,而关联行还没复制过来。
4. 一套可复现的实验:四种场景对照
4.1 准备发布订阅环境
纸上谈兵没意思,我们直接实操。我会用最简单的方式搭建一套本地实验,两个库,一个发布端一个订阅端。这里全部用虚构的业务表,比如订单表,测试触发器。
发布端:
-- 发布端库 CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, amount NUMERIC(10,2) NOT NULL, status TEXT NOT NULL DEFAULT 'NEW' ); CREATE PUBLICATION pub_orders FOR TABLE orders;订阅端:
-- 订阅端库 CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, amount NUMERIC(10,2) NOT NULL, status TEXT NOT NULL DEFAULT 'NEW' ); CREATE SUBSCRIPTION sub_orders CONNECTION 'host=发布端地址 port=5432 dbname=发布端库 user=某某' PUBLICATION pub_orders WITH (copy_data = true);如果只是本机测试,连接串里的主机名和端口按实际改一下就行。订阅建好之后,发布端对 orders 表的 DML 就会同步过去。
我在订阅端建一个审计表和一个日志触发器,用来观察触发行为:
-- 订阅端 CREATE TABLE orders_audit ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL, src TEXT NOT NULL DEFAULT 'subscriber', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE OR REPLACE FUNCTION trg_orders_audit_fn() RETURNS TRIGGER AS $$ BEGIN INSERT INTO orders_audit(order_id) VALUES (NEW.id); RETURN NEW; END; $$ LANGUAGE plpgsql;注意,这里我还没把触发器绑定到表上,因为我们要分场景设置不同的触发模式。
4.2 场景一:默认触发器 vs 复制数据
订阅端先把触发器按默认方式建出来:
CREATE TRIGGER trg_orders_audit AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION trg_orders_audit_fn();然后在发布端插入一条订单:
-- 发布端 INSERT INTO orders(amount, status) VALUES (100.00, 'PAID');回到订阅端查:
SELECT * FROM orders; SELECT * FROM orders_audit;你会看到 orders 表里有了这行数据,但 orders_audit 是空的。这个结果完美复现了开头说的“复制过来的数据不触发触发器”。因为订阅端 apply worker 的角色是replica,而这个触发器默认是ENABLE ORIGIN。
为了对照,你在订阅端手动执行一条同样的插入:
-- 订阅端 INSERT INTO orders(amount, status) VALUES (200.00, 'PAID');这次 orders_audit 里会多一条记录。同一个库、同一张表、同一个触发器,仅因为会话角色不同,触发器表现就完全不一样。
4.3 场景二:ENABLE ALWAYS 强制触发
现在把订阅端触发器改成 ALWAYS 模式:
ALTER TABLE orders ENABLE ALWAYS TRIGGER trg_orders_audit;去发布端再插入一条订单:
-- 发布端 INSERT INTO orders(amount, status) VALUES (300.00, 'REFUND');回到订阅端查审计表,这次会有记录。因为ENABLE ALWAYS不管角色是origin还是replica都触发。
这个模式最适合“复制数据也需要本地衍生逻辑”的场景,比如订阅端要维护一份全量统计表,或者要给每行打上机房标签。但要注意,ALWAYS 模式下你在订阅端手动写数据一样会触发,不要以为它只在复制时跑。
4.4 场景三:ENABLE REPLICA 的模式依赖
先把触发器改成 REPLICA 模式:
ALTER TABLE orders ENABLE REPLICA TRIGGER trg_orders_audit;去发布端插入一条:
-- 发布端 INSERT INTO orders(amount, status) VALUES (400.00, 'PAID');订阅端审计表会新增记录,说明复制场景下触发了。然后在订阅端自己手工插入一条:
-- 订阅端 INSERT INTO orders(amount, status) VALUES (500.00, 'NEW');你会发现审计表没有新增记录。这就是ENABLE REPLICA的典型特征:只对复制角色开放,本地业务写入不触发。
这种模式适合那种“只在同步链路上才需要执行”的触发器,比如订阅端落库时自动记录一条 apply 日志,但本地前台写入不需要这条日志。这样可以避免正常业务会话里产生额外开销。
4.5 场景四:分区表行级触发器验证
最后验证分区表的特殊行为。把订阅端建成分区表场景,这里用简单的范围分区:
-- 订阅端 CREATE TABLE orders_part ( id BIGSERIAL, amount NUMERIC(10,2) NOT NULL, status TEXT NOT NULL DEFAULT 'NEW', created_at DATE NOT NULL DEFAULT current_date ) PARTITION BY RANGE (created_at); CREATE TABLE orders_part_2025_01 PARTITION OF orders_part FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');在父表上建行级触发器:
CREATE TRIGGER trg_orders_part_audit AFTER INSERT ON orders_part FOR EACH ROW EXECUTE FUNCTION trg_orders_audit_fn();发布端同步到这张分区表之后,只要 PostgreSQL 版本 >= 14,复制写入就会触发父表上的行级触发器。而语句级触发器即使建在父表上,复制写入也不会触发。
这个版本的差异我建议你在自己的环境里用SHOW server_version;先确认一下,因为很多团队还在用 13 甚至更老的版本,行为会完全不同。
5. 实战中的常见问题与排查清单
5.1 为什么触发器完全没反应
这是最常见的问题,而且大概率不是触发器写错,而是触发模式不对。排查路径我建议按下面几步走:
- 先确认数据是不是真的从发布端同步过来了,排除订阅本身断掉的可能。
- 查
pg_trigger的tgenabled,看看当前触发器模式是O还是D。如果是O,复制场景不触发是正常的。 - 手动在订阅端开一个会话,执行
SET session_replication_role = replica;,再做一条 DML 测试触发。如果这条也不触发,说明触发器和函数的定义本身有问题。 - 如果是分区表,还要确认版本是否 >= 14,以及触发器是否建在父表上、是不是行级触发器。
这里我强烈建议把“触发器模式”和“触发器函数逻辑”分开排查,不要混在一起。我之前排查过一个问题,同事发誓自己设了 ALWAYS,结果查出来他设在了发布端,订阅端表的触发器还是默认模式。消息发错对象了。
5.2 复制大量数据时触发器风暴怎么办
如果订阅端业务确实需要行级触发器,请注意批量同步时的放大效应。发布端如果一次性 UPDATE 一万行,订阅端的 apply worker 回放这一万行时,只要触发器是 ALWAYS 或者 REPLICA 模式,就会触发一万次触发器函数。如果触发器函数本身还要写日志表、再做子查询,订阅端的回放速度会显著变慢,甚至拖垮整个订阅链路。
我的经验是给这种复制触发器的函数做三件事:
- 函数体保持精简,除了必要的 INSERT 之外不做关联查询。
- 尽量使用
AFTER INSERT,少用BEFORE INSERT去做行级修改。 - 在触发器函数里避免调用耗时的存储过程,比如外部 API 调用。
如果你要基于复制数据做聚合统计,也别放在行级触发器里逐条算,改成定时任务在订阅端统一刷。
5.3 订阅端备用库上看到触发器不执行
还有一种场景,订阅端库本身又被另一个备库通过物理流复制挂着。这时候你登录到物理备库去查数据,看不到任何触发器执行结果。这不是逻辑复制的问题,而是物理备库根本不允许写操作,触发器自然也跑不了。很多同学把“逻辑复制订阅端”和“物理备库”混在一起看,结果排查半天方向都错了。
需要区分清楚:触发器只在主库或逻辑复制订阅端这种“可写”节点上执行。物理流复制备库永远只做 WAL 回放,不执行任何触发器逻辑。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 处理方法 |
|---|---|---|
| 复制数据落库但触发器没跑 | 触发器是默认 ORIGIN 模式,apply worker 是 replica 角色 | ALTER TABLE ... ENABLE ALWAYS/REPLICA |
| 本地手动插入也不触发 | 触发器被 DISABLE 了,或者函数本身有问题 | 查pg_trigger.tgenabled,调用函数测试 |
| 订阅端本地写入触发,复制数据不触发 | 触发器是 REPLICA 模式的反向使用 | 改 ORIGIN 或 ALWAYS |
| 分区表复制后行级触发器未触发 | PG 版本 < 14,或者触发器建在子分区上 | 升级版本,触发器建在父表 |
| 初始同步完成后存量数据没触发 | COPY 命令本身不触发触发器 | 用脚本或后续 UPDATE 处理存量数据 |
| 订阅端触发器执行太慢 | 触发器函数体过重 | 精简函数,异步化,旁路处理 |
| 订阅端外键冲突导致复制中断 | 约束与 WAL 到达顺序冲突 | 订阅端去掉非必要外键或改为延迟约束 |
最后再分享一个我个人的习惯:生产环境里,凡是订阅端要用的触发器,我会在触发器名字里直接带后缀_sub,比如trg_orders_audit_sub,这样在pg_trigger里扫一眼就知道哪个是给复制链路用的。同时维护一份触发器模式登记表,记录每个表的触发器当前是 ORIGIN、REPLICA 还是 ALWAYS,以及设置原因。这样后续排障时不用靠猜,直接查表就能定位。触发器控制本身并不难,难的是你在一个庞大同步体系里快速确认“这个触发器应该在哪个角色下跑”,有了命名规范和登记信息,这个问题就解决了一大半。