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(按主键覆盖)。这样既不丢也不重,代价是快照期间要保留日志。