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的数据流模型串起来:

CDC数据流模型 数据源 MySQL / PostgreSQL / Oracle 日志解析引擎 Canal / Debezium / Maxwell 消息中间件 Kafka / Pulsar / RabbitMQ 实时计算 Flink / Spark Streaming 目标存储 Elasticsearch / HBase 缓存/搜索 Redis / Solr 说明: 数据从源库流出,经过日志解析引擎转换成统一格式,进入消息中间件解耦, 最后由下游消费者按需消费。这是目前最成熟、最稳定的CDC架构。 全量同步(Snapshot) 一次性读取所有历史数据 通常使用 SELECT * FROM table 增量同步(Streaming) 持续监听日志,实时捕获变更 延迟通常在毫秒级 衔接

这个模型,说白了就是三层架构:

  1. 源端(Source): 数据库产生日志。
  2. 解析层(Connector): CDC工具读取日志,解析成统一格式(通常是JSON或Avro)。
  3. 消费端(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。