数据变更捕获(CDC)是做多库同步、缓存刷新、搜索索引维护时绕不开的话题。提到CDC,很多人第一反应是上Debezium加Kafka这套重型装备,但对于中小规模的业务来说,引入一整套消息中间件的运维成本往往超过了收益。其实PostgreSQL自带的触发器能力足以支撑一个轻量级的CDC方案:在源表上挂触发器,把变更记录写到一张事件表,消费端定时拉取回放即可。这篇文章就完整演示这套方案的落地过程,包括事件表设计、触发器函数编写、消费端处理以及一些容易踩的坑。

一、整体思路与事件表设计
这套方案的核心是"源表触发器 + 事件中间表 + 消费端轮询"三段式结构。源表发生任何写操作时,触发器把变更内容序列化后插入事件表;消费端(可以是另一个进程、定时任务,甚至是另一台服务器上的脚本)按顺序读取事件表,回放到目标端,处理完成后标记或删除事件。整个过程不依赖任何外部组件,纯SQL就能跑起来。
事件表的设计是关键。它至少要包含这几类字段:自增主键用于保证事件顺序、表名和操作类型用于路由、行数据的主键值用于定位记录、变更内容的JSON快照、以及创建时间。下面是一个可直接使用的建表语句:
CREATE TABLE cdc_event (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL, -- 来源表名
operation CHAR(1) NOT NULL, -- I/U/D 分别对应增改删
record_id BIGINT NOT NULL, -- 源表主键值
row_data JSONB, -- 变更后的整行数据
old_data JSONB, -- 变更前的整行数据(删除时用)
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_cdc_event_created ON cdc_event (id);
有几个设计细节值得说明。第一,id用BIGSERIAL而不是UUID,因为CDC消费天然需要顺序性,自增主键天然有序,消费端只需记住上次处理到的id就能断点续传。第二,把row_data设为JSONB而不是TEXT,好处是消费端可以直接用JSON运算符提取字段,也方便加索引。第三,单独保留record_id字段,是为了让消费端不用解析JSON就能快速判断这条事件对应哪条记录,简化幂等处理。
二、触发器函数的编写
触发器函数是这套方案的发动机。PostgreSQL的行级触发器能把同一次批量操作中的每一行都送进函数处理,我们在函数里根据TG_OP判断操作类型,用to_jsonb()把行转成JSON写入事件表。写触发器函数最需要注意的一点是:函数必须返回触发器类型,且一定要用BEFORE或AFTER触发器配合FOR EACH ROW使用。
CREATE OR REPLACE FUNCTION cdc_capture()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO cdc_event (table_name, operation, record_id, row_data)
VALUES (TG_TABLE_NAME, 'I', NEW.id, to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO cdc_event (table_name, operation, record_id, row_data, old_data)
VALUES (TG_TABLE_NAME, 'U', NEW.id, to_jsonb(NEW), to_jsonb(OLD));
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO cdc_event (table_name, operation, record_id, old_data)
VALUES (TG_TABLE_NAME, 'D', OLD.id, to_jsonb(OLD));
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
函数中的NEW和OLD是PostgreSQL内置的行变量:INSERT时只有NEW可用,DELETE时只有OLD可用,UPDATE时两者都有。这个特性让我们能区分"变更前"和"变更后"两种快照,消费端做审计或对比时就有了完整信息。注意record_id直接取NEW.id或OLD.id,这里假设源表主键列名是id,如果你的表主键叫别的名字,需要相应调整,或者用row_to_json之后再提取。
接下来把触发器挂到业务表上:
CREATE TRIGGER trg_cdc_orders AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION cdc_capture();
这里选AFTER而不是BEFORE,是有讲究的。AFTER触发器在约束检查完成之后执行,能保证事件表里记录的一定是最终成功落库的数据。如果用BEFORE触发器,一旦后续约束检查失败导致回滚,事件表里的记录也会一起回滚——这其实是好事(不会产生假事件),但BEFORE触发器在某些场景下会干扰RETURN NEW的行为,习惯上做CDC用AFTER更稳妥。
三、消费端如何拉取与回放
事件表写满之后,消费端的工作就是按id顺序批量拉取、逐条处理、标记完成。最简单的实现是用一个游标值记录上次处理位置,每次查询大于该游标的一批事件:
-- 消费端伪代码:每次拉取500条 SELECT * FROM cdc_event WHERE id > :last_processed_id ORDER BY id LIMIT 500;
回放逻辑按operation分三种情况处理:INSERT和UPDATE都可以走"不存在则插入、存在则更新"的UPSERT语义;DELETE则根据record_id删除目标端对应记录。如果目标端也是PostgreSQL,直接用INSERT ... ON CONFLICT (id) DO UPDATE一条语句搞定,天然幂等。消费失败时要做好重试与死信记录,避免单条脏数据卡死整个同步链路。
处理完成后的事件有两种处置策略:直接DELETE掉最干净,事件表不会膨胀,但丢了历史审计能力;另一种是加一个processed布尔列做软标记,配合定期归档或清理任务。如果选择软标记方案,务必建(processed, id)复合索引,否则轮询查询会随着数据量增长越来越慢。还有一个容易忽视的问题:高并发写入源表时,事件表的插入会成为额外热点,建议把事件表放在独立的表空间,并监控其写入延迟。
四、与逻辑复制的对比及适用边界
必须承认,PostgreSQL原生的逻辑复制(Logical Replication)和第三方工具(如Debezium、pgoutput协议)才是CDC的正统方案,它们读取WAL日志,对源库写入性能几乎零侵入。触发器方案则存在明确的天花板,下面这张表帮你判断该选哪个:
| 维度 | 触发器方案 | 逻辑复制方案 |
|---|---|---|
| 部署复杂度 | 极低,纯SQL | 中高,需配置发布订阅或中间件 |
| 源库性能影响 | 每次写入多一次INSERT | 几乎无影响 |
| 事件格式 | 自定义JSON,灵活 | 固定协议格式 |
| 事务一致性 | 与源事务原子绑定 | 依赖复制槽确认 |
| 高吞吐场景 | 容易成为瓶颈 | 表现稳定 |
| 捕获TRUNCATE | 需单独建语句级触发器 | 部分版本支持 |
从中可以看出,触发器方案的优势在于简单可控、事务一致性强(事件和业务数据在同一个事务里,要么都成功要么都回滚,不会出现半同步状态)、格式完全自定义。它的劣势在高吞吐和低延迟场景:每次业务写入都多一次事件表INSERT,QPS高时事件表本身会成为热点;轮询模式也决定了秒级以下的延迟难以保证。
另外一个边界点是TRUNCATE操作。行级触发器捕获不到TRUNCATE,如果你的业务会清空表,需要额外创建语句级触发器,或者在操作规范中禁止TRUNCATE改为批量DELETE。同理,COPY批量导入会触发行级触发器,一次导入千万行会产生千万条事件,这种场景下要么临时禁用触发器(ALTER TABLE ... DISABLE TRIGGER trg_cdc_orders),要么提前评估事件表的容量。
总结一下,触发器CDC适合的场景是:每天变更量在百万级以内、可以接受秒级延迟、不想引入额外基础设施、且需要对同步格式做深度定制的项目。一旦业务量级超过这个范围,就应该果断切换到逻辑复制加Debezium的方案。好在事件表的设计是向前兼容的,从触发器方案迁移到逻辑复制时,消费端只需改动事件来源,回放逻辑基本可以复用,这也是先用简易方案起步的另一个好处。
PostgreSQL触发器CDC数据同步修改时间:2026-09-05 22:25:05