46.4 CDC (变更数据捕获)

一句话

CDC 的价值是把“轮询全表”换成“订阅变更流”:它不改变数据本身,只改变“别人怎么知道数据变了”——日志型 CDC 能拿到每一次变更(含中间态),查询型 CDC 只能看到某个时刻的快照。

三种实现

方式 原理 能否看到中间态 对源库压力 延迟
日志型(binlog/WAL) 订阅数据库日志,解析成变更事件 ✅ 每一次都看得到 极低(读日志) 毫秒–秒
查询型(时间戳轮询) 定时 WHERE updated_at > ? ❌ 只看最后一次 高(扫表/索引) 轮询间隔
触发器型 在表上加 trigger 写变更表 ✅ 高(影响写事务) 低

🔑 判据:源库能不能开日志、变更要不要保序、下游要不要中间态。三个都要 → 日志型;只是“每天同步一次报表” → 查询型足够。

代码:轮询与日志的账

package main

import "fmt"

const (
	tableRows   = 1_000_000.0 // 源表行数
	changeRate  = 0.05      // 每小时有 5% 的行发生变化
	changesPerRow = 3.0     // 每个被改的行平均改 3 次(中间态)
)

func main() {
	fmt.Printf("源表 %.0f 行,每小时 %.0f%% 行变化,每行平均改 %.0f 次\n\n",
		tableRows, changeRate*100, changesPerRow)

	fmt.Printf("%-14s %-16s %-18s %-18s %s\n", "方式", "每天扫描行数", "每天捕获事件数", "丢失的中间态", "备注")
	// 查询型:每个轮询周期扫一遍表(最坏),只取最后一次
	for _, minutes := range []int{1, 5, 60} {
		pollsPerDay := 1440 / minutes
		scanned := float64(tableRows) * float64(pollsPerDay)
		changedRowsPerHour := tableRows * changeRate
		events := changedRowsPerHour * 24 // 每个变化行只看到一次最终态
		lost := changedRowsPerHour * 24 * (changesPerRow - 1)
		fmt.Printf("%-14s %-16.0f %-18.0f %-18.0f %s\n",
			fmt.Sprintf("轮询 %dmin", minutes), scanned, events, lost, "扫全表,只保最终态")
	}
	// 日志型:只读变更事件
	events := tableRows * changeRate * changesPerRow * 24
	fmt.Printf("%-14s %-16s %-18.0f %-18s %s\n", "binlog", "0(不扫表)", events, "0", "每次变更一条事件")

	fmt.Println("\n=== 轮询的另一个坑:updated_at 不可靠 ===")
	fmt.Println("同一秒内的多次更新、时钟回拨、批量补数,都可能让 updated_at 漏掉变更")
}

本机实测(macOS,Apple M1,Go 1.27):

源表 1000000 行,每小时 5% 行变化,每行平均改 3 次

方式             每天扫描行数           每天捕获事件数            丢失的中间态             备注
轮询 1min        1440000000       1200000            2400000            扫全表,只保最终态
轮询 5min        288000000        1200000            2400000            扫全表,只保最终态
轮询 60min       24000000         1200000            2400000            扫全表,只保最终态
binlog         0(不扫表)           3600000            0                  每次变更一条事件

=== 轮询的另一个坑:updated_at 不可靠 ===
同一秒内的多次更新、时钟回拨、批量补数,都可能让 updated_at 漏掉变更

读数:

方式 每天扫描 每天事件 丢中间态
轮询 1 分钟 14.4 亿行 120 万 240 万
轮询 5 分钟 2.88 亿行 120 万 240 万
轮询 1 小时 2400 万行 120 万 240 万
binlog 0 360 万 0

🔑 两个结论:① 轮询的扫描量与频率成正比,而捕获的中间态永远是 0——下游只能看到“最终长什么样”,看不到“怎么变的”;② 日志型事件数是真实的 360 万(含中间态),这正是“能重放历史”的基础。

CDC 的五个工程细节

细节 说明
初始快照 必须先全量快照 + 从对应位点开始增量,否则中间有空洞
位点管理 位点要持久化,重启从上次位点继续(至少一次)
Schema 演进 加列/改类型要能被下游解析(42.2 的兼容规则)
顺序 同一主键的变更要保序(按 binlog 位点 / 按主键分区)
幂等 下游要按主键 upsert,重复投递不能出错(25.4)

⚠️ 最常见的 CDC 事故是快照与增量之间的空洞:全量快照跑了 30 分钟,而增量从“开始快照的时刻”开始读,这 30 分钟里的变更要么重复、要么丢失。正确做法是“记录快照开始的位点 → 快照 → 从该位点重放并去重”。

反模式

反模式 后果
用 updated_at 轮询做实时同步 漏中间态、漏同秒更新,且扫表压力大
快照与增量位点不对齐 数据空洞或重复
CDC 下游不做幂等 重启/重放导致重复
不保留 binlog 足够久 下游故障恢复时位点已过期,只能重做全量
把 CDC 当“实时数仓”唯一来源 跨库 join 与状态计算仍需流处理(62.6 实时数仓)
忽略 DDL 变更 加列后下游解析失败,整条链路停

自检

Q1 为什么查询型 CDC(轮询 updated_at)会丢“中间态”,这在什么业务下是致命的?

▸ 答案:轮询只能看到“两次查询之间这一行最后的样子”,中间的多次更新被覆盖。对“只关心最终状态”的场景(同步商品信息、更新用户资料)无所谓;但对“状态机/审计/风控”是致命的——比如订单 created → paid → shipped,如果两次轮询之间全发生了,下游只看到 shipped,就永远不知道“曾经支付过”,也可能漏掉“支付后取消”的异常路径。

Q2 日志型 CDC 为什么能保证顺序,而轮询不能?

▸ 答案:日志本身就是按提交顺序追加的,位点单调递增,解析出来天然有序;轮询是按时间戳过滤,同一时间戳的多条变更顺序不确定,而且不同表的变更没有全局顺序。要跨表有序,只能依赖日志位点(或引入全局序列)。

Q3 全量快照 + 增量同步时,怎么避免“快照期间变更丢失”?

▸ 答案:三步:① 记录快照开始时的日志位点 L0;② 执行全量快照(读的是某个一致性快照,如 START TRANSACTION WITH CONSISTENT SNAPSHOT);③ 从 L0 开始重放增量,并对“快照里已包含的行”做幂等 upsert(按主键覆盖)。这样既不丢也不重,代价是快照期间要保留日志。

关联