☰
MySQL与Elasticsearch数据同步:Binlog订阅驱动最终一致性与毫秒级搜索
2026/10/20 7:23:06 网站建设 项目流程

1. 一场搜索数据事故引发的思考:不一致问题为什么这么难解

去年年中那段时间,我维护的社区系统搜索模块一直不太平。用户反馈最多的一个问题是:帖子明明已经发布成功了,在列表页和详情页都能看到,但搜索就是搜不到。一开始我以为是ES集群的问题,查了半天发现集群健康状态是绿的,文档数也正常增长,但数据就是和MySQL对不上。后来我把MySQL里某条帖子的created_at和ES那边文档里的时间戳一对比,差了好几个小时,有些甚至压根没同步过去。那一刻我才明白,问题根本不在搜索本身,而在MySQL和ES之间的数据管道。

这个场景做后端的人应该都不陌生:业务数据落在MySQL,搜索需求交给ES。但MySQL和ES是两套独立的存储系统,既没有事务保障,也没有内置的同步机制,想让它们保持数据一致,完全靠业务代码自己想办法。而“想办法”这三个字,恰恰是大量事故的起点。

1.1 事故还原:MySQL有数据,ES搜不到

当天的事故记录我到现在还留着。某用户在下午3点12分发布了一条帖子,MySQL插入成功,接口返回正常。但用户用关键词搜索这篇文章,ES返回的结果里没有。我手动查了ES索引,发现确实没有这条文档,执行全量重建之后才有了。当时线上的同步逻辑是典型的双写:

// 伪代码:双写逻辑 $insertId = $mysql->insert($postData); $es->index([ 'index' => 'posts', 'id' => $insertId, 'body' => $esDocument, ]);

看起来很简单,MySQL写完然后写ES。问题出在哪?第二行ES写入抛了一个异常:索引里某个字段的类型映射冲突。这个异常被上层框架捕获后记录到日志,但业务接口仍然返回成功,因为MySQL插入已经提交了。用户看到了“发布成功”,ES里却少了这条数据。更麻烦的是,这个错误不会自动补偿,除非有人发现并手动处理,否则这条帖子永远搜不到。

这种双写方案最大的问题不是代码复杂,而是MySQL和ES的写入天然不具备原子性。MySQL提交成功,ES提交失败;或者ES提交成功,MySQL回滚——这两种情况都会留下不一致的尾巴。任何技术方案都绕不开这个现实。你说可以在ES失败时重试,但如果重试也失败呢?你说可以回滚MySQL,可如果回滚成功但ES实际上写进去了呢?分布式系统里的“二阶段提交”听着美好,放到两个异构存储上,代价和复杂度远超大多数项目的承受范围。

1.2 双写方案的原子性难题

我见过很多项目对双写的处理方式,基本都是“失败了就catch住,打日志,后续人工处理”。有些项目稍微完善一点,把失败的任务塞进一张待重试的表,定时脚本去扫。这样做能解决一部分问题,但有几个场景永远处理不好:

  • MySQL写入成功,但进程在写ES之前崩溃了:重试无从谈起,因为根本没有机会记录失败任务。
  • ES写入成功,但MySQL事务最终回滚:ES里多了一条不该存在的假数据,很难被发现。
  • 接口并发高、MySQL压力大时:双写会让业务接口的耗时直接翻倍,动不动就几百毫秒。

更隐蔽的问题是:双写涉及的ES业务逻辑被散落在各个接口里。你以为只有发布帖子一个入口会触发索引更新,实际上编辑、删帖、审核通过、话题标签变更……任何一个入口都可能要同步改ES。改漏一个,就是一个新的不一致来源。

1.3 定时扫表为什么也不靠谱

后来有一些项目改成了“定时全量同步”:每隔几分钟把MySQL整张表扫一遍,或者只扫当天更新的数据。这个方案确实能解决部分漏掉的情况,但问题也很明显。首先是时效性,定时任务的间隔就是数据新鲜度的上限,间隔5分钟,就意味着新发布的内容最长要5分钟之后才可能被搜索到,这在很多业务场景里是无法接受的。

