如何保证 ES 和数据库的数据一致性?

#中间件#面试八股

如何保证 ES 和数据库的数据一致性?

结论先行:ES 不是权威存储,一致性目标只能是“最终一致”。工程上分三段:写入链路上用“先库后 ES + 失败重试”,跨服务用“事务消息或 outbox 表”,兜底用“binlog 订阅 + 定时对账”。要做到可运维,还必须给每条文档带上版本号或更新时间,让乱序消息不会用旧值覆盖新值。

一、四种同步方案对比

方案一致性实时性侵入性适用
双写(先库后 ES)弱,ES 失败即不一致高低简单业务、可容忍丢失
本地消息表 / outbox较强,可重试中中订单、商品等核心数据
事务消息(RocketMQ)较强中中已有 MQ 的体系
binlog 订阅(Canal)强(以库为准)中低低(对业务透明)大表、多下游、历史数据

二、双写为什么不够

① 写 MySQL 成功
② 写 ES 超时 / 失败
   → 数据库有新值,ES 还是旧值
   → 用户搜到旧价格,客服无法解释

③ 即使重试,也可能因为网络乱序导致旧值覆盖新值

双写要成立,必须补三件事:失败重试(带退避)、版本号校验(防乱序覆盖)、定时对账(补最终差距)。

三、outbox 表方案

CREATE TABLE order_outbox (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_id    BIGINT      NOT NULL,
  event_type  VARCHAR(32) NOT NULL,
  payload     JSON        NOT NULL,
  version     BIGINT      NOT NULL,
  status      TINYINT     NOT NULL DEFAULT 0,  -- 0 待投递 1 已投递
  retry       INT         NOT NULL DEFAULT 0,
  created_at  DATETIME    NOT NULL,
  KEY idx_status (status, id)
);
同一本地事务里:
  INSERT/UPDATE order ...
  INSERT order_outbox ...
提交成功后由投递器轮询 status=0 的记录 → 写 ES → 置为 1

好处是“业务数据与待投递消息同事务”,不会出现“库改了但消息没发”;坏处是投递器有延迟,需要控制轮询间隔与批量大小。

四、binlog 订阅(Canal 模式)

MySQL ──binlog──> Canal ──> Kafka ──> 消费者 ──> ES
                                    │
                                    └── 失败进重试队列 / 死信
要点说明
顺序按主键哈希分区,保证同一行变更有序
幂等以 _id = 业务主键 写入,重复消费是覆盖
版本文档带 update_time 或 version,写入前比较
删除binlog 的 DELETE 要转成 ES 删除,不能只处理新增
初始化全量导入 + 增量追平,切换时记录位点

五、用版本号挡住乱序

POST /order/_update/1001
{
  "script": {
    "source": "if (ctx._source.version == null || params.v > ctx._source.version) { ctx._source = params.doc } else { ctx.op = 'none' }",
    "params": { "v": 1735660800000, "doc": { "status": "PAID", "version": 1735660800000 } }
  }
}

ctx.op = 'none' 表示放弃本次更新,等价于“旧版本不覆盖新版本”。这比 if_seq_no 更适合跨链路同步,因为同步端拿不到 ES 内部的 _seq_no。

六、对账兜底

每小时跑一次对账任务:
  ① 从 MySQL 按 update_time 拉最近 2 小时变更的主键
  ② 批量查 ES 的 _mget,比对关键字段与版本号
  ③ 不一致的写回修复队列,并打点告警

对账不追求“零延迟”,追求“差异可发现、可修复”。没有对账的最终一致,等于没有一致性。

常见追问

  • 追问:为什么不能直接双写并开事务?ES 不支持跨系统事务,@Transactional 管不到 ES;所谓“分布式事务”在这里只能降级为“本地事务 + 消息 + 补偿”。
  • 追问:删除怎么同步?逻辑删除用状态字段(is_deleted),查询时过滤;物理删除靠 binlog 的 DELETE 事件,或在 ES 侧按 update_time 清理。
  • 追问:延迟一般多大?双写是毫秒级,outbox 取决于轮询间隔(通常 100ms~1s),binlog 链路通常秒级;对外可以声明“搜索延迟 < 5 秒”。

小结

  • 目标是最终一致,不是强一致;ES 只做检索副本。
  • 双写要配重试、版本号、对账,否则必然漂移。
  • 核心数据用 outbox 或事务消息,大表用 binlog 订阅。
  • 版本号比较是防乱序覆盖的关键,对账是最后的兜底。
笔记加载中…