如何保证 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 订阅。
- 版本号比较是防乱序覆盖的关键,对账是最后的兜底。