其次是全量对比的代价。假设一张表有500万条记录,每5分钟扫一次,相当于每5分钟全表查一遍再批量灌进ES,MySQL的IO和CPU压力都保不住。如果你聪明一点,只扫updated_at > 上次时间的记录,那得先保证所有入口都正确更新了这个时间字段,否则又要漏。

还有个大坑:MySQL里被物理删除的数据,定时扫表是感知不到的——你根本扫描不到那条已经被删掉的记录。除非你额外保留一张删除日志表,或者强制走逻辑删除,否则ES里会沉积越来越多的僵尸数据。

1.4 一致性问题的本质拆解

踩了这么多坑之后,我把这个问题重新拆了一下,其实它是两个完全不同的问题被绑在了一起:

  • 增量变更怎么及时、不丢失地传递到ES。这是数据管道问题,核心是捕获、传输、重试。
  • 传递过去的文档结构和业务查询需求怎么匹配。这是搜索建模问题,核心是Mapping、分词、查询优化。

很多团队把这两件事混在一起做,导致代码里既写同步逻辑又写查询逻辑,出了故障也不知道到底是数据没同步,还是搜法不对。而标题里说的“最终一致”和“毫秒级搜索”,本质上也是两件事:前者是数据链路的收敛时间,后者是查询链路的响应时间。数据最终能对上,不一定代表搜索就一定快;搜索快,也不代表数据就是新鲜的。我建议所有做这块的同学先把这个认知建立起来,后面的取舍才有方向。

2. 引入Binlog订阅:从源头捕获MySQL变更的架构设计

想通了问题本质之后,我的第一反应是找一种不需要侵入业务代码、又能捕获所有MySQL变更的方案。最后落到了Binlog上。MySQL的Binlog是MySQL服务器生成的二进制日志,记录了对数据库执行更改的所有操作。不管你是通过哪个接口、哪条SQL、哪个后台任务改的数据,只要提交成功,Binlog里就一定有记录。

这句话的价值非常大:业务代码可以不用再关心同步这件事了。发布帖子的接口只管写MySQL,搜索同步作为独立的链路去消费Binlog。这样一来,所有入口都自动纳入了同步范围,不会再有“改了A入口忘了B入口”这种低级错误。

2.1 为什么选Binlog作为事实来源

选Binlog当事实来源,有几个很实在的理由:

  • 与业务代码解耦:不需要在每一个写操作后面加ES同步代码,新增业务入口时也不会漏掉同步。
  • 记录的是结果而非过程:Binlog关心的是“哪一行变成了什么样”,而不是“哪条SQL被执行了”。这意味着你可以拿到变更后的完整行数据,直接拼装ES文档。
  • 天然有顺序:同一个GTID事务、同一张表内部的变更顺序是明确的,消费端按照Binlog顺序处理,能最大程度避免乱序覆盖。

关于Binlog格式,MySQL有三种:STATEMENT、ROW、MIXED。做数据同步一定要用ROW格式。因为STATEMENT记录的是SQL语句,在从库回放时可能因为环境数据不同而产生不同的结果,解析起来也麻烦,你拿不到变更前后的具体字段值。ROW格式直接记录每一行数据的变化,做增量同步最合适。

2.2 整体链路架构解析

我搭的这套链路大概是这样的:

  1. MySQL开启Binlog,格式设为ROW,并配置binlog_row_image=FULL,保证每一行变更都带着完整的字段信息。
  2. Binlog解析组件模拟MySQL从库协议,接收MySQL推送的Binlog事件。
  3. 解析组件把数据变更信息转换成JSON格式的事件,推送到消息队列,或者直接通过HTTP回调推给PHP消费端。
  4. PHP侧有一个常驻消费进程,接收事件后,对每一条变更做业务组装(比如补充关联数据),然后写入ES。
  5. 每次ES写入失败,事件进入重试队列;重试几次还失败,进入死信队列表待人工处理。
  6. 每日对账任务跑一遍MySQL与ES的数量和关键字段校验,兜住漏网之鱼。

这套链路最核心的思想是:把“MySQL写入”和“ES写入”从同一次接口调用里拆开。接口只保证MySQL写入成功,ES同步变成异步的、有补偿的、可监控的独立流程。用户请求不再被ES拖累,搜索数据的一致性靠链路本身兜底。

