这个老问题为什么一直存在?

ES 和 MySQL 双写一致性,是一个”二十年前不存在、十年前开始出现、今天几乎人人遇到”的问题。

套路是这样的:用户更新了一条数据,MySQL 写成功了,然后你要把这条数据同步到 ES 让搜索能搜到。如果中间某个环节失败了——MySQL 写成功但 ES 写失败,或者反过来——数据就不一致了。

这个问题一直没被”一劳永逸”地解决,因为它在不同阶段有不同的约束:

  • 十年前:数据量小,可以”更新完 MySQL 再更新 ES”,同步调用,失败重试。
  • 五年前:数据量变大,同步调用扛不住,引入 MQ 异步,但引入了一致性问题。
  • 今天:AI 应用兴起,搜索结果的准确性直接影响用户体验甚至安全(RAG 召回不一致 = 回答错误)。

MQ 在中间做了什么?

典型的 MQ 异步同步架构:

1
应用 → 写 MySQL 成功 → 发 MQ 消息(包含变更数据) → 消费者 → 写 ES

MQ 解决了什么:

  1. 解耦:业务代码不需要关心 ES 是否写成功,只要 MQ 消息发出去了就算”同步任务已提交”。
  2. 削峰:高峰期大量写入不会因为 ES 慢而拖垮业务接口。
  3. 重试:消费失败的消息可以自动重试,比应用层自己写重试逻辑可靠。

MQ 没解决什么?

1. 消息丢失

MQ 消息发出去了 ≠ ES 写成功了。消费者挂了、网络断了、ES 集群重启——这些情况下消息可能丢了。

对策:消费端做幂等 + 死信队列 + 定时全量对账。其中”定时全量对账”是最可靠的兜底——定期扫 MySQL 最近变更的数据,和 ES 做 difsync。

2. 消息乱序

同一行数据先被改成 A,再被改成 B。但如果先发的消息(A)后到达消费者,ES 里就永远是 A 而不是 B。

对策:需要业务层保证同一行数据的有序性。最简单的方式是分区有序——比如用数据 ID 做分区键,同一 ID 的消息都发到同一个 MQ 分区,分区内保证顺序消费。

3. “MySQL 写了,MQ 消息没发出去”

这是最头疼的情况。如果先写 MySQL 再发 MQ,MQ 发送失败,事务无法回滚(除非用事务消息)。如果先发 MQ 再写 MySQL,MySQL 写失败但消息已经发出去了。

对策:事务消息(RocketMQ 支持,RabbitMQ 不原生支持)+ Outbox 模式(在 MySQL 事务内同时写业务数据和一张 outbox 表,然后一个单独的 worker 从 outbox 扫消息发到 MQ)。

4. ES 自身的最终一致性

ES 里,一条文档写入后不是立即可搜索的。有一个 refresh_interval(默认 1 秒)。在这 1 秒内的搜索请求看不到刚写入的文档。

对策:如果业务要求”写完立即可搜”,需要调小 refresh_interval 或手动 refresh——但这会显著影响写入性能。这其实不是 MQ 的问题,是 ES 的机制问题。

一套落地方案

假设你的场景是:Spring Boot + MySQL + RocketMQ + ES。

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
30
31
32
// 1. 写 MySQL + Outbox 表(同一个事务)
@Transactional
public void updateProduct(Product product) {
productMapper.update(product);
// 同一事务内写 outbox
outboxMapper.insert(new OutboxEvent("product_updated", product.getId(), product.toJson()));
}

// 2. 定时任务扫 outbox 发 MQ
@Scheduled(fixedDelay = 100)
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxMapper.findUnpublished(100);
for (OutboxEvent event : events) {
rocketMQTemplate.send("product-change-topic", event.toMessage());
outboxMapper.markPublished(event.getId());
}
}

// 3. MQ 消费者写 ES
@RocketMQMessageListener(topic = "product-change-topic", consumerGroup = "es-sync")
public class ESSyncConsumer implements RocketMQListener<Message> {
public void onMessage(Message msg) {
// 幂等:用 version 字段或消息 ID 去重
esClient.update(productIndex, msg.getProductId(), msg.getData());
}
}

// 4. 兜底:定时全量对账
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨 3 点
public void fullReconciliation() {
// 对比最近 24 小时的 MySQL 数据和 ES 数据,补偿差异
}

关键认知

**”最终一致性”不等于”不管了一致性”**。一个靠谱的双写方案至少要有这三层保障:

  1. 尽力送达:Outbox + MQ 保证消息不丢
  2. 有序消费:分区有序保证不乱序
  3. 定期兜底:全量对账保证最终一致

MQ 解决了”传输”问题,没解决”幂等”和”顺序”问题。 幂等和顺序需要业务层自己保证。

ES 不是 MySQL 的附属品。 不要试图让 ES 的数据和 MySQL 完全一致——追求的是”搜索结果准确”,不是”两个库逐行相等”。有些场景(比如实时性要求低的内容搜索),1 分钟的延迟完全可以接受。

一句话:MQ 是双写一致性的传输层基础设施,但一致性最终要靠 Outbox 模式 + 分区有序 + 定时对账三层保障来兜底。