怎么保证MySQL和ES的一致性?

MySQL 和 Elasticsearch(ES)的“一致性”本质是两套存储在不同一致性模型下做数据冗余。

MySQL 以事务与行级更新为中心,ES 以近实时(NRT, near real-time)索引刷新为中心。

目标通常不是强一致,而是把不一致窗口压到可控范围,并且让不一致可检测、可修复、可回放。

一致性目标分层:先把口径定清。

工程上常见三档目标,选错档位会导致方案过重或掩盖风险。

  • 读一致(展示一致):用户查询结果与 MySQL 最终一致,允许秒级延迟;适合搜索、列表、推荐召回。
  • 写一致(业务一致):关键业务写入后立刻可被检索到;通常需要同步写或写后校验,成本高,吞吐低。
  • 可恢复一致(可运维一致):允许出现不一致,但必须满足可追踪(有日志)、可重放(可补偿)、可校验(可对账)。

大多数“MySQL + ES”场景追求“读一致 + 可恢复一致”。

主流方案一:CDC 异步同步(推荐基线)

CDC(Change Data Capture,变更数据捕获)基于 MySQL Binlog 把增量变更流式投递到 ES。

典型链路:MySQL → Binlog → 消费端(如 Debezium/Canal)→ MQ(可选)→ Indexer → ES。

关键收益:

  • 不侵入业务事务,不要求业务代码在事务内同时写 ES。
  • 按 Binlog 顺序消费,天然具备可重放能力。
  • 可做断点续传与延迟监控。

落地要点:

  • 表必须有稳定主键,ES 文档 _id 通常直接用主键,避免重复文档。
  • 消费幂等:同一条变更重复投递时结果不变。
    • ES index(覆盖写)天然幂等;更新用 update 时需要脚本或完整文档覆盖,避免部分字段回滚。
  • 顺序与乱序控制:同一主键的更新必须按顺序落 ES。
    • 常用做法是按主键分区(MQ 分区或消费端内部队列)确保同 key 串行。
  • 删除一致:MySQL 删除必须映射为 ES delete
    • 软删除要统一语义(例如 is_deleted 字段),避免 MySQL 物理删但 ES 仍保留。
  • DDL 处理:字段新增、类型变更要有治理流程;
    • ES mapping 变更不可随意(尤其是类型冲突会导致写入失败并积压)。

适用边界:

  • 秒级延迟可接受。
  • 读多写少、以检索为主的场景。

主流方案二:Outbox 模式(事务内落库,库外投递)

Outbox 把“需要同步到 ES 的事件”与业务数据放在 同一个 MySQL 事务里提交,避免“业务写成功但事件没发出去”的裂缝。

基本形态:

  • 业务事务:写业务表 + 写 outbox 表(事件表)
  • 异步 worker 扫描 outbox 表或通过 Binlog CDC 读取 outbox,再写 ES
  • 成功后标记 outbox 事件已处理(或用状态机)

关键收益:

  • 事务级保证“业务数据与事件”一致。
  • 不依赖业务在事务里直接调用 MQ 或 ES,避免分布式事务复杂度。

落地要点:

  • 事件去重键event_id(UUID/雪花)+ 唯一索引,保证重复扫描不会重复产生副作用。
  • 投递语义:通常做到“至少一次”(at-least-once),靠 ES 幂等覆盖写达成最终一致。
  • 堆积治理:outbox 表分区/归档,扫描按时间窗口或自增 ID,避免全表扫。

适用边界:

  • 业务对“写后可检索”要求高,但仍可容忍短延迟。
  • 需要更清晰的审计与补偿链路。

不推荐但常见:双写(业务同时写 MySQL + ES)

双写看似简单,实则最容易出现一致性缝隙:MySQL 成功、ES 失败;或反过来;或重试导致乱序覆盖。

若历史包袱必须双写,最低限度的控制:

  • 以 MySQL 为唯一真源,ES 失败不回滚 MySQL;失败记录必须可重放。
  • 写 ES 使用幂等覆盖_id=主键index),避免重试产生重复。
  • 写入顺序用版本号:文档携带 version(可用 MySQL 自增版本/更新时间戳/全局递增序列),ES 更新时做版本比较。
    • 拒绝旧版本覆盖新版本(可用外部版本 external versioning 思路实现)。
  • 统一重试与死信:写 ES 失败进入重试队列,超过阈值进入死信,人工或批任务修复。

双写适合低并发、低复杂度、容忍少量不一致且有补偿能力的场景;在高并发更新下很难长期稳定。

写入模型:增量同步之外要有“全量重建”

仅有增量链路不足以“保证一致”,因为 ES 可能因 mapping 冲突、节点抖动、批量写失败等产生缺口。

稳定系统一定包含全量能力。

常用组合:

  • 全量重建索引:新建 index(带版本号或日期),全量从 MySQL 导入,追平增量后切 alias(别名)完成无感切换。
  • 增量追平:重建期间仍消费 Binlog/Outbox,把变更写到新 index;切换前做一次对账校验。
  • 回滚通道:切 alias 失败可快速切回旧 index。

这套机制把“长期漂移”变成“可周期清零”的问题。

校验与对账:让不一致可被发现

一致性不是只靠写链路,更靠监控与对账闭环。

可操作的校验方式:

  • 按时间窗口抽样对账:取最近 5–30 分钟 MySQL 变更主键集合,与 ES 按 _id 查询比对缺失/字段差异。
  • 按业务版本号对账:MySQL 表增加 row_version(递增)或 updated_at
    • ES 文档同步同字段。对比版本不一致即可定位乱序与漏写。
  • 消费位点监控:CDC 的 Binlog 位点(offset)与当前 Binlog 差距,形成延迟指标;超过阈值告警。
  • 失败写入告警:Bulk API 错误率、mapping 冲突、429/503 等必须可观测并自动降速或熔断。

常见坑:一致性失败往往不是“没用对方案”

  • ES mapping 漂移:字段类型在不同批次被推成 text/keyword/date 冲突,导致部分写入永久失败,形成“暗洞”。
  • 更新是局部字段:MySQL 更新了字段 A,ES 用部分更新脚本覆盖时字段 B 被旧值写回,出现“回滚式不一致”。
    • 用全量文档覆盖或用版本控制避免。
  • 同主键并发更新:消息乱序导致 ES 落了旧版本。解决靠分区顺序 + 版本拒写。
  • 删除语义不统一:MySQL 软删、ES 物理删或反之,查询侧过滤条件不一致。
  • 刷新与可见性误判:ES 默认刷新间隔导致“写入成功但搜索不到”在 1s 级别出现;
    • 业务若要求写后立见,需改查询策略或走 MySQL 回源。

一套可落地的“强韧”组合

实践中稳定且成本可控的组合通常是:

  • MySQL 为真源
  • CDC(Debezium/Canal)或 Outbox 做增量
  • ES 写入 幂等覆盖 + 版本控制
  • 全量重建 + alias 切换
  • 延迟、失败、对账三类监控闭环

这套设计不追求分布式强一致,而是把不一致窗口、发生概率与修复成本压到可运维的范围,并且在故障时能快速恢复到统一口径。