2.3 方案对比:双写、MQ广播、Binlog订阅

为了把选型思路说清楚,我把市面常见的几种方案放在一起对比:

方案数据实时性业务侵入性可靠性适用场景
双写较高高,每个入口都要改低,失败无补偿数据量小、一致性要求低的内部系统
MQ异步双写较高高,仍需改业务入口中,依赖MQ可靠性对实时性有要求、能接受入口侵染
定时全量同步低低中,靠对账兜底数据量不大,搜索实时性无所谓
Binlog订阅高低,业务无感高,有重试有补偿数据量大、实时性要求高的正式项目

没有哪个方案是绝对错的,关键看你的场景。如果是几十万数据的管理后台,双写可能就够了。但只要数据量大起来、入口多起来、用户的搜索体验要求高起来,Binlog订阅基本是唯一靠谱的选择。

2.4 什么场景不适合Binlog订阅

这套方案也有不适合的情况。比如你的MySQL是云厂商的托管实例,但没开放Binlog权限,那就没法玩。还有一些老旧实例Binlog日志保留时间很短,可能只有1天,消费端一旦宕机超过这个时间就得全量重建。再有就是团队里没人懂Binlog解析原理,遇到问题只能干瞪眼,这类项目直接上Binlog方案反而给自己挖坑。

不过这些问题都有后手:云厂商没有Binlog,可以开DTS类的数据传输服务;Binlog保留时间短,可以调长;团队不熟悉,可以先用全量重建兜底,慢慢培养。我当时的决策逻辑很简单:两害相权取其轻。双写和定时扫表带来的问题已经是现实存在的痛点,Binlog方案的困难是可以通过工程手段解决的,所以选了后者。

3. PHP侧增量同步落地:订阅路由、幂等写入与删除处理

架构定了,接下来就是最磨人的落地环节。我在这一部分踩了不少坑,把关键细节展开说说。

3.1 Binlog格式的配置与解析组件选型

先看MySQL侧需要做什么。确认你的MySQL开启了Binlog,格式为ROW:

[mysqld] server_id=100 log_bin=mysql-bin binlog_format=ROW binlog_row_image=FULL expire_logs_days=7

binlog_row_image=FULL这个参数很关键。不加的话,MySQL为了节省Binlog空间,可能只记录变更过的字段。那你解析出来的数据就是残缺的,拼ES文档时还得反查MySQL,凭空多一次IO。expire_logs_days建议至少7天,给消费端故障留出恢复时间。

解析组件这块,业界常用的是类似Canal的开源Binlog订阅组件。它的原理是把自己伪装成一个MySQL从库,向主库发送dump请求,然后持续接收Binlog事件。如果你不想引额外的组件,也可以直接用一些现成的PHP扩展去读Binlog文件,但Canal类方案的好处是天然支持断点续传,组件会记录自己消费到了哪个Binlog位置,服务重启后能接着消费。

我当时选的标准有三个:社区活跃度、断点续传是否可靠、部署方式是否轻量。最后选择了一个能对接消息队列的解析组件,把事件丢进Kafka,PHP侧消费Kafka,两边互不阻塞。

3.2 PHP消费端的事件路由设计

PHP这边不需要自己写Binlog解析逻辑,但要处理的是:拿到一条变更事件后,怎么把它转成ES写入动作。

事件到达后,首先看变更来自哪张表。不同的表对应不同的ES索引和文档组装逻辑。比如posts表的数据进posts索引,users表的数据进users索引。我建议在这个环节做一张“表名到处理器”的路由映射,尽量避免在同一个方法里堆一堆if-else:

class SyncConsumer { private array $handlers; public function __construct() { $this->handlers = [ 'posts' => new PostSyncHandler(), 'users' => new UserSyncHandler(), ]; } public function handle(BinlogEvent $event): void { $handler = $this->handlers[$event->table] ?? null; if ($handler) { $handler->sync($event); } } }

表名路由解决了“这张表谁负责”的问题,剩下的就是每个Handler内部的组装逻辑了。

