PostgreSQL 的逻辑复制,本质上就是数据库自带的一套 publish/subscribe 体系:发布端把变更解码成消息,订阅端拉回去回放。很多同学第一次在订阅端接表时都会遇到一个奇怪现象——明明写好了触发器,复制过来的数据却安安静静,触发器一次都不触发。这篇文章就专门聊 PostgreSQL 原生 pub/sub 订阅端触发器的控制问题:为什么默认不触发,到底用哪条命令才能让触发器在订阅端生效,以及我在实际项目里踩过的坑和验证方法。适合正在用逻辑复制做读写分离、数据汇聚,或者接手了某个订阅端同步链路并且需要在同步之后继续做加工的人。
1. 为什么订阅端的触发器"静默失灵":认识 apply worker 的角色开关
1.1 订阅端回放不是简单的"再执行一次 SQL"
在说触发器之前,得先把订阅端的执行链路讲清楚。发布端由 publication 把表上的 INSERT、UPDATE、DELETE 解码成 WAL 中的逻辑变更,订阅端通过 subscription 连接发布端拉取这些变更,再由本地的 apply worker 进程逐一 apply 到目标表。这个 apply 过程看起来像"把丢过来的数据重新插一次",但它并不仅仅是重放一条 SQL,而是一套独立的执行路径。
为什么强调这一点?因为很多人在订阅端创建触发器时,脑子里的模型是"数据进来就会走表上的 INSERT/UPDATE/DELETE,那么触发器自然也该跑"。实际上逻辑复制的 apply worker 为了保证订阅端是一份干净的副本,会主动把当前会话的session_replication_role设置为replica。这个参数不是给用户看的摆设,它直接决定了一批数据库对象在本次会话里到底执行不执行。默认情况下,普通会话是origin,此时表上默认的触发器(状态为 origin)都会触发。而一旦切到replica,PostgreSQL 的规则就变了:只有被显式标记为replica或always的触发器才会被触发,普通触发器直接跳过。所以你说触发器"失灵",其实它根本没坏,而是被这套角色开关拦在了外面。
顺带一提,PostgreSQL 还有另一套轻量级的原生 pub/sub:LISTEN/NOTIFY。它是异步消息通知,不会自动回放数据,通常需要自己在触发器或者业务代码里接收通知。它和逻辑复制订阅端是两码事,这套通知链路里不存在 apply worker,触发器的控制其实就是"收到通知后该干什么"的问题。我在项目里经常两条链路一起用,逻辑复制负责数据落地,LISTEN/NOTIFY 负责知会应用层"某张表又变了",前提是你得先把逻辑复制订阅端的触发器控制明白,否则很容易出现数据到了订阅端、应用层却毫不知情的局面。
1.2 session_replication_role:一个决定触发器生死的开关
我是这么理解的:session_replication_role 就像一个"角色滤镜",数据库在执行命令前先看一眼当前会话是什么角色,再决定带哪些滤镜的触发器可以出场。取值主要有两个:origin,表示这是正常的业务会话;replica,表示当前是复制/回放会话。逻辑复制的 apply worker 启动后,会把整条 apply 过程中的会话角色设成 replica,所以订阅端的普通触发器全部被忽略。
这里还有一层容易忽略的关系:触发器本身也有一个 enabled 状态,记录在系统表pg_trigger.tgenabled里,常见的就是O、R、A、D四种字母。字母O表示该项触发器只在 origin 会话中触发,R表示只在 replica 会话中触发,A表示无论哪个角色都触发,D表示彻底关闭。逻辑复制的订阅端恰恰处于 replica 会话里,所以O状态被跳过是设计内行为;要让订阅端的触发器真正执行,你要做的就是把触发器状态改成R或A。理解了这层对应关系,后面的一系列控制命令全部都顺理成章了。
1.3 从系统表看懂触发器当前状态
上手之前我建议你先学会查状态,不然很容易改错对象或者改了没感觉。一条查询就能把某张表上所有用户触发器状态拉出来:
SELECT tgname, tgenabled FROM pg_trigger WHERE tgrelid = 'orders'::regclass AND NOT tgisinternal;看到tgenabled = 'O',就知道这个触发器目前只在普通业务会话里触发,订阅端复制过来的数据不会碰它。看到R,说明已经切换成复制专用;看到A,就是常驻状态。我在排查问题的时候,会先用这条 SQL 确认状态,再决定下一步改哪个,避免在表很多、触发器也很多的环境里瞎改。
2. 让触发器在复制链路生效的三种标记姿势与取舍
2.1 最常见的做法:ENABLE REPLICA TRIGGER
如果你的需求很单纯——只希望复制过来的数据触发触发器,本地手工写数据不触发,那直接用ALTER TABLE ... ENABLE REPLICA TRIGGER即可。实际操作是在订阅端表上执行:
ALTER TABLE orders ENABLE REPLICA TRIGGER trg_orders_audit;执行后,pg_trigger.tgenabled会从O变成R。此后普通业务连接写这条表,照常跳过逻辑复制专属的触发器;逻辑复制 apply worker 回放时,则会执行这个触发器。这正贴合订阅端最典型的场景:报表库、查询库、数仓贴源层的表,本地一般不允许业务直接写数据,所有的数据都来自发布端,所以"只对复制事件生效"反而最安全。
我在真正动手之前会反复确认一点:订阅端这张表,本地连写入权限到底开了没有。如果订阅端只是纯只读副本,REPLICA 基本就是唯一正确选项,因为本地没有合法写入路径,ALWAYS 和 ORIGIN 都容易在未来被人手动改数据时误触发。如果订阅端本身也承担了一部分写入职责,那么再往下看 ALWAYS 的适用场景。
2.2 需要本地也触发时:ENABLE ALWAYS TRIGGER
有些场景是订阅端也要参与写业务,而且无论数据来自复制还是来自本地写入,都要统一走一段加工逻辑。例如有一列脱敏字段、一列缓存用的冗余字段,所有写入路径都不能漏。这时就可以用:
ALTER TABLE orders ENABLE ALWAYS TRIGGER trg_orders_full;状态变为A后,origin 会话和 replica 会话都会触发。但要小心一个副作用:如果发布端那台机器上同一张表也有同名逻辑的触发器,每当从发布端产生的业务变更在发布端上被触发一次、订阅端回放时又被 ALWAYS 往前推一次,两边就会各自执行一遍同样的加工。所以 ALWAYS 不是万灵丹,它适合"订阅端本地写权限存在且希望行为完全一致"的场景。如果只是想让复制过来的数据走加工,建议优先走 REPLICA,比 ALWAYS 更不容易制造重复动作。
2.3 批量开关:DISABLE TRIGGER ALL 与 ENABLE REPLICA TRIGGER ALL
线上表往往不止一个触发器,比如有审计触发器、同步哈希触发器、更新时间触发器。一个个改名字确实费劲,我更常用的是批量形式:
ALTER TABLE orders DISABLE TRIGGER ALL; ALTER TABLE orders ENABLE REPLICA TRIGGER ALL;第一条相当于把表上所有用户触发器关掉,第二条把当前所有触发器统一标记成replica。注意一点:DISABLE TRIGGER ALL会连同内部约束触发器一起禁用,如果订阅端表上还有外键约束,复制过程中约束可能会被跳过,这个副作用很大。所以我的建议是:除非你明确知道这张表没有外键依赖,否则尽量按名字精确操作,或者先SELECT出触发器清单再决定怎么批量。另一个必须提醒的坑是:ALTER TABLE ... ENABLE/DISABLE TRIGGER都会对表加锁,在还在接收复制的订阅端上执行,意味着从命令开始到结束,apply worker 处理到这张表时会一直被阻塞。选择在复制空闲的窗口执行更稳妥。
2.4 四种状态的取舍速查
| 目标状态 | 对应命令 | 触发行为 | 典型场景 |
|---|---|---|---|
| O(origin 默认) | ENABLE TRIGGER | 只普通会话触发 | 订阅端不关心复制事件,纯业务触发器 |
| R(replica) | ENABLE REPLICA TRIGGER | 只复制回放触发 | 订阅端只要同步后加工,本地禁止写入 |
| A(always) | ENABLE ALWAYS TRIGGER | 两种会话都触发 | 订阅端本地与会话都要统一逻辑 |
| D(disabled) | DISABLE TRIGGER | 都不触发 | 临时关停某个触发器 |
这个表基本能覆盖日常 90% 的控制需求。真正的复杂度反而在表格之外:你要先想清楚订阅端的定位是"纯只读副本"还是"可写业务库",这决定了用 R 还是 A;再想想表上外键、约束、分区结构,这决定了能不能放心用 ALL。
3. 发布端 filter 和行级条件:在数据源头控制触发范围
3.1 发布端 WHERE 过滤:从源头减少订阅端需要处理的行
触发器控制不只发生在订阅端。发布端可以在创建 publication 时写 WHERE 条件来控制哪些行会被复制,例如从 PG15 开始支持发布端行过滤:
CREATE PUBLICATION pub_orders FOR TABLE orders WHERE (biz_line = 'core');这样订阅端只收到核心业务线的订单变更。从触发器角度看,订阅端根本接收不到被过滤掉的行,自然也不会触发后续逻辑。好处非常直观:复制带宽下降、订阅端 apply 压力下降、触发器里需要判断的数据也少了。如果整个链路就是把某个业务线的数据汇聚到数据仓库,这个方式比在订阅端触发器里写条件更干净,因为无用数据压根不出门。
这里有一个细节经常被忽略:发布端 WHERE 过滤只对增量变更生效,初始快照阶段同样会按条件筛选,但已存在于订阅端、不满足条件的历史数据不会被反向删除。如果你期望"以前同步过的旧数据也消失",光靠这个过滤是不够的,需要在订阅端再安排一次清理任务,或者用触发器在符合条件的删除事件到达时做兜底。
3.2 触发器函数内部加条件:控制粒度更细,链路更直观
如果不想在生产库动 publication 的 WHERE 条件,也可以把控制逻辑下沉到订阅端触发器函数里。比如只想对金额大于某阈值的订单做进一步审计:
IF NEW.amount <= 1000 THEN RETURN NEW; END IF;这种写法的好处是改动不用动复制链路,完全在订阅端本地完成,回滚也是本地回滚。缺点是只控制"触发器内部业务要不要执行",数据还是照常从发布端传过来了,改不了带宽和 apply 压力。所以我的习惯是:能把条件放到发布端时放发布端,不需要同步的数据尽量不传;必须同步但又不想每条都加工时,再用触发器内部的 IF 条件做二次过滤。
3.3 一个组合案例:层级过滤让订阅端触发器负担降到最低
某项目要做会员订单的多区域报表,主库在中心机房,报表库在灾备机房,中间用逻辑复制同步订单表。发布端 publication 用WHERE (biz_line = 'core')过滤掉非核心业务线的订单。订阅端在报表库的订单表上建了一个 AFTER INSERT OR UPDATE 触发器,里面只关心 status 变成 paid 的记录,其余直接 RETURN。这样一来,非核心业务线的数据连进报表库的资格都没有,核心业务线数据里也只有真正付费的记录才会触发后续的统计逻辑。这个案例想表达的是,订阅端触发器不是孤立的,控制它可以从发布端、订阅端表结构、触发器函数三个层级分别着手,每层控制一部分,链路性能才不会都堵在最后那一下。
4. 一个从零到可用的实战记录:订阅端审计触发器完整落地
4.1 场景与表结构
我用一套最经典的场景来讲完整落地过程:某订单库是发布端,某报表库是订阅端,orders 表通过逻辑复制同步到报表库。业务方希望在报表库里对 orders 的每次变更留痕,于是我在报表库准备了一张审计表 audit_orders_log,字段包括操作类型、订单 ID、金额变化、变更时间。目标很明确:只要 orders 表在订阅端被复制过来的 INSERT、UPDATE、DELETE 触发,就往审计表写一条记录。这套结构很简单,但它能覆盖"订阅端需要基于同步数据做二次加工"的典型需求。
4.2 创建触发器并确认默认状态
先写一个触发器函数:
CREATE OR REPLACE FUNCTION fn_orders_audit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO audit_orders_log(tg_op, order_id, amount, changed_at) VALUES (TG_OP, COALESCE(NEW.order_id, OLD.order_id), COALESCE(NEW.amount, OLD.amount), now()); RETURN COALESCE(NEW, OLD); END; $$;然后创建普通触发器:
CREATE TRIGGER trg_orders_audit AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION fn_orders_audit();这时去 pg_trigger 里查,tgenabled 一定还是 O。此时我故意在发布端插入一条新订单,过几秒看报表库 audit_orders_log 是空的,这恰好证明了普通触发器在订阅端不生效。很多人在这一步就开始怀疑是不是复制坏了,其实复制一切正常,只是触发器状态没切。
这里有一个经验要强调:我一开始把审计触发器设计成 BEFORE,因为业务方后来想在触发器里补充一些订阅端本地字段,比如入库时间、机器标识。如果只做纯审计,用 AFTER 更安全,因为此时行已经真正落盘,不会因为触发器逻辑把写入数据改坏。BEFORE 触发器适合"修改 NEW 里的列再落库",AFTER 适合"读最终结果做旁路计算"。两种都能在订阅端被 REPLICA 标记控制,但使用位置要想清楚。
4.3 切换 REPLICA 标记并验证
关键命令就是:
ALTER TABLE orders ENABLE REPLICA TRIGGER trg_orders_audit;再次查询pg_trigger.tgenabled,看到 R,就说明这个触发器已经进入"仅复制回放触发"的状态。接着回到发布端插入一条 order_id=10086、amount=199 的订单,等待几秒(如果数据量大就多等一会),再查报表库 audit_orders_log:
SELECT tg_op, order_id, amount, changed_at FROM audit_orders_log ORDER BY changed_at DESC;能查到一行INSERT 10086 199,就说明订阅端触发器真的被触发成功了。整个过程不需要重启订阅、不需要重新创建订阅,改动可以随时回滚,回滚命令也简单:
ALTER TABLE orders ENABLE TRIGGER trg_orders_audit;状态从 R 变回 O,订阅端复制回来的数据再次进入"不触发"状态。
4.4 实战中的几个验证细节
验证阶段最容易踩的坑是"没等复制完成就查表"。逻辑复制有天然的延迟,尤其发布端有大事务或者订阅端有慢查询时,几分钟甚至更久都正常。我的做法是先用pg_stat_subscription确认复制到的 LSN 已经超过发布端的最新 LSN,或者干脆查询订阅端那行数据本身已经出现后,再去看审计表。另一个细节是触发器函数里的now()取的是订阅端本地时间,并不是发布端事务的发生时间。审计业务如果要求两边时间一致,必须由发布端在业务字段里写一个 insert_time/update_time,触发器直接引用业务字段即可。
还有一点,如果触发器逻辑要用RETURN NEW修改写入订阅端的数据,即 BEFORE 触发器里改某列值再落库,这个改动只影响订阅端本地表,不会反传给发布端,也不会再次触发发布端的发布逻辑。所以订阅端做数据补全、脱敏、默认值填充这类需求,完全可以在 BEFORE 触发器里随心做,但要清楚它不会回流到源头。
5. 分区表、重复触发与复制延迟:边界条件和避坑清单
5.1 订阅端是分区表时,触发器最容易装错地方
逻辑复制订阅端建成分区表的情况越来越常见,尤其是报表库想按时间分区存订单。问题在于,分区表上直接建触发器,子分区不一定继承。PostgreSQL 的父表触发器天然不会传播到子分区,需要为每个分区单独建。你可以在父表上建一个普通触发器,但复制事件实际落在哪个分区,就可能落在那个分区自己的触发器逻辑上,父表的触发器很多时候是空的。项目上也遇到过:报表库 orders 是 range 分区表,我在父表建好触发器并切了 replica 标记,结果插入订单后审计表毫无反应,最后挨个查子分区,才发现在每个分区上补建触发器、逐个执行ENABLE REPLICA TRIGGER,才真正跑起来。如果订阅端分区表还会自动增加新分区,新分区创建之后也要同步补触发器,这是最容易被忽略的维护点。
5.2 本地写入与复制写入并行时,如何避免重复加工
有些订阅端并不是严格只读的,比如报表库允许运维手工修正字段。如果用 ALWAYS 触发器,手工 UPDATE 也会触发同一段逻辑,可能把统计表重复刷新一遍。这时我习惯在触发器函数内部按会话角色分叉:
IF current_setting('session_replication_role') = 'replica' THEN -- 复制回放带来的变更,走复制专属逻辑 ELSE -- 本地手工修改,走本地逻辑 END IF;注意current_setting('session_replication_role')返回的是当前连接会话的角色值。apply worker 回放时是 replica,普通业务连接是 origin,分叉逻辑本身很容易实现。但要注意,普通用户不能随意 SET 这个参数,所以不用太担心有人临时改角色来绕过触发器。这么做之后,ALWAYS 和 REPLICA 的边界就变得不那么重要了,只要触发器状态能保证在被期望执行的会话里被触发,具体的业务动作再用函数内部的分支去收敛。
5.3 触发器开销如何拖慢复制延迟
订阅端触发器不是一个免费的钩子。表里每来一条复制变更,apply worker 都要额外执行一次函数体里的 SQL。如果这张表每天同步几百万行,函数里写了复杂的INSERT、UPDATE、子查询,复制延迟会肉眼可见地升高。我自己优化过的一个案例是:审计触发器里用了两条 INSERT 写两张审计表,还把一次历史汇总放在触发器里做,结果复制延迟从秒级变成分钟级。后来把历史汇总改成独立任务,只在触发器里写最小化的流水,延迟立刻恢复。
所以"控制"订阅端触发器,不仅是状态开关,也包括性能控制。如果你发现复制延迟异常,先停下对触发器的排查:是不是函数里做了太重的工作,是不是触发器数量太多,是不是每条变更都要跨表查询。临时方案是先DISABLE TRIGGER让复制追平,再决定是否精简函数逻辑。但禁用期间数据变更不会留痕,需要确认业务可以接受这段空窗。
5.4 上线前要做的三项检查
把订阅端触发器正式上线前,我至少会确认三件事。一是触发器函数的健壮性:函数内一旦抛异常,apply worker 可能不断重试,把订阅端复制卡在那里;所以函数里要避免对外部不稳定对象的依赖,尽量做成单表、无副作用的操作,异常路径要么提前在外面兜住,要么让异常结果明显可见并人工介入,而不是让复制 worker 无限重试。二是权限是否到位:触发器函数要访问的表、序列、扩展,其权限必须覆盖订阅端连接用户的权限范围,否则复制事件一到就报权限错误。三是用最小代价做回归验证:不要直接在生产大表上折腾,先进测试环境走一遍"发布端写数——订阅端查看触发日志"的闭环,再切生产。
5.5 我的最终建议
把 PostgreSQL 原生 pub/sub 订阅端的触发器控制讲到底,其实就一句话:订阅端回放环境的 session_replication_role 是 replica,所以默认情况下普通触发器不会执行,真正要控制的核心是触发器自身的 enabled 状态。想只让复制事件触发,就用 ENABLE REPLICA TRIGGER;想全部路径都触发,就用 ENABLE ALWAYS TRIGGER;想临时关停,就 DISABLE TRIGGER。实际项目中我最常用的仍然是 REPLICA,因为它最符合订阅端"数据由复制链路流入"的定位,也最不容易造成重复逻辑。每次改完状态,我都会立刻查一遍pg_trigger,再让发布端写一条测试数据,看到审计日志真的出现,才算这一单落地。希望这篇文章能把你的订阅端触发器从"静默失灵"变成"指哪打哪"。