2. CDC 核心原理:三种主流捕获方式
聊 CDC,绕不开一个核心问题:数据到底怎么“抓”出来的?
我见过不少团队,一上来就选某个工具,结果跑了大半年才发现选错了路子。说白了,CDC 的捕获方式就三种——日志捕获、轮询捕获、触发器捕获。每种都有它的脾气,选对了事半功倍,选错了……嗯,后面有你头疼的。
一句话总结三种方式:
- 日志捕获——读数据库的“操作日记”,不侵入业务
- 轮询捕获——定时查表,看哪些数据变了
- 触发器捕获——在数据库里埋钩子,一变就通知
下面我一个一个拆开讲。
2.1 日志捕获(Log-based CDC)—— 生产环境的首选
日志捕获,说白了就是去读数据库的“操作日记”。MySQL 有 Binlog,PostgreSQL 有 WAL,Oracle 有 Redo Log。每次数据变更,数据库都会老老实实写一条日志。CDC 工具就蹲在旁边,一条一条读出来,解析成你能用的格式。
我个人习惯,只要条件允许,优先选日志捕获。为什么?因为它对业务系统零侵入。你想想看,不需要改表结构,不需要加字段,不需要写触发器——数据库自己就在做这件事,你只是“蹭”了一下它的日志而已。
实战经验:我在一个电商订单系统里用过 MySQL Binlog 做 CDC。订单表每秒写入上千条,如果用轮询,数据库 CPU 直接飙到 80%。换成 Canal 读 Binlog 后,CPU 降到 5% 以下。差距就这么大。
日志捕获的工作流程大致是这样的:
- 数据库执行 INSERT/UPDATE/DELETE,写入事务日志
- CDC 工具(如 Debezium、Canal、Maxwell)模拟成 Slave 连接数据库
- 工具读取日志流,解析出变更事件(包含旧值、新值、操作类型)
- 将事件发送到消息队列(Kafka、Pulsar)或直接写入目标端
-- MySQL Binlog 中一条 UPDATE 的原始内容(简化)
# at 12345678
#250101 10:00:00 server id 1 end_log_pos 12345999 Query thread_id=100 exec_time=0 error_code=0
SET TIMESTAMP=1704067200;
UPDATE orders SET status = 'shipped' WHERE order_id = 10086;
-- Debezium 解析后输出的 JSON(简化)
{
"op": "u", -- 操作类型:c=create, u=update, d=delete
"before": {"order_id": 10086, "status": "pending"},
"after": {"order_id": 10086, "status": "shipped"},
"source": {"db": "ecommerce", "table": "orders", "ts_ms": 1704067200000}
}
注意:日志捕获依赖数据库的日志格式。MySQL 必须开启 Binlog,且格式设为 ROW(行模式)。STATEMENT 格式只记录 SQL 语句,CDC 工具根本没法用。我遇到过有人踩这个坑,Binlog 开了但格式不对,折腾了两天才发现。
日志捕获的优点很明显:
- 低延迟——毫秒级,几乎实时
- 无侵入——不改业务代码,不改表结构
- 完整——能捕获所有变更,包括 DELETE 和 TRUNCATE
- 支持全量+增量——先 snapshot 全量,再流式同步增量
缺点也有:
- 依赖日志——数据库得开日志,有些云数据库不给权限
- 日志格式复杂——不同数据库、不同版本,解析逻辑不一样
- 日志堆积——如果消费端挂了,日志会堆积,磁盘可能撑爆
2.2 轮询捕获(Polling-based CDC)—— 简单但粗暴
轮询捕获,就是定时去查表。每次查询带上一个时间戳或版本号,看看哪些数据变了。
举个例子,你的订单表有个 update_time 字段。CDC 程序每隔 5 秒跑一次:
-- 每 5 秒执行一次
SELECT * FROM orders
WHERE update_time > '2025-01-01 10:00:00'
AND update_time <= '2025-01-01 10:00:05';
查到的新数据,就是这段时间内变更的。然后记录下这次查询的最大时间戳,下次从那里开始查。
说实话,轮询捕获是我最早接触 CDC 时用的方式。那时候公司数据库是 SQL Server,DBA 不给开日志权限,没办法,只能轮询。虽然能用,但心里总不踏实。
轮询捕获的核心要素:
- 时间戳字段——每次更新时自动写入当前时间
- 版本号字段——每次更新时自增,用于检测变更
- 轮询间隔——决定了延迟和数据库压力
- 游标记录——记住上次查到哪里了
轮询捕获的优缺点,我列个表给你看:
| 维度 | 说明 |
|---|---|
| 优点 | 实现简单,不依赖数据库日志,通用性强(MySQL、Oracle、SQL Server 都能用) |
| 缺点 1 | 延迟高——轮询间隔决定了延迟,间隔越短压力越大 |
| 缺点 2 | 数据库压力大——每次轮询都是全表扫描或索引扫描 |
| 缺点 3 | 无法捕获 DELETE——除非用软删除(逻辑删除) |
| 缺点 4 | 时间戳精度问题——同一毫秒内多条变更可能漏掉 |
我曾经在一个项目中用轮询同步 500 万行的用户表,轮询间隔设了 10 秒。结果每次查询都要扫描几万条数据,数据库 IO 直接打满。后来加了索引才好一些。但说实话,治标不治本。
轮询捕获适合什么场景?
- 数据量小(百万级以下)
- 变更频率低(几分钟一次)
- 数据库不支持日志捕获(比如某些云数据库)
- 临时方案,快速验证
2.3 触发器捕获(Trigger-based CDC)—— 精确但沉重
触发器捕获,就是在数据库里建触发器。每次 INSERT、UPDATE、DELETE 时,触发器自动把变更记录写到一张专门的日志表里。CDC 程序再去读这张日志表。
听起来很完美对吧?每次变更都被精确记录。但代价是什么?
代价就是数据库性能。每次业务操作,除了执行本身的 SQL,还要额外跑一遍触发器。你想想看,一个订单插入本来只要 2 毫秒,加上触发器变成 5 毫秒。单条看不出来,但每秒几千笔订单,性能损耗就非常可观了。
-- 创建一个触发器来捕获订单表的变更
CREATE TRIGGER trg_orders_cdc
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
BEGIN
-- 将变更记录写入 cdc_log 表
INSERT INTO cdc_log (
table_name,
operation,
old_data,
new_data,
created_at
) VALUES (
'orders',
CASE
WHEN INSERTING THEN 'INSERT'
WHEN UPDATING THEN 'UPDATE'
WHEN DELETING THEN 'DELETE'
END,
-- 旧值(DELETE 和 UPDATE 时有值)
CASE
WHEN DELETING OR UPDATING THEN
JSON_OBJECT('order_id', :OLD.order_id, 'status', :OLD.status)
ELSE NULL
END,
-- 新值(INSERT 和 UPDATE 时有值)
CASE
WHEN INSERTING OR UPDATING THEN
JSON_OBJECT('order_id', :NEW.order_id, 'status', :NEW.status)
ELSE NULL
END,
SYSTIMESTAMP
);
END;
我的建议:触发器捕获能不用就不用。除非你有非常特殊的需求,比如需要精确记录每一次变更的完整前后值,而且数据量不大。我在一个金融审计项目里用过,每天几十万笔交易,触发器跑得还算稳。但如果是互联网高并发场景,千万别碰。
触发器捕获的优缺点:
| 维度 | 说明 |
|---|---|
| 优点 | 实时性高,精确记录每次变更,不依赖日志格式 |
| 缺点 1 | 侵入性强——需要修改数据库结构,增加触发器 |
| 缺点 2 | 性能影响大——每条 DML 都额外执行触发器逻辑 |
| 缺点 3 | 维护成本高——表结构变更时,触发器也要同步更新 |
| 缺点 4 | 日志表可能成为瓶颈——高并发下写入冲突 |
2.4 三种方式怎么选?
讲到这里,你应该心里有数了。我直接给结论:
选型优先级:
- 日志捕获——能选就选,90% 的场景都适用
- 轮询捕获——日志不可用时,数据量小、频率低的情况下用
- 触发器捕获——除非万不得已,别碰
最后说一句,选 CDC 方式不是一锤子买卖。我见过不少项目,一开始用轮询,后来数据量大了,再迁移到日志捕获。迁移过程很痛苦,因为要保证数据不丢不重。所以,一开始就选对路子,比后面折腾强得多。