3.3 幂等写入:以业务主键固定ES文档ID

我见过不少团队同步ES时用自动生成的文档ID,每同步一次就新增一条,导致索引里出现大量重复文档。正确做法是用MySQL行的业务主键作为ES文档的_id。

这是整个同步链路里最重要的一个设计。用业务主键当ES文档ID,天然就有了幂等性:同一个帖子更新100次,ES里始终只有一条文档,后一次更新覆盖前一次,不会出现重复。而且做对账的时候很方便,MySQL主键集合和ES_id集合可以直接对比。

PHP侧写入ES的简化逻辑:

class PostSyncHandler { public function sync(BinlogEvent $event): void { $data = json_decode($event->payload, true); $postId = $data['id'] ?? null; if ($postId === null) { return; } $params = [ 'index' => 'posts', 'id' => (string) $postId, 'body' => [ 'title' => $data['title'], 'content' => $data['content'], 'category_id' => (int) $data['category_id'], 'status' => (int) $data['status'], 'updated_at' => $data['updated_at'], ], ]; try { $this->es->index($params); } catch (\Throwable $e) { $this->retryQueue->push($postId, $event); } } }

$this->es->index($params)里面指定的id就是帖子ID。有了这个设计,哪怕同一条帖子的更新事件连续来了两条,结果也一定是对的——ES里永远是最后到达的那条数据(前提是顺序不乱,后面专门讲)。

3.4 删除场景处理:物理删除的坑与逻辑删除建议

删除是最容易被忽视的坑。如果业务代码写的是DELETE FROM posts WHERE id = ?,那么Binlog事件里你只能拿到一个被删除行的ID,理论上可以同步删掉ES里的对应文档。看起来没问题,但实际工程中我强烈建议优先改造为逻辑删除。

为什么?物理删除意味着MySQL里再也找不到这条数据的任何痕迹。假设ES删除操作失败了几次,进入重试队列,重试队列也丢了,或者消费端在处理删除事件时正好宕机,恢复后Binlog位置没续上——这条ES文档就成了幽灵数据,永远无法被对账发现,因为MySQL侧已经没有任何记录可以对比。

逻辑删除就简单多了:把status字段从1改成0,Binlog产生的是一个UPDATE事件,数据还在MySQL里。即使ES更新失败,对账任务也能通过比对状态字段发现并修正。如果你实在没法避免物理删除,那至少要对“删除”类型的Es操作特别小心,重试和日志得做得比别人厚一倍。

3.5 关联表变更触发的联动索引更新

还有一类很容易漏的场景:ES文档里的内容不只来自单张表。比如帖子索引里冗余了作者名、话题名、分类名。当作者改了昵称,或者话题改了标题,ES里的帖子文档如果不更新,搜索时就会展示出旧的作者名和话题名,而且用新昵称搜索还会搜不到老帖子。

我采用的方案是:在users表、topics表的Binlog变更Handler里,不仅更新对应的独立索引,还要触发一次关联变更。具体思路是——作者改昵称后,通过消息队列发一个“作者昵称变更”事件,PHP消费端再批量重刷该作者活跃期内的帖子文档。这个动作很重,所以通常会限制查询范围(比如最近90天的帖子),避免全表扫描。

4. 最终一致性兜底:失败重试、每日对账与全量重建

增量同步做得再好,也挡不住网络抖动、ES节点漂移、消费端宕机这类意外。这一章讲的是兜底机制,也是我觉得这套体系里最能体现工程经验的部分。

4.1 消费失败的补偿队列设计

Binlog事件进入PHP消费端后,ES写入可能失败。可能是ES集群暂时不可用、Mapping冲突、字段类型不合法,也可能是业务数据本身有问题。这时候如果只是简单记录日志,问题会被埋没。

我的设计是两层:

  1. 重试队列:失败事件先入重试队列,按指数退避策略重试,比如第1次延迟10秒、第2次30秒、第3次2分钟……最多重试5次。
  2. 死信表:连重试5次都失败的事件,进MySQL里的sync_dead_letter表,记录表名、主键ID、事件类型、原始数据、错误信息和失败时间,由值班同学处理或写脚本自动修复。

