CDC 经典论文《DBLog: A Watermark Based Change-Data-Capture Framework》解析

深度剖析 Netflix 开源的无锁全量+增量数据同步方案

一、 什么是 CDC(变更数据捕获)?

在微服务时代,没有一种单一的数据库设计能够满足所有的业务需求。比如,一个服务可能用 MySQL 存核心业务数据,同时用 Elasticsearch 来提供强大的搜索功能。

为了让这些异构数据库保持同步,传统的做法是“双写”(应用程序同时写两个库)或者分布式事务,但这些方法在可行性、健壮性和维护性上都有很大的局限性。

于是,CDC(Change-Data-Capture,变更数据捕获)技术应运而生。简单来说,CDC 就是一个“监听器”。它悄悄地盯着数据库的事务日志(Transaction Log,比如 MySQL 的 binlog),一旦数据库里有增删改查,CDC 就能在接近实时的情况下捕获到这些变更的行,并把它们推送到下游去。


二、 业界痛点:存量与增量的两难

CDC 听起来很完美,但它面临一个致命的物理限制:数据库的事务日志通常只保留最近一段时间的数据,不包含完整的历史记录。

如果要初始化一个全新的下游数据库,光靠读实时日志是不够的,还需要获取数据库当前的全量状态(Full State)。老一代的 CDC 工具在处理“全量导出 + 增量日志”时,往往顾此失彼:

  • Debezium:为了保证数据一致性,它在全量导出时会使用表锁。对于大数据库,这会长时间阻塞应用程序的写入请求。
  • Maxwell:采用的策略是暂停处理增量日志,然后全量拉取想要的数据表。这容易导致“时间旅行”现象(即下游先收到了未来的新数据,随后又收到了全量拉取时的老数据,顺序错乱)。
  • MySQLStreamer:使用了内建于特定数据库厂商的功能,导致代码难以跨不同的数据库复用。

Netflix 在生产环境中深受其扰,于是需要一个不锁表、不长时间阻塞日志、随时能启停、并且不依赖特定数据库魔术的完美方案 —— DBLog 诞生!


三、 核心原理:DBLog 的“水印”魔法

DBLog 的最牛之处在于:它允许以“块(Chunk)”为单位执行全量查询(Selects),并将全量查询的数据与实时的事务日志事件无缝交织在一起,且无需加锁

核心在于 水印(Watermark)算法

1. 创建水印表

DBLog 会在源数据库建一个专门的“水印表”(在一个独立的命名空间里,不会和业务表冲突)。

  • 这个表只有一行数据,存着一个 UUID
  • 通过更新这个 UUID,就会在事务日志中生成一条可识别的“水印变更事件”。

3. 分块读取与水印交织

当需要同步某张表(比如一张千万级用户表)的全量数据时,DBLog 会将全量读取按主键切分成多个小块(Chunks)。对于每一个 Chunk,执行以下步骤:

  1. 暂停:短暂暂停增量日志事件的处理。
  2. 打低水位(Low Watermark):更新水印表的 UUID,生成一个“低水位”日志事件。
  3. 抓取全量块:通过主键范围,从表中 SELECT 抓取这一小块的存量数据,放在内存里。
  4. 打高水位(High Watermark):再次更新水印表,生成一个“高水位”日志事件。
  5. 恢复日志:恢复之前的增量日志处理。

[!NOTE] 步骤 2 到 4 非常快,因为仅仅是单行更新和一个带 LIMIT 的主键索引查询,所以日志暂停时间极短。

3. 消除冲突:防止时空错乱

在刚才的低水位(LW)和高水位(HW)之间,如果正好有用户的业务请求修改了这部分数据怎么办?

DBLog 监听着事务日志,当它读到低水位和高水位之间的日志时:

  • 如果发现某条实时增量日志的主键,正好在刚才内存里抓取的 Chunk 数据中,它会直接从内存 Chunk 里删掉那条全量数据
  • 因为实时的增量日志才是最新、最准确的。

