做 PHP 后端的人早晚会遇到一个尴尬场景MySQL 里明明几百万条商品数据LIKE 查询一下商品名称接口直接飙到 800ms 甚至 2s用户不买账老板盯着你问能不能做成“毫秒级搜索”。你下意识想到 Elasticsearch但数据在 MySQL 里ES 里的索引怎么和它保持一致又是一个想清楚再动手的问题。这篇文章就围绕“MySQL ES 最终一致性 毫秒级搜索”这条链路把我实际用 PHP 搭建这套架构时趟过的坑、验证过的方案一次讲清楚。适合正在做电商、内容检索、订单后台搜索的中级以上 PHP 开发者参考想抄作业也可以直接看第 2、3 章的方案对比和代码。我最早接触这类需求是在一个电商后台的商品检索模块。MySQL 单表 800 万商品名称、品牌、分类、标签字段全挤在一张表里用 LIKE 做模糊匹配再按销量排序查询耗时曲线非常陡峭。后来把搜索切到 ES主流程才真正“活”过来。但切过去不等于完事MySQL 和 ES 是两套独立的存储任何一次写操作都可能在同步链路中出问题。最终一致性不是靠某个中间件一劳永逸而是靠一整套“消息可靠投递 幂等消费 定时对账”的组合拳。1. 为什么单库扛不住搜索ES 又怎么做到毫秒级1.1 MySQL 的 LIKE 查询到底慢在哪先看一个最典型的慢查询SELECT * FROM product WHERE name LIKE %无线耳机% ORDER BY sales DESC LIMIT 20。这条 SQL 看着简单但 MySQL 在执行时基本只能全表扫描。BTree 索引对前缀匹配有效LIKE 无线耳机%还能勉强走索引可你把%放在最前面那就意味着索引无法定位起点优化器只能遍历大量行把每一行的 name 字段取出来做子串匹配再排序、取前 20 条。如果表里只有几万行这个扫描还能忍。到了百万、千万级别扫描成本会指数级上升。更麻烦的是你往往不止查一个字段还要组合品牌、分类、上架状态、价格区间、销量排序这种多维过滤在 MySQL 里通常只能靠几个单列索引 intersect或者干脆建一张宽表用空间换时间。宽表也不是万能药每次新增一个筛选条件都要改表结构、刷数据业务迭代快到一定程度这套方案就僵住了。有人会说用搜索引擎不也得存一份数据吗对ES 之所以快是因为它把数据预加工成了一种特别适合搜索的结构。理解这个结构你才知道同步数据时哪些字段重要、哪些字段可以舍弃。1.2 ES 倒排索引的核心原理ES 底层基于 Lucene核心数据结构是倒排索引。关系型数据库的索引是“文档 ID - 字段内容”的正向映射倒排索引反过来是“分词结果 - 包含它的文档 ID 列表”。比如“无线耳机”这个词会被分词器拆成“无线”“耳机”“无线耳机”等词项每个词项后面挂一个有序的 posting list记录哪些文档包含这个词。查询的时候ES 只需要在词项字典里找到对应词项再拉取 posting list做集合运算和算分最后取 top N 返回。整个过程不需要像 MySQL 那样逐行去比对字符串所以单次查询的耗时能维持在个位数到几十毫秒级别。再加上 ES 会把热点数据缓存在内存、文件系统缓存中实际业务里查询体感非常顺滑。不过要注意ES 不是万能加速器。它的强项是“全文检索 多维过滤 相关性排序”不适合频繁更新、事务性写入的 OLTP 场景。所以合理架构是MySQL 继续承担订单、库存这类强一致写入ES 只负责搜索和聚合分析。两套存储各有分工这也是“最终一致”问题出现的前提。1.3 引入 ES 后真正的问题两套存储的一致性MySQL 和 ES 各自独立没有内置的分布式事务。你往 MySQL 插入一条商品时不能指望 ES 立即感知必须通过应用代码或中间件把变更同步过去。这个同步过程一旦失败MySQL 里有数据、ES 里没有用户搜索就漏数据反过来ES 里多出一条已下架商品搜索就出现脏数据。回到标题里的“最终一致”本质意思是允许 MySQL 写入和 ES 索引之间存在一小段延迟但经过一段确定的时间后两边数据必须收敛到一致状态。这个“确定的时间”可能是几百毫秒也可能是几秒取决于同步链路怎么设计。对搜索场景来说通常能接受秒级延迟但不能接受“永远不一致”。所以后面所有方案的目标都不是消灭延迟而是把延迟控制在可接受范围内同时保证链路故障后能自动恢复。2. 主流的 PHP 数据同步方案怎么选2.1 业务代码双写简单但坑最多很多团队第一版会这么写在新增商品、更新商品、删除商品的 Service 方法里先写 MySQL再调 ES 接口写索引。表面看起来没什么问题但实际跑起来全是坑。第一个坑是事务边界不一致。MySQL 写入可能在一个数据库事务里事务没提交前ES 那边已经写完了回头 MySQL 事务回滚ES 索引里就出现了一条库里不存在的数据。第二个坑是失败后的处理。ES 写入抛异常怎么办你在 catch 里重试重试还是会失败业务只能干等。不重试那 ES 就缺数据了。第三个坑是代码侵入性。每加一个会改数据的接口都要记得同步调一次 ES时间一长漏一个线上就出问题。我见过一个项目就是把 ES 更新散落在十几个 Service 方法里后来加了一个“批量导入历史数据”的功能有人忘了写 ES导致后台新增的 3 万条商品全部搜不到。这个教训很典型业务代码双写不是不能用但只适合数据量小、更新频率低、允许偶尔丢数据的内部系统。核心业务千万别这么干。2.2 消息队列异步同步最终一致的经典路径更靠谱的做法是引入消息队列把“同步 ES”这件事从业务主流程里剥离开。业务代码只负责更新 MySQL在事务提交成功后发送一条消息到 RabbitMQ 或 Kafka专门的消费者进程拉取消息再写入 ES。举个例子public function createProduct(array $data): int { $this-db-beginTransaction(); try { $id $this-productRepository-insert($data); $this-db-commit(); } catch (\Throwable $e) { $this-db-rollBack(); throw $e; } // MySQL 事务提交成功后再发消息 $this-mq-publish(es.product.sync, json_encode([ event product.create, product_id $id, updated_at time(), ])); return $id; }为什么一定要在事务提交后发消息因为事务还没提交别的消费者如果立刻去查 MySQL可能查不到这条数据或者查到的是旧版本。先提交、后发消息能保证消费者看到的数据是可用的。消息队列方案把“同步 ES”变成了异步任务好处非常明显MySQL 写入延迟不受同步 ES 影响ES 偶尔抖动消息会在队列里堆积恢复后继续消费业务代码不需要关心 ES 细节只发一个事件。这套就是最终一致性架构里最常见的实现路径。2.3 订阅 MySQL Binlog 的旁路方案如果你不想在业务代码里埋点或者已经有一套老系统没法改可以考虑订阅 MySQL 的 binlog 来做同步。大概思路是用 canal 这类工具伪装成 MySQL 从库拉取 binlog 解析成结构化变更事件然后投递到消息队列消费端再写 ES。这个方案的好处是侵入性极低业务代码完全不用改任何对 MySQL 的写入都会被 binlog 捕获包括其他团队直接连库执行的 SQL。坏处是部署和运维成本比较高你需要维护 canal 进程、处理 binlog 位点偏移、解析 DDL 变更对 PHP 团队来说往往需要额外的人力和基础设施。我个人的看法是如果是新项目优先用消息队列方案如果是在复杂老系统上做搜索增强又不想动原有写入逻辑binlog 订阅是非常值得考虑的路。很多互联网公司最终都会走到 binlog 这一步因为业务代码里的“埋点式同步”总有漏网之鱼而 binlog 是从数据库层面做的覆盖率天然接近 100%。2.4 方案对比与选型建议方案侵入性实时性可靠性运维成本适用场景业务代码双写高每个写接口都要改同步理论上最快低失败难补偿低内部小系统、允许丢数据消息队列异步中写入处发一个事件异步秒级中高配合重试和幂等中大多数业务系统的首选binlog 订阅低业务无感异步秒级高只要 binlog 在就能恢复高老系统改造、对侵入敏感的场景选型时不要贪新技术先看团队能不能维护住。消息队列方案虽然要改一部分业务代码但它的链路短、问题定位容易一个 PHP 团队完全能hold住。binlog 方案虽然优雅但 canal 集群、kafka 分区、位点管理这些都是额外负担团队没有运维经验时慎选。3. 最终一致性落地的关键细节3.1 消息不能丢生产端和消费端都确认很多人以为把消息发到 MQ 就万事大吉其实消息丢失可能发生在三个阶段生产端发送失败、MQ 内部故障、消费端处理失败。三个阶段都要有兜底。生产端这边我建议给消息加一个本地落地。最实用的做法是在业务库里建一张消息表事务提交时把事件记录也写入这张表然后由定时任务把消息表中的记录扫出来投递到 MQ成功之后再标记为已发送。这等于把“消息可靠发送”从依赖 MQ 变成了依赖本地数据库事务只要 MySQL 不丢消息就不会丢。消费端这边RabbitMQ 要关自动 ack改成手动确认Kafka 要把 enable.auto.commit 关掉处理成功后再提交 offset。消费者处理 ES 写入时如果 ES 抛异常不要确认让消息重新进入队列重试。重试仍然失败的消息放到死信队列里由对账任务兜底。3.2 幂等消费同样一条消息重复执行也没关系消息队列本身不保证“恰好一次”投递在极端情况下重复消费是常态。消费者写入 ES 时必须做幂等否则同一条商品更新消息被消费两次ES 里可能出现重复文档或者旧数据把新数据覆盖。幂等设计有个很简单的技巧用业务主键直接作为 ES 文档的 _id。比如商品 ID 是 1001那么 ES 里商品索引的 _id 就是product_1001。这样无论消息被消费多少次ES 都是对同一个文档做更新天然幂等。写入的时候不要用 create要用 index 或 update。比如$params [ index product, id product_ . $productId, body [ doc $data, doc_as_upsert true, ], ]; $es-update($params);这个写法表示如果 _id 对应的文档不存在就新增存在就只更新字段。配合消息里携带的版本号或 updated_at 字段还能避免旧消息晚到覆盖新数据的问题。3.3 对账与补偿定时任务把漏掉的数据补回来再可靠的消息链路也会有极端意外消息队列丢消息、消费者代码有 bug、ES 集群磁盘满写入失败。所以最终一致性方案里对账是绝对不能省的一道防线。我常用的做法是写一个定时任务每 5 分钟扫描一次 MySQL 中最近有变更的记录把这些记录的主键集合和 ES 里的文档做比对发现缺失或更新时间不一致的重新同步一次。扫描时不要用LIMIT OFFSET深分页效率太低要改成WHERE updated_at :lastMaxId AND id :lastId ORDER BY id LIMIT 500这种游标式分页。对账任务不需要同步所有字段只比对业务更新时间戳和 ES 文档的sync_time。如果 MySQL 里updated_at比 ES 里的sync_time新说明同步晚了一步触发全量 upsert。这样即使消息链路出了岔子最多延迟几分钟数据仍然能收敛。3.4 更新、删除场景的特殊处理新增商品做全量 upsert 就好更新和删除却常常藏雷。物理删除要当心。如果 MySQL 业务逻辑是直接 DELETEbinlog 或者消息里还能拿到这条记录的主键消费者要在 ES 里执行 delete。但如果你在 MySQL 里用的是软删除比如deleted字段置 1那消费者就不要把文档从 ES 删除而是要把状态改成“已下架”让搜索引擎过滤掉它。因为软删除在 MySQL 里本质是更新ES 如果物理删除下次对账又可能把软删除的记录重建回去。部分更新也要小心。很多 PHP 项目更新商品时只传变化字段比如只改价格但消息里如果带的是完整对象快照问题不大如果带的是不完整数组直接 upsert 就会把 ES 里原来的名称、分类覆盖成空。解决方法是消费者里先查旧文档再合并字段或者干脆约定消息体里必须携带完整的“搜索快照”数据。4. 毫秒级搜索的查询链路优化4.1 先写库再发消息查询请求的时序一致性同步链路理清楚了还得想清楚一个业务问题用户刚改完自己的商品转头去前台搜索为什么搜不到因为搜索是异步最终一致的MySQL 写入成功后消息还在队列里ES 索引还没来得及刷新。这是架构取舍不是 bug。你要么接受“刚变更的数据几秒后才能被搜到”要么在前端做提示。电商商品、文章内容这类数据秒级可见完全可以接受。但如果是用户改了昵称立刻要在搜索结果里生效那就要前台下个版本做补偿或者将“搜索可见性”要求特别高的字段直接读 MySQL。还有一个顺序问题能不能把消息发到 MQ 之后再提交 MySQL 事务不行。万一 MySQL 事务提交失败消息已经发出去了消费者一去查 MySQL 发现数据不存在这条消息就变成脏消息。所以顺序必须是“先提交事务后发消息”。4.2 PHP 客户端的超时、连接池与重试策略查询链路要毫秒级PHP 客户端这层不能拖后腿。使用官方 elasticsearch-php 客户端时一定要调超时和连接池参数。ES 查询超时如果设置成 30 秒PHP-FPM 请求也会挂 30 秒前端早就超时了。我一般这样配置$client ClientBuilder::create() -setHosts([es-server:9200]) -setRetries(2) -setHandler(ClientBuilder::defaultHandler()) -build(); $params [ index product, client [ timeout 2, connect_timeout 1, ], body [...], ];超时控制在 1 到 2 秒就足够再长就做降级。注意尽量复用客户端实例避免每次请求都建立连接在 PHP-FPM 常驻模型里这要依赖连接池或 workerman 这类常驻框架传统 PHP-FPM 每次请求结束连接就释放所以更考验 ES 集群本身的吞吐能力。4.3 分页、排序和高亮怎么控成本ES 查询快但一些高级特性用不好也会让你从毫秒级跌到秒级。最典型的是深分页。from 10000, size 20这种写法ES 必须遍历前 10020 个文档再截取页码越深越慢。业务上搜索结果的翻页一般不会超过 100 页超过的话要么限制最大翻页数要么改成 search_after 游标翻页。search_after 不要用在用户随意跳页的场景它更适合“加载更多”这种往下翻的场景。排序字段尽量用数字、日期、keyword 类型并且开启 doc_values。临时对文本字段做排序ES 要为每个 shard 做额外计算性能很差。高亮也是不要把整个正文都高亮设置fragment_size和number_of_fragments只挑相关片段返回能省不少带宽和算力。搜索接口要“瘦身”ES 返回结果里只留必要的字段商品 ID、名称、图片、价格、销量、分数。详情页需要的信息让前端拿到 ID 之后再调详情接口别把整个文档全量返回。4.4 缓存兜底与降级搜索别把 ES 打挂流量稍微上来一点ES 也可能扛不住尤其碰上热搜、秒杀、大促。这时候必须做缓存和降级。我习惯在 ES 前面加一层 Redis 缓存。搜索关键词的维度组合非常多不可能都缓存但可以把 top 热门词、首页默认推荐词、搜索结果第一页的 JSON 缓存 60 秒。用户搜索时先查缓存命中就直接返回不命中再走 ES异步回填缓存。降级策略也要提前想好。如果 ES 集群出故障搜索接口不能直接 500最简单的兜底是返回一个空列表或者推荐商品静态列表同时记录日志告警。千万不要等到故障发生时再来设计降级逻辑那时候你连改代码的机会都没有。5. 一次生产环境的故障复盘5.1 现象新增商品搜不到有一个做电商平台的朋友项目上线消息队列同步方案两个月后运营反馈后台新增的商品在前台搜索不到但是在后台列表里能看到。一开始以为是队列积压看了监控发现堆积量不大而且积压早就处理完了但问题依旧。后来查了一下出问题的新增商品都有一个共同点创建商品时事务还没提交MQ 消息就已经发出去了。后来代码改了“事务提交后再发消息”但历史数据中已经有很多“先发消息后提交事务”造成的脏事件这些事件消费时去查 MySQL商品还不存在消费者就跳过或者写入了空数据。5.2 排查链路排查过程大概分三步。第一步查 MQ 日志确认消息确实被消费者拉取过了排除消息从未发出的可能。第二步查消费者日志发现有大量product not found的警告也就是消费者执行时查不到 MySQL 里的商品记录直接跳过。第三步回查代码发现生产端发布消息的方法写在数据库事务 commit 之前。这个问题单独看每一条日志都合理但拼在一起就暴露了时序 bug。消费者本身没错错的是生产端把“未来才存在的数据”提前暴露给了消费端。分布式系统里消息的顺序和数据可见性必须匹配这是最容易踩坑的地方。5.3 修复方案消息表 重试 对账这次故障让我下决心把“本地消息表”机制补上了。具体做法是在业务库里建一张sync_message表字段包括 id、biz_type、biz_id、payload、status、retry_count、next_retry_time、created_at。业务写入 MySQL 时在同一事务里插入一条待发送消息。事务提交后后台任务扫描 status pending 的记录投递到 MQ收到 MQ 确认后更新 status sent。消费者处理成功后调用一个接口把消息标记为 done或者由对账任务判断 biz_id 对应的 ES 文档已一致后清理。这样消息生产就不再依赖“业务代码有没有写对”而是依赖数据库事务。只要事务提交了消息就一定在表里。配合消费幂等和定时对账最终一致性就有了三层保障消息表保证“消息不丢”幂等消费保证“重复无害”对账保证“漏了能补”。6. 常见问题速查与排坑经验问题可能原因排查方法解决方案新增数据搜不到消息没发、消费失败、ES refresh 还没生效查 MQ 消费日志、查消息表状态用本地消息表保证发送消费失败重试调短 refresh interval更新后搜索还是旧值ES 文档没更新成功比对 MySQL updated_at 和 ES sync_time检查消息体是否携带最新数据幂等更新时用 updated_at 做版本判断搜索结果出现已删除商品物理删除未同步到 ES在 ES 中按 id 查询该文档消息消费时增加 delete 分支对账任务处理软删除状态搜索接口变慢深分页、高亮过大、排序字段非 doc_values查看 ES 慢查询日志限制 fromsize用 search_after精简高亮字段消费者一直失败堆积ES 集群异常、索引 mapping 变了查死信队列、ES 集群健康状态先恢复 ES再重放死信消息必要时重建索引MySQL 和 ES 数据整体不一致上线前的历史数据没有全量同步比对总数、抽样校验写一次性全量同步脚本导入时也用幂等 upsert除了表格里的问题我再分享一个经验ES 索引的 mapping 设计要“考虑未来别怕后悔”。mapping 一旦上线字段类型是没法直接改的。你同步数据时本来只存了商品名称和价格半年后业务要做价格区间筛选却发现 price 字段没设成数字类型那时候只能重建索引麻烦很大。我建议一开始就把商品 ID、名称、分类 ID、品牌 ID、价格、销量、上架状态、上下架时间、更新时间这些字段的类型都定义清楚keyword、integer、long、double、date、text 各归各位。搜索和筛选字段分开设计text 类型给 match 查询keyword 类型给 term、过滤和聚合。还有一个细节是 ES 的 refresh interval。默认 1 秒刷新一次意味着索引数据写入后最多 1 秒可见。如果你的业务对“秒级可见”要求比较高可以调到 30 秒甚至更短但要付出写入性能的代价。我个人建议保持默认 1 秒不要为了“看起来实时”牺牲写入吞吐。最后再讲一点最终一致性设计里最重要的不是技术选型而是你能否接受“延迟”。很多团队做搜索增强心态上急于把 ES 当成 MySQL 的实时从库一遇到数据没同步就说架构不行。实际上索引更新延迟、消费失败、对账周期这些都是分布式系统里预期内的行为。你要做的不是消灭延迟而是把延迟控制到业务能接受的范围同时保证任何异常都有补偿通道。我在几个项目里验证下来本地消息表 MQ 异步消费 幂等 upsert 定时对账这套组合稳定性和可维护性都足够好。代码上不复杂难的是每个环节都做到位少一个线上总会在你想不到的时候给你上一课。