死信表对排障特别有用。有一次我们发现ES里有一批文档的时间字段是null,一查死信表,原来是上游MySQL那张表是历史表,Binlog里没有时间字段的信息,导致组装ES文档时缺字段。如果没有死信记录,这种问题排查起来会非常痛苦。

4.2 每日对账:如何找出“漏网之鱼”

不管同步链路多完善,我都坚持要有一个对账任务。对账的核心思路是:对比MySQL和ES两边的主键集合与关键状态字段。

具体做法:

  • 从MySQL按主键批量拉取记录,比如每次取1000条。
  • 用这些主键ID去ES批量查询对应文档。
  • 如果某个ID在ES里不存在,说明漏删或漏写,触发重建。
  • 如果某个ID两边的updated_at或status字段不一致,说明ES文档是旧的,触发更新。
// 对账伪代码 foreach ($mysqlRows as $row) { $esDoc = $this->es->get([ 'index' => 'posts', 'id' => (string) $row['id'], ]); if (!$esDoc['found']) { $this->rebuildIndex->push($row['id']); continue; } if ($esDoc['_source']['updated_at'] !== $row['updated_at']) { $this->rebuildIndex->push($row['id']); } }

对账任务不要跑得太频繁,每天一次或者每小时一次就够了。如果对账发现的问题太多,说明增量同步本身有系统性问题,对账不该用来兜住日常的大量漏同步,否则大家会产生依赖心理,增量链路的故障被掩盖。

4.3 全量重建流程的工程实践经验

有些场景你需要重建整个索引,比如修改了Mapping、调整了分词器、或者同步链路故障太久需要全量恢复。全量重建的流程也比较有讲究,不能直接删除旧索引然后重建,因为那会让搜索服务瞬间失去索引。

标准做法是:

  1. 用新索引名(比如posts_v2)创建新的Mapping和Settings。
  2. 对MySQL做全量扫描,把数据批量写入posts_v2。
  3. 数据同步完成后,通过索引别名把线上流量从posts切到posts_v2。
  4. 确认一切正常后,再删除旧的posts索引。

全量写ES时最好用bulk批量接口,一批5000到10000条,单批大小控制在10MB以内。超过这个量反而性能下降。这个经验我当时调了好几轮才摸到,一开始贪心一批提交20000条,ES的CPU飙到90%,后来降到8000条左右才稳定下来。

4.4 对账频率和成本控制经验

对账的成本主要来自全量扫描MySQL和ES。如果数据量超过千万级别,每小时跑一次全表扫描对MySQL压力很大。折中的做法是:

  • 高频增量对账:每5分钟只对最近30分钟变更过的数据做一次快速比对,专门抓“刚发生的不一致”。
  • 低频全量对账:每天凌晨跑一次全表主键比对,抓“历史积累的不一致”。

两种结合,既能保证问题被快速发现,又不会把数据库拖垮。这套组合我用了很久,稳定性很好。

5. 毫秒级搜索的真正门槛:Mapping设计、查询优化与数据预热

数据同步链路通了之后,搜索响应速度就是下一个重点。很多人以为ES“天生就是毫秒级”,其实那是对小数据量和简单查询而言。数据量一上来、查询复杂起来,秒级甚至超时的请求都会出现。毫秒级搜索的落地,更多体现在索引设计和查询写法上。

5.1 写索引之前先把Mapping画明白

Mapping相当于ES里的表结构,这一步设计错了,后期改起来代价极大。我的建议是把字段分成三类来看:

  • 需要搜索的字段:比如标题、正文,用text类型,配合中文分词插件。只用于精确匹配的字段用keyword,比如分类ID、状态。
  • 需要展示但不需要搜索的字段:比如浏览量、点赞数、发布时间,用keyword或integer、long,但不开分词。
  • 不需要索引的字段:比如一些内部备注、原始JSON串,设置"enabled": false,这样ES不会为它建索引,省存储省CPU。

一个常见误区是:把所有MySQL字段全部原样倒进ES,什么字段都配上text加分词。这会导致索引体积巨大、写入变慢、查询变慢。ES不是你的MySQL备份,它只需要容纳“搜索和展示需要”的字段。

