"我们加个监听,把变更同步过去"——这句话背后藏着四个必须同时解决的问题:初始全量怎么灌、增量怎么抓、冲突怎么裁、错了怎么修。只做增量是绝大多数同步方案走向不一致的起点:订阅从"现在"开始,而历史数据从未被搬运过,第一次全量对齐之前,下游一直缺一段数据,且没人知道缺的是哪一段。
这篇给的是判据与代价,不是搭 Canal 的步骤。
1. 同步要解决的四个问题
| 问题 | 不解决的后果 | 常见做法 | 成本性质 |
|---|---|---|---|
| 初始全量 | 下游从第一天就缺历史 | 快照导出 + 增量衔接 | 一次性,但有锁定与时长 |
| 增量捕获 | 变更漏掉,且不可回放 | CDC / 双写 / 定时拉取 | 持续,取决于捕获方式的完整性 |
| 冲突处理 | 同一行两个版本,谁赢靠运气 | 以源为准 / 时间戳 / 业务合并 | 逻辑复杂度,最容易被漏掉 |
| 对账修复 | 错了没人知道,知道也没法修 | 抽样比对 + 修复任务 | 长期运行成本 |
"只做增量必然不一致"的原因:增量流只描述"发生了什么",不描述"原本是什么"。丢失一条消息、消费失败一次、订阅晚起一天,都表现为一段永久空洞,而增量流本身没有任何机制能发现这个空洞。能发现空洞的只有全量与对账。
2. 四种捕获方式:完整性与侵入性对照
| 方式 | 完整性 | 对业务侵入 | 延迟 | 主要风险 |
|---|---|---|---|---|
| 业务双写 | 依赖代码正确,最脆弱 | 高,写路径里加逻辑 | 最低 | 写库成功写消息失败 |
| CDC 日志订阅 | 高,覆盖所有写入路径 | 低,几乎零侵入 | 秒级 | 位点、schema 变更、解析开销 |
| 定时批量 | 中,靠时间窗与版本列 | 低 | 分钟到小时 | 窗口内多次变更被折叠 |
| 触发器 | 高 | 中,DDL 要同步维护 | 低 | 触发器逻辑难测,写入放大 |
-- 定时批量的必备前提:可增量识别的版本列
SELECT id, name, version, updated_at FROM item
WHERE updated_at >= :last_watermark AND updated_at < :now
ORDER BY updated_at LIMIT 1000;
-- 水位线只能前进,且必须用"数据库时间",不能用应用服务器时间
选型的判据顺序是:先看写入路径能否被日志覆盖(能,优先 CDC),再看能否容忍延迟(不能且路径单一,才考虑双写),最后看数据量是否小到可以整表对齐(小到可以,就别做增量)。
3. 双写的一致性缺口与事务性发件箱
| 缺口 | 发生时机 | 表现 | 缓解 |
|---|---|---|---|
| 写库成功、发消息失败 | 消息中间件抖动 | 下游永久缺这条 | 发件箱 + 重试投递 |
| 先发消息、后写库 | 顺序颠倒 | 下游读到不存在的记录 | 只允许"库先行" |
| 写库成功、消息重复 | 重试与超时并存 | 下游重复计数 | 消费侧幂等键 |
| 双写都在事务内 | 事务回滚但消息已发 | 下游多出一条幽灵记录 | 消息只能事务外投递 |
@Transactional
public void create(Order o) {
orderMapper.insert(o); // 同一本地事务
outboxMapper.insert(Outbox.of("order", o.id)); // 只落库,不发消息
}
// 独立投递器轮询 outbox → 发送 → 标记已投递;失败可重放
事务性发件箱把"两件事原子完成"降级为"一件事原子完成 + 可重放的投递"。代价是多一张表、多一个投递器、多一份延迟,收益是缺口从"永久"变成"可检测可重放"。投递器本身是至少一次语义,所以消费侧仍必须幂等——发件箱解决的是"不丢",不解决"不重"。
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
agg_type VARCHAR(32), agg_id BIGINT,
payload JSON, created_at DATETIME(3),
sent_at DATETIME(3) NULL,
KEY idx_unsent (sent_at, id)
);
4. CDC 的落地要素
| 要素 | 具体内容 | 漏掉会怎样 |
|---|---|---|
| 位点管理 | binlog 文件名 + position 或 GTID | 重启后从头或跳过一段 |
| schema 变更 | 加列、改类型、拆表的下游兼容 | 解析失败或字段错位 |
| 幂等消费 | 以主键 + 版本或事件 ID 去重 | 重放导致重复计数 |
| 延迟监控 | 位点差、端到端延迟 | 积压到磁盘打满才发现 |
| 全量衔接 | 快照位点与增量起点的对接 | 衔接区间重复或缺失 |
# MySQL 侧前置条件(按版本核对参数名)
log_bin = ON
binlog_format = ROW
binlog_row_image = FULL
binlog_expire_logs_seconds = 604800
binlog_row_image=FULL 常被忽略:设成 MINIMAL 时变更事件只带被改动的列,下游拿不到完整行,更新语义退化成"部分字段合并",删改顺序一乱就出错。
还有一个隐蔽前提:CDC 通常连从库而不是主库。从库延迟会直接变成同步延迟,且主从切换后位点可能跳变,所以"连从库"必须配"延迟阈值 + 位点校验",否则你监控的是一段不知道通向哪的流。
5. 全量对齐:一致点怎么选
| 衔接方式 | 一致点 | 风险 | 适用 |
|---|---|---|---|
| 先锁表快照,再起增量 | 快照时刻的位点 | 锁定窗口,大表不可接受 | 小表、可停机 |
| 先起增量,再导快照 | 快照开始时的位点 | 需过滤快照期间的回放 | 常用,要求幂等 |
| 按主键分片滚动快照 | 每片的位点 | 片间时间不一致 | 超大表 |
| 只做定时全量 | 每次全量即一致点 | 窗口内不准 | 小数据量 |
正确顺序(常用):
1) 记录当前位点 P0
2) 启动增量消费,位点从 P0 开始
3) 导出快照,记录快照开始时间 T0 与位点 P1
4) 快照写入下游(按主键覆盖写,幂等)
5) 回放 P0..P1 之间的事件,覆盖写语义保证最终一致
关键判据:全量写入必须是"覆盖写"而不是"插入",否则快照与增量的重叠区间会撞主键或产生双份。一致点不是"某个时刻",而是"某段可重叠、可幂等重放的区间"。
6. 冲突、删除与 schema 演进
冲突处理的三种策略:
| 策略 | 判定依据 | 适用 | 风险 |
|---|---|---|---|
| 以源为准 | 方向固定,源覆盖目标 | 单向同步 | 双向时会互相覆盖 |
| 以时间戳为准 | 最后写入获胜 | 多写、弱冲突 | 时钟漂移导致旧值胜出 |
| 业务合并 | 按字段或规则合并 | 库存、标签等可加量 | 逻辑复杂,难回放 |
删除传播的两种做法:
A) 软删:UPDATE item SET deleted=1 → 下游保留行,查询侧过滤
优点:可回滚、可对账;缺点:下游存储只增不减
B) 物理删:DELETE → 下游必须收到删除事件
优点:干净;缺点:删除事件丢失后下游永久残留,且无法靠全量对齐修复
结论很直接:需要能对账的系统,删除必须是软删或"删除墓碑表"。物理删一旦丢事件,就只能整表重灌。
双向同步要额外说一句:它把冲突处理从"边缘情况"变成"常态"。只要两边都能写,就必然出现同一行两次不同方向的写入,此时"以源为准"失去意义,只剩时间戳与业务合并两选,而这两者都需要一个全局单调的版本来源。
schema 演进的三类变更与下游姿态:加字段(下游忽略未知字段)、改类型(下游按字符串兜底再解析)、拆表(下游按视图或聚合层吸收)。共性是下游必须先于上游具备兼容能力,顺序反了就是一次全量重灌。
7. 延迟、积压与对账周期
| 指标 | 口径 | 判据 |
|---|---|---|
| 位点差 | 源端最新位点 − 消费位点 | 持续增长即消费能力不足 |
| 端到端延迟 | 事件产生时间到写入下游 | 与业务可接受窗口比 |
| 消费速率 | 条/秒 | 需大于写入峰值,否则永久落后 |
| 失配率 | 抽样比对不一致条数占比 | 趋势上升即逻辑有 bug |
-- 对账抽样:按最近变更取一批主键,逐条比对版本与关键字段
SELECT id, version, updated_at FROM item
WHERE updated_at > NOW() - INTERVAL 10 MINUTE LIMIT 500;
-- 失配率超阈值 → 触发按主键范围的重灌,而不是全表重来
对账周期的设计原则:频率由"可容忍的错误暴露时间"决定,不由成本决定。日批对账意味着最长 24 小时的数据错误窗口,如果业务无法承受,就得改成小时级抽样 + 每日全量校验两级结构。
积压出现时的顺序同样有讲究:先确认是"并发不够"还是"单条太慢"。前者扩并行度立刻见效,后者扩并行只会把下游打得更乱。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 下游数据"停在某天" | 消费停止但监控只看进程存活 | 监控改成位点差 |
| 反复出现重复行 | 消费不幂等,重放即重复 | 主键 + 版本去重 |
| 全量后数据对不上 | 快照与增量重叠区间未覆盖写 | 改覆盖写 + 回放区间 |
| 某张表突然不同步 | schema 变更导致解析失败 | 变更前先验证下游兼容 |
| 延迟指标正常但数据错 | 指标只测消费速度,不测正确性 | 加抽样比对 |
| 删除后下游仍有数据 | 物理删事件丢失 | 改软删或墓碑表 |
| 积压越滚越大 | 消费速率低于写入峰值 | 先扩并行度,再查单条处理耗时 |
9. 边界:这些情况不该做同步架构
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 数据量小 | 定时全量覆盖 | 增量机制的复杂度远超收益 |
| 下游能实时查源 | 直接查或开只读接口 | 同步是复制,查询是引用 |
| 强一致要求 | 同库或分布式事务 | 同步天生是最终一致 |
| 只读报表 | 离线跑批 | 不需要秒级 |
| 一次性迁移 | 一次性脚本 + 校验 | 不需要常驻管道 |
最后一类边界最容易被忽视:同步方案一旦引入,就永久成为系统的一部分——它需要监控、需要演练、需要有人懂。把它当成"临时脚本"上线的团队,最后都在维护一条没人敢动的管道。
10. 自测
- 为什么"只做增量"在没有全量核对的情况下必然不一致?
- 双写里"先发消息后写库"会造成什么,为什么只允许库先行?
- 事务性发件箱把什么问题降级成了什么问题?
binlog_row_image=MINIMAL时下游会失去什么能力?- 全量快照与增量衔接的重叠区间,靠什么语义保证不出错?
- 什么情况下"下游直接查源"比同步更正确?