2、CDC核心原理:数据库日志解析机制
大家好,我是老赵。做数据同步这些年,我踩过最大的坑就是——没搞懂数据库日志,就敢上CDC。结果呢?生产环境一跑,数据对不上,半夜被运维电话叫醒。嗯,今天咱们就把这个根基打牢。
说白了,CDC(Change Data Capture)的核心就一句话:从数据库的“操作日记”里,把变更数据捞出来。这个“操作日记”,在MySQL里叫Binlog,在PostgreSQL里叫WAL。咱们一个一个拆开看。
2.1 MySQL Binlog:行级变更的“录音带”
MySQL的Binlog,你可以把它想象成一台永不停止的录音机。只要数据库里有数据变更(INSERT、UPDATE、DELETE),它就原原本本地录下来。
我个人习惯把Binlog的格式分成三种,咱们用表格对比一下:
| 格式 | 记录内容 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | 记录原始SQL语句 | 日志量小,写入快 | 依赖上下文,主从可能不一致 |
| ROW | 记录每一行数据变更前后的值 | 精确、可靠、主从一致 | 日志量大,尤其大字段 |
| MIXED | 自动判断:安全时用STATEMENT,不安全时用ROW | 折中方案 | 逻辑复杂,偶有意外 |
我的建议:做CDC实时同步,必须用ROW格式。为什么?因为你要的是“这一行数据从A变成了B”的精确信息,而不是一条SQL。我在项目中遇到过,用STATEMENT格式同步,结果因为时间函数(NOW())导致主从数据不一致,排查了整整两天。
Binlog里到底长什么样?我给你们看一段伪代码,模拟一条UPDATE语句在ROW格式下的记录:
# Binlog事件:Table_map(映射表结构)
table_id: 108
database: "shop"
table: "orders"
# Binlog事件:Update_rows(更新行)
before: { id: 1001, status: "pending", amount: 299.00, update_time: "2024-01-15 10:00:00" }
after: { id: 1001, status: "paid", amount: 299.00, update_time: "2024-01-15 10:05:30" }
看到了吗?before和after清清楚楚。CDC工具(比如Canal、Debezium)就是解析这种事件,然后转换成统一的变更消息,发给Kafka或者直接写入目标库。
避坑指南:我曾经在生产环境遇到过Binlog被自动清理导致同步中断的问题。记住,Binlog的过期时间(expire_logs_days)要设置得足够长,至少能覆盖你CDC工具可能中断恢复的时间窗口。我一般设7天。
2.2 PostgreSQL WAL:更底层的“事务日志”
PostgreSQL的WAL(Write-Ahead Log),跟MySQL的Binlog不太一样。它更底层,记录的是数据页的物理变更,而不是逻辑SQL。
你想想看,MySQL的Binlog是“逻辑日志”,告诉你“某行数据变了”。而PostgreSQL的WAL是“物理日志”,告诉你“磁盘上的第100号数据页,偏移量2048处,写了4个字节”。
不过,做CDC的时候,我们通常不直接解析WAL的原始格式——那太底层了,而且不同版本格式可能变化。PostgreSQL提供了一个叫逻辑复制(Logical Replication)的机制,它把WAL里的物理变更,翻译成我们能看懂的“逻辑变更”。
逻辑复制的核心概念:
- 发布(Publication): 定义哪些表的数据变更要暴露出去。可以是一张表,也可以是多张表。
- 订阅(Subscription): 定义从哪里接收变更数据。通常是一个发布。
- 复制槽(Replication Slot): 这是关键!它记录了消费者已经消费到WAL的哪个位置。如果消费者挂了,WAL不会被清理,直到消费者恢复并继续消费。
注意:复制槽是把双刃剑。它保证了数据不丢,但如果消费者长期不消费,WAL会无限堆积,最终撑爆磁盘。我曾经见过一个案例,CDC消费者挂了3天,复制槽没清理,结果WAL把200GB的磁盘写满了,数据库直接宕机。所以,一定要监控复制槽的延迟。
PostgreSQL逻辑复制的变更消息,通常长这样(以Debezium格式为例):
{
"op": "u", // 操作类型:c=create, u=update, d=delete, r=read
"before": { // 变更前的数据(UPDATE/DELETE时有)
"id": 1001,
"status": "pending"
},
"after": { // 变更后的数据(CREATE/UPDATE时有)
"id": 1001,
"status": "paid"
},
"source": { // 元数据
"db": "shop",
"table": "orders",
"lsn": "0/1A2B3C4D", // WAL中的位置
"ts_ms": 1705300000000
}
}
2.3 Change Data Capture的数据流模型
搞懂了日志,咱们来看看数据是怎么流出来的。我画了一张图,把整个CDC的数据流模型串起来:
这个模型,说白了就是三层架构:
- 源端(Source): 数据库产生日志。
- 解析层(Connector): CDC工具读取日志,解析成统一格式(通常是JSON或Avro)。
- 消费端(Sink): 下游系统消费变更数据,写入目标。
我个人习惯在解析层和消费端之间加一层消息队列(比如Kafka)。为什么?解耦。源库挂了不影响消费端,消费端挂了不影响源库。而且Kafka能帮你缓冲流量高峰,避免把下游系统打爆。
2.4 全量同步 vs 增量同步:一个都不能少
很多新手会问:CDC不就是做增量同步吗?为什么还要全量?
我举个例子你就明白了。假设你要把MySQL里的“订单表”(有1000万条历史数据)同步到Elasticsearch。如果你只做增量同步,那历史数据就永远过不去了。所以,全量同步是增量同步的前提。
咱们用表格对比一下两者的核心差异:
| 对比维度 | 全量同步(Snapshot) | 增量同步(Streaming) |
|---|---|---|
| 数据范围 | 表中所有历史数据 | 从某个时间点之后的新增/变更数据 |
| 执行方式 | 一次性批量读取(SELECT *) | 持续监听日志,实时推送 |
| 延迟 | 分钟级到小时级(取决于数据量) | 毫秒级到秒级 |
| 对源库影响 | 较大(大量读IO,可能锁表) | 极小(只读日志,不锁表) |
| 典型场景 | 初始化数据、数据迁移、重建索引 | 实时报表、缓存更新、事件驱动架构 |
关键点:在实际项目中,全量和增量是配合使用的。先做一次全量同步,把历史数据“搬”过去。然后记录下全量结束时的Binlog位置(或WAL的LSN),从这个位置开始做增量同步。这样,历史数据和实时数据就无缝衔接了。
我记得有一次做电商订单同步,全量同步跑了3个小时,结果增量同步启动后,发现丢了全量期间产生的变更数据。为什么?因为全量同步和增量同步的衔接点没对齐。后来我学乖了:全量同步开始前,先记录一个Binlog位置;全量同步结束后,从这个位置开始增量。这样就不会丢数据了。
避坑指南:全量同步时,如果表很大(比如上亿行),千万不要直接用SELECT * FROM table一把梭。我建议分批读取,比如每次读10万行,用主键或唯一索引做分页。否则,轻则把数据库IO打满,重则导致主从延迟飙升,甚至触发OOM。