一个电商场景的Mapping简化示例:

{ "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "category_id": { "type": "keyword" }, "price": { "type": "double" }, "status": { "type": "integer" }, "shop_name": { "type": "keyword" }, "raw_data": { "enabled": false } } } }

标题用了ik_max_word做索引分词,ik_smart做搜索分词。这样做的原因是索引时尽量切得细、召回更全,搜索时切得粗、结果更精准。这个组合是目前中文搜索场景比较成熟的做法。

5.2 查询DSL层面的性能优化空间

写查询的时候,有几个习惯能直接影响延迟:

  • 用filter而不是must:filter只做过滤不参与相关性打分,ES会缓存它的结果,性能比must高不少。凡是“分类是XX、状态是XX、价格区间在XX”这种条件,都应该放到filter里。
  • 避免wildcard模糊查询:LIKE '%xx%'这类需求,在ES里用wildcard会导致全索引扫描,数据量大时必超时。要么改用分词后的match_phrase,要么改用ngram分词器把前缀搜索优化掉。
  • 控制返回字段:用_source只返回需要的字段,能大幅降低网络传输量。

一个推荐的基础查询结构:

{ "query": { "bool": { "must": [ { "match": { "title": "手机" } } ], "filter": [ { "term": { "status": 1 } }, { "term": { "category_id": "1002" } }, { "range": { "price": { "gte": 1000, "lte": 5000 } } } ] } }, "from": 0, "size": 20 }

5.3 分页与搜索场景的取舍:search_after而非深分页

ES的深分页(from大到几万)是性能杀手。原因是分布式环境下,每个分片都得先把满足条件的数据找出来排序,然后汇聚到协调节点,from越大,需要丢弃的数据就越多。我见过有人做后台列表分页,翻到第200页时查询耗时超过3秒。

如果用户只是在搜索列表里翻页,翻不了几页,from+size够用。但如果你的产品有“加载更多”或者滚动流,必须用search_after:

{ "query": { "bool": { ... } }, "search_after": [1650000000000, "12345"], "sort": [ { "updated_at": "desc" }, { "_id": "asc" } ], "size": 20 }

search_after的原理很简单:把上一页最后一条数据的排序值带进下一页,相当于告诉ES“从这条数据之后开始拿”,不需要跳过大段数据,性能非常稳定。这个点几乎每个做搜索的团队都应该知道。

5.4 预热与缓存策略:给冷查询“加热”

ES有自带的查询缓存,但注意它只对filter生效,must这种带评分的查询是不走缓存的。所以想让缓存生效,就得主动把高频查询的条件尽可能放进filter。

另外一个实用技巧是索引副本和节点数要匹配业务量。很多人盲目加副本,觉得副本多了查询一定更快。其实副本增加意味着每次写入要复制更多份,写入压力变大。查询压力大就扩容节点,写入压力大就加副本,两个方向不能搞混。

还有一个偏经验的玩法:对于首页推荐、热门榜单这种几乎所有人都会看一眼的搜索结果,可以在服务端缓存结果,比如用Redis缓存5秒到1分钟。这个方案对延迟的帮助最立竿见影,毕竟ES再怎么优化,也不可能比直接读Redis更快。

5.5 一条搜索请求的耗时画像

我习惯在做性能优化前,先给一次搜索请求画一张耗时画像。一次完整的搜索从PHP发出到返回,通常由这几个环节组成:

  • PHP侧拼装查询条件:几毫秒
  • 网络传输到ES:取决于网络环境,内网通常0.5到2毫秒
  • ES查询执行:真正的大头,几毫秒到几百毫秒不等
  • ES结果返回到PHP:再看一次网络
  • PHP后处理后返回用户:几毫秒

如果你发现接口慢了,先整体画像,再定位到底是网络慢、ES慢,还是PHP侧处理慢。很多人一上来就调ES查询,结果瓶颈其实出在PHP侧序列化或循环处理上。

6. 实测数据与几个容易被忽略的细节

最后分享一组我们环境里的实测数据和我在实践中积累的几个容易踩但不一定写在文档里的经验。

6.1 从MySQL变更到ES可搜的延迟实测

当时我们的环境是:商品表560万条数据,ES两个节点,各8核32G,单条商品文档平均大小3KB左右。Binlog订阅组件和PHP消费端部署在同一内网。实测下来:

  • 从MySQL变更提交成功,到ES索引中可查到该文档,平均延迟约1.8秒,绝大多数情况下在3秒以内。
  • 这个延迟主要来自:Binlog解析组件的事件推送(约0.3秒)、PHP消费端的队列积压(约0.5秒)、ES的refresh间隔(默认1秒)。

这里需要特别说明:“毫秒级搜索”指的是查询响应,不是数据可见延迟。用户搜的是已经存在ES里的数据,搜索响应可以达到毫秒级;但从MySQL写入到这条数据能被搜到,仍然需要一两秒。如果业务希望“提交后立即能被搜到”,那要么用refresh=wait_for牺牲写入性能,要么另建一套实时检索通道。大多数社区的搜索场景,一到两秒的可见延迟是完全可以接受的。

6.2 乱序事件的版本号保护

同一条MySQL记录短时间被更新了多次,理论上Binlog是有顺序的,但到了消费端这一层,经过消息队列或者多线程消费之后,顺序就不一定被严格保留了。两条更新事件如果乱序到达,ES里最终留下的可能是旧数据。

解决思路有两个。第一个是用MySQL侧的时间字段兜底,每次组装ES文档时都带上updated_at,写入前用ES的内部版本冲突机制做判断;第二个更省力,把同一主键ID的事件分发到同一个消费线程,保证该ID的事件在消费端依然是顺序处理。我们采用的是第二种,配合Kafka的分区键策略,把id作为分区键,消息队列的严谨顺序就不难实现。

6.3 Binlog解析中的类型坑:字符串数字与格式化问题

Binlog事件里的数据解析出来之后,很多你以为是数字的字段其实是字符串。PHP的json_decode不会帮你把字符串转成数字。如果ES的Mapping里把这个字段定义为long或integer,写入时就会报类型冲突,然后事件进入死信队列。这个坑我至少排查过两次,原因都一样。

解决办法就是在PHP消费端组装文档时,显式做类型转换,不能依赖ES的自动推断。代码里每个字段该(int)就(int),该(float)就(float),该(string)就(string),养成好习惯。

6.4 磁盘、网络与消费进程的资源配置经验

开启Binlog之后,MySQL的磁盘写入会明显增加,尤其是ROW格式+FULL镜像,Binlog文件增长很快。我当时把expire_logs_days设成7天,结果磁盘占用从几十GB一路涨到200多GB,不得不紧急扩容。后来把保留天数降到3天,勉强平衡了恢复能力和磁盘成本。

另外,PHP消费端建议用常驻进程的方式运行,而不是靠crontab每秒钟拉起一次。常驻进程不仅省去进程启动开销,还能维持长连接,比如ES的HTTP连接、MySQL连接池,整体吞吐会高很多。但常驻进程也意味着内存泄漏、连接失效这些问题需要自己处理,每次发布代码之前要优雅重启。为了这个,我还专门写了一个简单的进程守护脚本,核心逻辑就是“进程不存在就拉起,长时间没心跳就强制重启”。

6.5 关于这套体系的一点个人总结

我做完这套方案之后,最大的感触是:数据同步不是一个纯编码问题,它更像一个可靠性工程问题。Binlog订阅只解决了“怎么捕获变更”,后面还有消费堆积、失败重试、顺序保障、一致性对账、全量重建这一长串等着你。每个环节单独看都不难,串起来才是真正的挑战。

如果你的项目也正在被“MySQL和ES数据对不上”折腾,我的建议是先别急着加代码做补偿,而是后退一步,把整个数据流画出来:谁在写MySQL、谁在消费变更、失败了怎么办、怎么发现漏网之鱼。只要这条链路设计清楚了,MySQL和ES的最终一致就是水到渠成的事情。至于毫秒级搜索,那是在一致性问题解决之后,专心打磨Mapping和查询的事,两个目标互不干扰,也缺一不可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询