一、 什么是 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,执行以下步骤:
- 暂停:短暂暂停增量日志事件的处理。
- 打低水位(Low Watermark):更新水印表的
UUID,生成一个“低水位”日志事件。 - 抓取全量块:通过主键范围,从表中
SELECT抓取这一小块的存量数据,放在内存里。 - 打高水位(High Watermark):再次更新水印表,生成一个“高水位”日志事件。
- 恢复日志:恢复之前的增量日志处理。
[!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 并合并输出流。
- 动作:
- 顺序读取事务日志。
- 遇到普通变更日志,直接序列化追加到输出缓冲区。
- 遇到低水位(L),开启时间窗口。
- 【核心清洗】:在 L 和 H 之间,若读到用户的变更日志,除写入输出缓冲区外,还会比对 Chunk 内存区。若有重合,则将其删掉。
- 遇到高水位(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 内部的元数据缓存。