数据三角对账兜底:MySQL + Doris + ES 不一致怎么办
问题
在 MySQL / Doris / ES 三角选型 一文中,我给出了”MySQL 做事务、ES 做搜索、Doris 做分析”的分工框架。在 ES 与 MySQL 双写一致性 和 Outbox 模式 中,我写了怎么用 MQ 和 Outbox 尽量保证数据同步的一致性。
但有一个问题一直没正面回答:
同步链路再完善,最终也会不一致。怎么办?
CDC 可能丢消息、MQ 可能重复投递、消费者可能超时、ES 的 refresh interval 可能导致刚写入的数据查不到、Doris 的 Routine Load 可能因为数据格式错误跳过一批。这些不是”如果”,是”什么时候”。
当不一致发生时,你的系统需要一个兜底机制——对账。
先定义”不一致”是什么意思
三个系统的数据不一致,有几种典型表现:
| 不一致类型 | 表现 | 典型原因 |
|---|---|---|
| MySQL 有、ES/Doris 没有 | 新数据在 MySQL 但搜索/分析查不到 | CDC 延迟、MQ 丢消息、消费者宕机 |
| MySQL 没有、ES/Doris 有 | 已删除的数据在搜索/分析里还在 | 删除操作未同步、消费者处理 DELETE 事件失败 |
| 字段值不一致 | 同一条数据在 MySQL 和 ES/Doris 里值不同 | 消费者处理更新时用了旧值、字段映射错误 |
| 数量不一致 | MySQL 10 万行,ES 9.9 万行 | 静默丢失,通常是最危险的 |
最危险的不是第一种(用户能感知”新数据查不到”,会重试),而是第四种——静默丢失。系统不会报错,监控不会告警,但数据就是少了。用户发现时可能已经过了好几天。
对账的三个层次
第一层:计数对账(发现有没有问题)
最简单的对账:数行数。
1 | -- MySQL 行数 |
三个数字一比,就知道有没有丢数据。
什么时候跑:每小时跑一次,按小时窗口对账。如果某个小时的行数不一致,说明那个小时的同步链路出了问题。
局限:计数对账只能发现”丢了多少”,不能定位”丢了哪条”。如果你有 10 万行,少了 100 行——是哪 100 行?计数对账回答不了。
第二层:主键对账(定位哪些数据不一致)
比对三个系统中每条数据的主键(ID),找出差异集。
1 | -- MySQL 有但 Doris 没有的 ID |
ES 端:用 terms 聚合拿到所有 ID,和 MySQL 的 ID 集合做差集。或者用 ES 的 _mget 批量检查 ID 是否存在。
怎么实现:
- 从 MySQL 拉一个时间窗口内的所有 ID(如昨天 14:00-15:00 的订单 ID)
- 从 Doris 拉同一时间窗口的所有 ID
- 从 ES 拉同一时间窗口的所有 ID
- 三个集合做差集,找出只在其中一个系统存在的 ID
局限:主键对账只能发现”有没有”,不能发现”值对不对”。如果同一条数据的 status 字段在 MySQL 是 PAID,在 ES 是 PENDING——主键对账发现不了。
第三层:内容对账(发现字段级不一致)
抽取关键字段,比对值是否一致。
1 | -- MySQL |
把两边的结果集拉到内存里逐行比对。如果 ID 一致但 status 不一致,就是字段级不一致。
ES 端:用 scroll 或 search_after 批量拉取数据,然后比对。
成本:内容对账是最贵的。每条数据要拉 3 份(MySQL + Doris + ES),逐字段比对。10 万行的对账可能需要拉 30 万行数据到内存。
什么时候跑:每天跑一次,对账前一天的全部数据。不是实时对账——实时内容对账的成本和延迟都不可接受。
对账发现不一致后怎么办
策略 1:以 MySQL 为准,重放同步
发现不一致后,最简单的修复方式是以 MySQL(源库)为准,重新同步差异数据。
1 | 不一致的 ID → 从 MySQL 拉最新数据 → 重新写入 ES 和 Doris |
1 | // 伪代码:修复不一致 |
好处:简单可靠,MySQL 是 source of truth。
注意:重放同步时要处理消费者幂等。ES 的 update 是幂等的(按 ID 覆盖),Doris 的 upsert 也是幂等的。但如果你的消费者有副作用(如发通知、触发其他流程),重放可能导致副作用重复执行。
策略 2:标记不一致,人工确认后修复
不是所有不一致都应该自动修复。某些不一致可能是”预期的延迟”——数据还没同步完,对账跑早了。
1 | 不一致的 ID → 写入 reconciliation_diff 表 → 标记状态 PENDING → 等待 N 分钟后复查 → 仍然不一致则告警 |
1 | CREATE TABLE reconciliation_diff ( |
好处:避免误报。CDC 延迟在正常范围内时不告警,只有持续不一致才触发。
策略 3:告警 + 手动介入
对于关键数据(如订单状态、支付金额),不一致应该立即告警,由人工确认后修复。
1 | 关键字段不一致 → 立即触发告警(DingTalk/飞书)→ 人工排查根因 → 修复 |
判断标准:字段是否”关键”。订单状态是关键的,用户头像 URL 是不关键的。关键字段不一致 → 告警;非关键字段不一致 → 记录 + 定期修复。
实际的对账系统设计
1 | ┌─────────────┐ |
分层执行
- 计数对账(每小时):成本最低,10 秒内完成。发现行数不一致就触发主键对账。
- 主键对账(每 3 小时):中等成本,需要拉 ID 集合。定位差异 ID。
- 内容对账(每天凌晨):成本最高,全量字段比对。发现字段级不一致。
为什么不每次都做内容对账
成本。10 万行数据的全量内容对账需要拉 30 万行数据(3 个系统 × 10 万),网络 IO 和计算成本高。每小时做一次不现实。
分层执行的好处是:大部分时候只做计数对账就够了。 只有计数不一致时才升级到主键对账,只有主键对账有问题时才升级到内容对账。
一个真实的对账坑
曾遇到过一个问题:Doris 的行数始终比 MySQL 多 1 行。
计数对账每小时告警,每次都是”多 1 行”。主键对账发现是某条 ID 在 Doris 存在但 MySQL 不存在。
排查发现:Doris 的 Routine Load 消费 Kafka 时,某条消息被消费了两次(Kafka 的 at-least-once 语义 + Doris 没做幂等)。MySQL 端的 DELETE 事件被 CDC 丢失了(Canal 在那个时间点重启了一次),但 INSERT 事件先到达 Doris,DELETE 事件丢失了。
修复:在 Doris 端的主键模型设为 REPLACE 模式(相同主键后到的覆盖先到的),同时在消费端加幂等校验(基于 id + update_time 去重)。
教训:计数对账发现”多了 1 行”时,不要假设是”误报”。每一条不一致都值得追根溯源——“多了 1 行”可能意味着你的 CDC 链路在某次重启时丢了 DELETE 事件。
这意味着什么
一致性保证是分层的:同步链路(CDC + MQ + Outbox)是第一层保证,对账是第二层兜底。第一层追求”尽量不丢”,第二层保证”丢了能发现”。
对账不需要实时,但需要分层。 计数对账每小时跑一次(低成本发现异常),主键对账每几小时跑一次(定位问题),内容对账每天跑一次(兜底字段级不一致)。
修复策略要区分严重性。 非关键字段不一致可以延迟修复,关键字段不一致必须立即告警。不是所有不一致都值得人工介入。
“以 MySQL 为准”是对账的基本原则。 MySQL 是 source of truth,ES 和 Doris 是派生数据。修复不一致时,始终以 MySQL 的值为准覆盖 ES/Doris。
