ES 与 MySQL 双写一致性:MQ 能解决到什么程度?
这个老问题为什么一直存在?
ES 和 MySQL 双写一致性,是一个”二十年前不存在、十年前开始出现、今天几乎人人遇到”的问题。
套路是这样的:用户更新了一条数据,MySQL 写成功了,然后你要把这条数据同步到 ES 让搜索能搜到。如果中间某个环节失败了——MySQL 写成功但 ES 写失败,或者反过来——数据就不一致了。
这个问题一直没被”一劳永逸”地解决,因为它在不同阶段有不同的约束:
- 十年前:数据量小,可以”更新完 MySQL 再更新 ES”,同步调用,失败重试。
- 五年前:数据量变大,同步调用扛不住,引入 MQ 异步,但引入了一致性问题。
- 今天:AI 应用兴起,搜索结果的准确性直接影响用户体验甚至安全(RAG 召回不一致 = 回答错误)。
MQ 在中间做了什么?
典型的 MQ 异步同步架构:
1 | 应用 → 写 MySQL 成功 → 发 MQ 消息(包含变更数据) → 消费者 → 写 ES |
MQ 解决了什么:
- 解耦:业务代码不需要关心 ES 是否写成功,只要 MQ 消息发出去了就算”同步任务已提交”。
- 削峰:高峰期大量写入不会因为 ES 慢而拖垮业务接口。
- 重试:消费失败的消息可以自动重试,比应用层自己写重试逻辑可靠。
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 | // 1. 写 MySQL + Outbox 表(同一个事务) |
关键认知
**”最终一致性”不等于”不管了一致性”**。一个靠谱的双写方案至少要有这三层保障:
- 尽力送达:Outbox + MQ 保证消息不丢
- 有序消费:分区有序保证不乱序
- 定期兜底:全量对账保证最终一致
MQ 解决了”传输”问题,没解决”幂等”和”顺序”问题。 幂等和顺序需要业务层自己保证。
ES 不是 MySQL 的附属品。 不要试图让 ES 的数据和 MySQL 完全一致——追求的是”搜索结果准确”,不是”两个库逐行相等”。有些场景(比如实时性要求低的内容搜索),1 分钟的延迟完全可以接受。
一句话:MQ 是双写一致性的传输层基础设施,但一致性最终要靠 Outbox 模式 + 分区有序 + 定时对账三层保障来兜底。
