问题

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- MySQL 行数
SELECT COUNT(*) FROM orders WHERE create_time >= '2026-07-26 00:00:00';

-- Doris 行数
SELECT COUNT(*) FROM doris_orders WHERE create_time >= '2026-07-26 00:00:00';

-- ES 行数(通过 _count API)
POST /orders/_count
{
"query": {
"range": {
"create_time": { "gte": "2026-07-26T00:00:00" }
}
}
}

三个数字一比,就知道有没有丢数据。

什么时候跑:每小时跑一次,按小时窗口对账。如果某个小时的行数不一致,说明那个小时的同步链路出了问题。

局限:计数对账只能发现”丢了多少”,不能定位”丢了哪条”。如果你有 10 万行,少了 100 行——是哪 100 行?计数对账回答不了。

第二层:主键对账(定位哪些数据不一致)

比对三个系统中每条数据的主键(ID),找出差异集。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- MySQL 有但 Doris 没有的 ID
SELECT id FROM orders
WHERE create_time >= '2026-07-26 00:00:00'
MINUS
SELECT id FROM doris_orders
WHERE create_time >= '2026-07-26 00:00:00';

-- Doris 有但 MySQL 没有的 ID
SELECT id FROM doris_orders
WHERE create_time >= '2026-07-26 00:00:00'
MINUS
SELECT id FROM orders
WHERE create_time >= '2026-07-26 00:00:00';

ES 端:用 terms 聚合拿到所有 ID,和 MySQL 的 ID 集合做差集。或者用 ES 的 _mget 批量检查 ID 是否存在。

怎么实现

  1. 从 MySQL 拉一个时间窗口内的所有 ID(如昨天 14:00-15:00 的订单 ID)
  2. 从 Doris 拉同一时间窗口的所有 ID
  3. 从 ES 拉同一时间窗口的所有 ID
  4. 三个集合做差集,找出只在其中一个系统存在的 ID

局限:主键对账只能发现”有没有”,不能发现”值对不对”。如果同一条数据的 status 字段在 MySQL 是 PAID,在 ES 是 PENDING——主键对账发现不了。

第三层:内容对账(发现字段级不一致)

抽取关键字段,比对值是否一致。

1
2
3
4
5
6
7
8
9
10
11
-- MySQL
SELECT id, status, update_time FROM orders
WHERE create_time >= '2026-07-26 14:00:00'
AND create_time < '2026-07-26 15:00:00'
ORDER BY id;

-- Doris
SELECT id, status, update_time FROM doris_orders
WHERE create_time >= '2026-07-26 14:00:00'
AND create_time < '2026-07-26 15:00:00'
ORDER BY id;

把两边的结果集拉到内存里逐行比对。如果 ID 一致但 status 不一致,就是字段级不一致。

ES 端:用 scrollsearch_after 批量拉取数据,然后比对。

成本:内容对账是最贵的。每条数据要拉 3 份(MySQL + Doris + ES),逐字段比对。10 万行的对账可能需要拉 30 万行数据到内存。

什么时候跑:每天跑一次,对账前一天的全部数据。不是实时对账——实时内容对账的成本和延迟都不可接受。

对账发现不一致后怎么办

策略 1:以 MySQL 为准,重放同步

发现不一致后,最简单的修复方式是以 MySQL(源库)为准,重新同步差异数据。

1
不一致的 ID → 从 MySQL 拉最新数据 → 重新写入 ES 和 Doris
1
2
3
4
5
6
7
// 伪代码:修复不一致
List<Long> inconsistentIds = reconciliationService.findInconsistentIds(hourWindow);
for (Long id : inconsistentIds) {
Order order = mysqlMapper.selectById(id);
esClient.update("orders", id, order);
dorisMapper.upsert(order);
}

好处:简单可靠,MySQL 是 source of truth。

注意:重放同步时要处理消费者幂等。ES 的 update 是幂等的(按 ID 覆盖),Doris 的 upsert 也是幂等的。但如果你的消费者有副作用(如发通知、触发其他流程),重放可能导致副作用重复执行。

策略 2:标记不一致,人工确认后修复

不是所有不一致都应该自动修复。某些不一致可能是”预期的延迟”——数据还没同步完,对账跑早了。

1
不一致的 ID → 写入 reconciliation_diff 表 → 标记状态 PENDING → 等待 N 分钟后复查 → 仍然不一致则告警
1
2
3
4
5
6
7
8
9
CREATE TABLE reconciliation_diff (
id BIGINT PRIMARY KEY,
source VARCHAR(20), -- mysql / doris / es
diff_type VARCHAR(20), -- MISSING / MISMATCH
detail JSON, -- 具体差异
status VARCHAR(20), -- PENDING / RESOLVED / ALERTED
detected_at TIMESTAMP,
resolved_at TIMESTAMP
);

好处:避免误报。CDC 延迟在正常范围内时不告警,只有持续不一致才触发。

策略 3:告警 + 手动介入

对于关键数据(如订单状态、支付金额),不一致应该立即告警,由人工确认后修复。

1
关键字段不一致 → 立即触发告警(DingTalk/飞书)→ 人工排查根因 → 修复

判断标准:字段是否”关键”。订单状态是关键的,用户头像 URL 是不关键的。关键字段不一致 → 告警;非关键字段不一致 → 记录 + 定期修复。

实际的对账系统设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
                ┌─────────────┐
│ 对账调度器 │
│ (每小时/每天) │
└──────┬──────┘

┌──────▼──────┐
│ 对账执行器 │
│ (分层执行) │
└──────┬──────┘

┌───────────────┼───────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ 计数对账 │ │ 主键对账 │ │ 内容对账 │
│ (每小时) │ │ (每3小时) │ │ (每天凌晨) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────┼───────────────┘

┌──────▼──────┐
│ 差异记录表 │
│ reconciliation│
│ _diff │
└──────┬──────┘

┌──────▼──────┐
│ 修复策略 │
│ 自动/延迟/告警│
└─────────────┘

分层执行

  • 计数对账(每小时):成本最低,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 事件。

这意味着什么

  1. 一致性保证是分层的:同步链路(CDC + MQ + Outbox)是第一层保证,对账是第二层兜底。第一层追求”尽量不丢”,第二层保证”丢了能发现”。

  2. 对账不需要实时,但需要分层。 计数对账每小时跑一次(低成本发现异常),主键对账每几小时跑一次(定位问题),内容对账每天跑一次(兜底字段级不一致)。

  3. 修复策略要区分严重性。 非关键字段不一致可以延迟修复,关键字段不一致必须立即告警。不是所有不一致都值得人工介入。

  4. “以 MySQL 为准”是对账的基本原则。 MySQL 是 source of truth,ES 和 Doris 是派生数据。修复不一致时,始终以 MySQL 的值为准覆盖 ES/Doris。

参考链接