最终输出顺序高水位之前的增量日志剔除了冲突数据后的存量 Chunk 块高水位之后的增量日志


四、 优势对比

通过这个算法,DBLog 实现了降维打击。我们可以看下论文中提供的全量状态捕获需求对比:

特性 Databus Debezium Maxwell MySQLStreamer DBLog
随时触发全量捕获 Unknown 支持
支持暂停和恢复 Unknown
日志处理不卡顿
保存历史顺序
无需锁表
无厂商特定功能

五、 核心推演:责任链模式视角

为了便于理解,我们用设计模式中“责任链模式”的视角来拆解这个过程。

1. 存储节点:两个核心数据区

论文中明确指出,这俩数据区都存在于 DBLog 进程所在的服务器内存中:

  • Chunk 内存区(Chunk in-memory):临时存放 SELECT 出来的全量数据。它就像一个“临时清洗池”,数据在这里会被比对、剔除,生命周期很短。
  • 输出缓冲区(Output Buffer):有序队列,所有最终要发给下游(如 Kafka)的数据都会先在此排队。

2. 处理节点:对象交互链路

  • 对象 1:MySQL(源头节点)
    • 职责:数据的发源地。
    • 动作:接收业务 DML,也接收 DBLog 发来的水印更新。
  • 对象 2:DBLog - 全量抓取器
    • 职责:控制节奏、打标获取全量块。
    • 动作:发送暂停指令 → 写入低水位 → 发起 SELECT → 写入高水位 → 通知恢复。
  • 对象 3:DBLog - 日志流处理器
    • 职责:最核心的“质检员”,负责消费日志、清洗 Chunk 并合并输出流。
    • 动作:
      1. 顺序读取事务日志。
      2. 遇到普通变更日志,直接序列化追加到输出缓冲区。
      3. 遇到低水位(L),开启时间窗口。
      4. 【核心清洗】:在 L 和 H 之间,若读到用户的变更日志,除写入输出缓冲区外,还会比对 Chunk 内存区。若有重合,则将其删掉。
      5. 遇到高水位(H),窗口关闭,将 Chunk 内存区幸存的数据推入输出缓冲区。
  • 对象 4:下游调度器与 Kafka
    • 职责:将最终组装好的正确数据送达目的地。

六、 生产环境实战 FAQ

🙋‍♂️ Q1:DBLog 进程宕机重启,如何保证数据“不重不漏”?

DBLog 采用 Active-Passive 架构,配合 ZooKeeper

  • 进度存档:Active 节点会不断将完成的 Chunk 边界信息记录到 ZooKeeper。
  • 断点续传:新 Leader 会读取存档,放弃处理到一半的 Chunk,重新从上次成功的边界开始。
  • 幂等性:输出数据本质是带有主键的 Upsert,保证了最终一致性。

🙋‍♂️ Q2:为什么要把“增量日志”和“全量查询”塞到同一个流(Kafka Topic)?

这关乎 “冷启动”“持续保鲜”

  • 下游(如 ES)需要全量基准数据来建立索引。
  • 通过统一流,下游消费者只需监听单流,即可完成从冷启动到实时更新的平滑过渡,无需复杂的流重组逻辑。

🙋‍♂️ Q3:处理第 100 个 Chunk 时,用户修改了第 1 个 Chunk 的数据怎么办?

完全不受影响。增量日志处理器是全局且持续运行的。打水印和剔除冲突仅针对当前正在抓取的那个 Chunk。第 100 个 Chunk 的内存中没有第 1 个 Chunk 的数据,所以变更会被视为普通实时变更直接转发。

🙋‍♂️ Q4:遇到数据库“表结构变更(DDL)”怎么处理?

  • PostgreSQL:使用 wal2json 插件,平衡事件自带元数据(列名、类型)。
  • MySQL:在日志流中拦截 DDL 事件,以此来感知并更新 DBLog 内部的元数据缓存。

参考资料

Tags: MySQL CDC DBLog
Share: X (Twitter) Facebook LinkedIn