MySQL到Elasticsearch的数据一致性,几乎是我见过最容易被低估的架构问题。业务侧一个事务写进MySQL,前端很快就去ES里查,结果要么搜不到、要么状态还是旧的,用户来投诉才发现两边数据已经对不上了。尤其订单、商品这类数据,一旦ES和MySQL不一致,轻则展示错乱,重则直接影响业务流程。
这确实是个绕不开的话题,也是很多团队从单库走向结构化搜索、大数据检索时必须补的课。这篇文章我把市面上主流的四种同步方案一次性讲透:应用层双写、消息队列异步、Binlog订阅(CDC)、定时任务轮询。每种方案从原理到代码、从坑到选型建议都会覆盖,适合正在做数据同步选型的开发者、后端工程师和架构师参考。
1. 问题拆解:一致性问题到底难在哪
1.1 两个存储引擎的底层差异
要解决一致性问题,首先要承认一个现实:MySQL和Elasticsearch根本就是两种不同定位的存储。
MySQL是关系型数据库,核心是ACID事务。数据以行格式存储,按照主键和B+树索引组织,写入时可以依靠事务保证原子性、一致性、隔离性和持久性。它像是系统里的“事实源”,业务操作的正确性都建立在它身上。
Elasticsearch则完全不同。它是为搜索和分析场景设计的分布式搜索引擎,底层以倒排索引为核心。写入的文档先进内存缓冲区,默认每隔1秒触发一次refresh才能真正被搜索到,这个机制叫“近实时”。另外ES为了保证高可用和扩展性,数据会分片、复制到多个节点,跨节点间还有副本同步延迟。
所以从底层设计上,ES就不是一个“写一条立刻能查到”的系统。哪怕你的同步程序毫秒级把数据推送过去,ES自身的refresh机制也会带来至少1秒左右的可见性延迟。这就是“近实时”三个字的含义。
我经常用一个类比跟团队解释这件事:MySQL是一本能够随时改写的账本,每次改动都有严格记录;ES更像一个公告栏,管理员把账本内容整理成索引卡片贴上去,贴一张擦一张,大家看完觉得眼前一新,但公告栏永远比账本慢半拍。
1.2 一致性在实战中的三种级别
数据一致性分很多级别,跨系统同步时尤其要提前定义好目标。
强一致:任何时刻读ES都能读到和MySQL完全一致的数据。说实话,在MySQL和ES之间几乎做不到。原因有两个,一是跨系统没有分布式事务,无法原子地同时提交两边;二是ES自身有refresh窗口,写进去也不能立刻被搜到。如果哪个方案声称能给你强一致,基本可以怀疑是在忽悠。
最终一致:允许短时间不一致,但经过一段时间后两边数据会收敛到一致。这是绝大多数MySQL到ES同步方案的现实目标。一致窗口有多长,取决于方案和配置,可能是一秒、几秒甚至几分钟。
弱一致:对一致性没有明显要求,延迟甚至丢失都可以接受。一般出现在非核心的数据上报和离线分析场景,比如埋点日志。MySQL到ES的同步很少做弱一致,因为既然要同步,肯定是想要一份能用的数据。
很多团队在讨论方案时没有先对齐“我们需要几秒的一致性”,结果选了重方案,或者选了轻方案又接受不了延迟,互相埋怨。我强烈建议动手前先明确一个数字:你的业务能容忍ES和MySQL之间最多差多少秒。
1.3 同步链路上会踩到的风险点
先不谈具体方案,我们把一条MySQL到ES的同步链路在纸上画出来,只看故障点就会发现这坑远比想象的多。
数据源侧:MySQL主从切换、binlog文件过期、表结构变更、大事务产生超大binlog;同步进程侧:进程崩溃、位点丢失、解析出错、重复消费、漏消费;目标侧:ES集群抖动、bulk批量写失败、映射mapping冲突、索引被误删。网络层面还有超时、重试、乱序。
这些风险点每一条都可能导致“MySQL赢了,ES输了”,或者反过来。一致性问题的本质不是某一个环节难,而是整条链路上的任意一个环节掉链子,数据就开始漂移。
所以我一直认为,设计一套“能跑的通”的同步方案不复杂,难的是设计一套“断了能自愈”的方案。这也是后面四种方案差别最大的地方。
2. 方案一:应用层双写,最直观也最容易被坑
2.1 双写的基本流程与代码形态
双写方案的思路简单得让人心动:业务代码里,写完MySQL之后紧接着写Elasticsearch,两边都成功才算“成功”。
典型的伪代码长这样:
@Transactional public void createOrder(Order order) { // 1. 先写MySQL orderMapper.insert(order); try { // 2. 再写ES esClient.index(ORDER_INDEX, order.getId(), order); } catch (Exception e) { log.error("ES同步失败", e); } }看着好像没毛病,但每个细节都值得打问号。事务提交了吗?如果MySQL事务还没提交,ES却已经写了,事务回滚就会出现ES有了、MySQL没有的幽灵数据。上面代码里的try-catch究竟该干什么?是吞掉异常假装无事发生,还是有补偿机制去补写?
很多人初学阶段都会选择双写,因为不需要引入任何新组件,业务代码里顺手就写了。我最初处理一个内部管理后台的数据同步时也是这么干的,当时数据体量小、并发低,双写确实跑了一段时间没出大问题。
2.2 双写方案的死穴在哪
但双写的核心问题几乎是结构性的,不是靠小心编码能解决的。
第一,两个系统之间没有真正的原子性。MySQL事务提交和ES写入是两个独立操作,无论你先写谁,都可能出现一边成功一边失败。先写MySQL后写ES,ES失败会出现“MySQL有、ES没有”;先写ES后写MySQL,MySQL失败会出现“ES有、MySQL没有”。后者更危险,因为ES里的数据是从业务代码里造的,一旦MySQL没成功,这条数据就成了无源之水,审计和排查都无从谈起。
第二,双写对业务的侵入性很强。每个需要同步的业务方法都要插入一段ES写入逻辑,同步逻辑和业务逻辑耦合在一起。一旦ES的写入变慢,你的业务接口也跟着变慢。ES的一次写入通常需要经过路由、分片、refresh等一系列流程,比MySQL单条insert耗时更加不稳定。业务高峰期ES集群抖动,你的业务接口直接跟着遭殃。
第三,补偿机制做起来很别扭。可靠的补偿需要把失败的写入记录落库,再通过后台任务重试,这本质上已经脱离了“双写”的范畴,变成了一套小型消息对账系统。很多团队做不到这层,失败就只是打一条日志,等于埋了一颗地雷。
所以我对双写的评价是:适合数据量小、业务简单、可以接受人工介入修复的项目,不适合作为核心链路的标准做法。
2.3 什么场景下仍然可以选双写
双写并非一无是处。我自己见过一些内部系统,比如运营后台、配置中心,每天的写入量就几百条,对实时性要求高,出了问题能接受人工跑脚本修。这种场景下双写成本低、见效快,其实没问题。
如果一定要选双写,我有几条经验可以分享:
不要在事务内做ES写入,先提交MySQL事务,再发起ES写入。事务提交成功后写ES,ES失败至少不会产生脏数据,只是少了一条,后续还能靠对账任务补。要在业务数据库中留一张同步失败记录表,失败时把业务主键和失败原因写进去,再配一个定时补偿任务把失败记录捞出来重新推送ES。给ES写入加个超时,不要让ES的慢拖垮主流程。
双写能做到的最多就是“尽力而为+事后补偿”,期望值不要拉太高。
3. 方案二:消息队列异步解耦
3.1 事务边界和消息发送时序
很多团队从双写方案走出来后,往往会进化到消息队列方案。思路变了:业务代码不再直接写ES,而是把数据变更事件扔到MQ里,让专门的消费者去写ES。
这么做的好处很直接,一是解耦,业务不需要关心ES是怎么写入的;二是削峰填谷,把ES写入压力通过队列缓冲掉了。但MQ方案引入了一个经典难题:先发消息还是先提交事务。
如果在事务提交前发消息,事务一旦回滚,消息已经发出去了,消费者会拿到一条根本不存在的数据变更,ES就会被写入幽灵记录。如果在事务提交后再发消息,那么从“事务提交”到“消息发出”之间有个时间窗口,如果进程正好在窗口内崩溃,消息就丢了,这条更新永远不会同步到ES。
解决这个问题的可靠方案有两种。第一种是本地消息表:在同一个事务里,除了业务数据表,额外插入一条消息记录到本地消息表,两者一起提交;然后后台任务扫描消息表,把消息投递到MQ,收到MQ的确认后再删除消息表的记录。第二种是事务消息,比如RocketMQ提供的事务消息机制,先发送半消息,执行本地事务,根据事务结果commit或rollback,最终保证消息和业务操作的最终一致。
本地消息表实现起来不复杂,但有一个需要接受的成本:业务表旁边多了一张消息表,多了一套后台扫描逻辑。事务消息更优雅、对业务侵入更小,但它强依赖MQ中间件本身的能力。
3.2 消费端幂等、顺序与兜底
MQ方案真正考验人的地方其实在消费端。就算你用事务消息保证消息不丢、不提前曝光,消费者那边还得处理三个问题:幂等、顺序、失败重试。
幂等是必须处理的。MQ是至少一次投递,重复消息一定会出现。消费者的写入逻辑不能“先查有没有,没有就insert”,否则重复消费会产生重复文档。更推荐的做法是用ES文档ID固定为业务主键,比如orderId,写入时走upsert语义,同样的ID写多少次都是覆盖更新,天然幂等。如果业务上需要判断消息的新旧,可以在文档里加一个version字段,消费端判断如果消息版本号小于等于ES里的版本号就直接丢弃。
顺序问题也要提前想清楚。同一条订单记录的状态变更,从“待支付”变成“已支付”,消息必须按这个顺序被ES消费,要是反过来,ES里最终状态就可能停留在旧值。MQ在写入侧有严格顺序,但在消费侧并不保证全局有序,所以同步消息必须按照业务主键进行分区,确保同一个主键的消息始终被同一个消费线程处理。
失败重试是最后一层防线。消费者处理消息失败时要区分情况,临时性故障比如ES集群抖动,可以做有限次数的重试;超过重试次数进入死信队列,再配一个单独的消费者去扫死信,人工介入或者自动再次投递。没有死信机制的话,失败消息堆积在队尾,ES会悄悄和MySQL拉开差距。
3.3 MQ方案和双写方案的取舍
MQ方案相比双写,牺牲了一定的链路复杂度和资源成本,换来了业务解耦和稳定性。双写是业务方法里每次都同步调用ES,MQ方案则把ES写入完全隔离出去,业务只保证自己的事务和消息投递,ES变慢不会直接影响主流程。
不过MQ方案也有自己的隐藏成本:你需要维护消息的topic、分区、消费者组,需要处理消息堆积、死信、幂等这一堆问题。如果团队里没有对MQ原理比较熟的人,这方案很容易在踩坑中交学费。另外,数据变更里有大量需要转换、过滤的逻辑时,消息队列方案比双写更灵活,因为消费者可以任意加工数据再写ES。
我遇到的多数团队最终都会从双写迁到MQ或者Binlog方案,因为只要业务规模上来,双写在性能和一致性上的短板会同时爆发。
4. 方案三:Binlog订阅(CDC),业务无侵入的主流方案
4.1 Binlog同步的底层原理
聊了两种“业务主动通知”的方案,接下来是重头戏:基于MySQL Binlog的订阅同步,通常也叫Change Data Capture(CDC)。
它的原理并不神秘。MySQL依靠binlog做主从复制,记录了每一次数据变更。同步组件伪装成一个MySQL从库,向主库请求binlog解析,拿到变更数据后推送给目标端,常见工具有Canal、Debezium、Maxwell等。
这种方式最大的优势是业务零侵入,不需要修改业务代码,不需要加Mapper方法,不需要发消息。只要对MySQL开启binlog,同步进程就能自动感知所有表的insert、update、delete。对于老系统或者不方便到处改代码的场景,Binlog订阅几乎是唯一不伤筋动骨的选择。
我特别喜欢拿这件事件类比说明:双写和MQ相当于“业务出事之后我打电话告诉你”,Binlog相当于“你放了个摄像头在数据库门口,任何进进出出的变动都逃不过记录”。
4.2 全量同步、增量订阅与数据校验
Binlog同步通常只是增量手段,解决不了“存量数据怎么进ES”的问题。所以一个完整的同步方案是:全量+增量配合,可能有老数据一直在MySQL里,但binlog是开启之后才开始记录的,增量同步只能覆盖开启之后的变化。先想办法把存量历史数据灌进ES,再通过binlog持续同步新变化。
全量同步可以借助现成的导入工具,也可以自己写脚本按主键范围分页扫描MySQL并批量写入ES。我建议全量同步时不要用一次查全表的粗暴方式,而是定义一个游标,比如按主键ID每批5000条,循环取数,每批用ES的bulk接口写入。
增量同步对binlog的消费要特别注意两点:一是ack机制,处理完一批数据必须手动确认,重启时才能从上次确认的位置继续,避免重复消费;二是位点持久化,如果同步进程挂了,重启后要从位点恢复,而不是重新拉全量。市面上成熟组件基本都处理了这两点,自己写解析器的话别漏掉。
数据校验是很多人会忽略、但绝对不应该省的环节。全量同步后、增量同步过程中,都要有定期对账任务:比较MySQL端记录数和ES文档总数,抽样比对关键字段的hash值。没有校验的同步,就像开车不看后视镜,撞了才知道。
4.3 开启Binlog同步前必须调好的参数
实际部署Binlog同步时,有几个前置条件没满足就会出现各种诡异问题。
binlog_format必须设置为row。statement格式下binlog只记录SQL语句本身,同步组件很难准确知道每条记录变更前后长什么样;row格式则记录每行数据变更的细节,解析起来最可靠。MySQL 8.0默认已经是row,老版本可能还在用mixed,需要确认并调整。
server_id不能和现有从库冲突。同步组件需要伪装成从库拉取binlog,如果server_id重复,可能会把真正的从库挤掉,造成主从复制中断。这个错误非常隐蔽,客服端报错可能只是通信失败,实际是ID冲突。
对于即将同步的表,尽量用主键约束。Binlog解析后需要用主键映射到ES文档ID,如果源表没有主键,删除操作会难以精确定位到ES里的具体文档。
4.4 同步任务挂了之后怎么恢复
聊点实操。Binlog同步进程一旦挂了,恢复的流程不是“重启就完事”。
首先要看一眼进程退出前的位置记录保存在哪里。如果是用文件保存位点,重启后要检查文件里的位点是否还在MySQL binlog的保留范围内。如果binlog已经过期清理,从旧位点恢复就拉不到日志了,这时候只能重新全量同步,或者丢弃旧位点接受一段不一致。基于这个原因,我强烈建议把MySQL的binlog保留时间设长一点,比如72小时,给排查留足窗口。
其次要看是否有堆积。进程挂了十几分钟,恢复后会瞬间涌入大量变更,消费者要能扛住这种突发流量。ES批量写入的bulk size此时不要调太大,建议控制在1000条以内或者5MB左右,不然ES容易被打爆。同步恢复后要多关注ES集群的延迟和写入拒绝率,等消费追平再恢复正常速率。
5. 方案四:定时任务增量轮询,简单但别滥用
5.1 增量轮询的实现逻辑
最后一类方案不是实时的,而是定时任务增量轮询。基本思路是在业务表里增加一个更新时间字段,定时任务每隔一段时间扫描这张表,把更新时间大于上次同步时间的记录同步到ES。
SELECT * FROM orders WHERE updated_at > #{lastSyncTime} ORDER BY updated_at ASC LIMIT 500;每次跑完任务,把本次扫描到的最大更新时间记录下来,作为下一轮的起点。逻辑非常简单,不需要MQ、不需要binlog,不需要引入任何中间件,唯一的前置条件是表结构里有可靠的updated_at字段。
有些项目没有加updated_at,也没法轻易改表结构。个别团队会退而求其次,用自增主键ID来判增量,但这样只能发现新增数据,发现不了更新数据,很容易漏同步。
5.2 轮询方案的短板和补救办法
这个方案的短板非常明显,首先是实时性差。任务调度周期是分钟级,数据从MySQL变到ES可见至少隔一个调度周期。如果业务要求秒级一致,这个方案直接被排除。
其次是更新时机不可靠。如果业务代码在更新数据时没有主动设置updated_at字段,这个字段就不会变,增量轮询就永远发现不了那条更新。这种坑很隐蔽,等线上数据对不上才去排查,结果发现好多更新操作根本没维护时间字段。
还有一个老生常谈的问题:删除。物理删除记录不会体现在轮询结果里,ES里会留下已经不该存在的“尸体”。要么业务改用软删除,增加一个deleted标记字段,轮询时把这个标记同步到ES;要么就得接受ES里删除数据延迟,手动补偿。
因此定时轮询的定位是“简单可靠、实时性差”,适合低频变更的数据,比如商品分类、配置字典、报表数据,这类数据本身更新少,晚几分钟完全没影响。我见过一些团队拿它同步核心商品表,结果线上出了好几起漏同步事故,最后不得不换Binlog方案。
6. 四种方案怎么选:对比、经验与排坑速查
6.1 选型对比表
把四种方案放在一起,从几个关键维度做对比:
| 维度 | 应用层双写 | 消息队列异步 | Binlog订阅 | 定时任务轮询 |
|---|---|---|---|---|
| 一致性能力 | 差,需要额外补偿 | 较好,依赖幂等和重试 | 好,源头捕获变更 | 弱,依赖时间字段 |
| 实时性 | 高(同步调用) | 中(取决于消费速度) | 高(秒级) | 低(分钟级以上) |
| 业务侵入性 | 高(业务代码耦合) | 中(需消息接入) | 低(无侵入) | 低(需维护时间字段) |
| 链路复杂度 | 低 | 高 | 中高 | 低 |
| 主要风险 | ES写失败不可感知 | 消息丢失、乱序、积压 | binlog位点、DDL、大事务 | 漏更新、删除残留 |
| 适用场景 | 小项目、内部管理后台 | 高流量、业务解耦场景 | 对外核心链路、老系统 | 低频辅助数据、对账兜底 |
6.2 我推荐的组合打法
实际架构中很少有人只用一种方案。我目前更倾向的组合是:Binlog订阅作为增量同步的主链路 + 定时任务轮询作为对账兜底 + MQ作为异构数据转换的缓冲层。
用Binlog是因为它对业务无侵入,任何变更都逃不掉,适合作为事实数据的搬运工。对核心交易数据,可以加一层MQ来做消费者缓冲,防止ES写入抖动拖慢消费速度。定时任务则负责定期做全量对账,比如每天凌晨跑一次数据比对,把漏掉的、污染的数据捞出来重新同步。
这套组合看起来“重”,但每一个部分都是针对特定风险设计的。Binlog解决实时性和覆盖度,MQ解决流量削峰和跨系统解耦,定时任务解决补偿和对账。大的电商系统里,订单、库存、商品这类核心数据基本是按照这个思路来搭建的,只是不同团队落地的组件不同。
6.3 常见问题速查表
最后把我在实际工作中反复遇到的同步故障整理成一张速查表,对应给出的排查思路都是踩过坑换来的。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| ES里查不到刚同步的数据 | refresh间隔未到、同步任务lag | 查看同步延迟指标,检查ES的refresh设置 |
| MySQL和ES记录总数对不上 | 漏同步、重复同步、物理删除未处理 | 跑一次全量比对任务,核对日志 |
| 同步进程无故退出,重启后又不工作 | binlog位点丢失或过期 | 检查位点记录文件和binlog保留时长 |
| MQ消费有积压 | 消费者能力不足、ES bulk写入慢 | 增加消费者,调小bulk size,定位ES慢的原因 |
| ES里数据没有更新 | 业务未维护updated_at字段、消息乱序 | 核对更新时间字段,检查MQ分区键设置 |
| 同步数据字段出现类型转换报错 | ES mapping和MySQL字段类型不一致 | 提前设计mapping映射,改用显式类型定义 |
最后再说一点个人经验:无论你选哪种方案,数据校验和监控告警才是真正让你安心睡觉的东西。同步链路是一个不断运转的管道,它总会出问题,关键是你能否在用户发现问题之前发现它。延迟监控可以观察消费位点和写入延迟,比直接监控一致性简单得多,建议先做起来。
我个人现在的习惯是:每个同步任务上线先跑三天全量对账,确认零差异再切流量;切流量之后保留定时对账,无论同步方案多先进,这条兜底都不砍。数据一致性没有银弹,但把方案选对、把补偿机制配齐,你至少能从“天天救火”变成“偶尔看一眼监控”。