从MySQL LIKE到Elasticsearch倒排索引:搜索架构改造实践
上个月我处理了一起线上事故:商品管理后台的搜索框输入“无线”两个字之后,页面转圈转了大概 40 秒才出结果。慢查询日志里躺着一条 SQL,SELECT * FROM products WHERE name LIKE '%无线%',扫描了 120 万行,全表没走索引。这套系统已经上线三年,MySQL 5.7 单机扛了将近 200 万商品数据,最早搜索需求很简单,按名字精确匹配就够了,后来运营非要支持模糊搜,于是代码里就用 LIKE 拼了个 %关键词%。数据量小的时候一切正常,量一上来,问题就是指数级恶化。那段时间我一边看日志一边想一个问题:MySQL 搜不动的东西,Elasticsearch(ES)到底是怎么接住的?
这篇文章不是我抄官方文档的心得,而是我把从 LIKE 到倒排索引、从 MySQL 数据同步到 ES 调参这条完整链路踩过一遍之后的整理。你会看到为什么 %关键字% 会拖垮 MySQL,倒排索引为什么能做到“秒回”,以及 MySQL 和 ES 之间数据同步的三种主流方案怎么选、ES 写入和查询有哪些容易被忽略的调参细节。最后我会聊聊真正的选型边界:什么场景留在 MySQL 就够了,什么场景必须老老实实上 ES。
1. 一次搜索卡死事故牵出的问题:LIKE 究竟慢在哪
1.1 事故现场:一条慢 SQL 的扩散效应
先还原一下当时的情况。商品表 products 大概 120 万行,字段不多,但 name 是核心搜索字段。运营反馈“搜索太慢”,我第一反应是看慢查询日志,结果发现 SELECT * FROM products WHERE name LIKE '%无线%' 这条语句的平均耗时在 15 秒到 40 秒之间摇摆。重点在这里:它不是偶尔慢,而是每次慢。
更麻烦的是,这条 SQL 每执行一次,MySQL 就要做一次全表扫描,把所有 120 万行的 name 字段全部取出来,逐行做通配符匹配。数据库的连接池是 20 个连接,后台搜索功能一被高频触发,20 个连接全部被这种慢查询占住,其他正常业务——订单查询、库存更新——全部排队。页面上所有依赖这个数据库的接口都开始超时,最后整个后台系统就像死了一样。
这就是 LIKE 查询最典型的扩散效应:一条慢 SQL 不只是一次慢请求,它会把数据库连接池耗尽,把其他正常流量一起拖死。排查的时候我发现这条 SQL 的产生毫无节制,用户在搜索框里每敲一个字就触发一次搜索请求,前端还做了防抖,敲完“无线蓝牙耳机”最多会触发三次查询。三次全表扫描叠加,连接池直接就满了。
1.2 EXPLAIN 告诉你:%关键词% 让 MySQL 只能裸奔
在 MySQL 里,LIKE 并不是完全不能走索引,关键在于通配符的位置。LIKE '无线%' 是可以走索引的,因为索引的 B+ 树结构支持字符串的最左前缀匹配,先找到所有以“无线”开头的记录就行。但一旦写成 LIKE '%无线%',或者说 LIKE '%无线',情况就完全不同了——通配符放到了字符串的开头,优化器无法确定从哪里开始扫描,因为任何字符都可能出现在“无线”前面。它只能选择全表扫描。
用 EXPLAIN 看一下就很直白:
结果里的 type 是 ALL,rows 是 120 万。ALL 在 MySQL 执行计划里代表全表扫描,这是五级访问类型里最差的一档。哪怕给 name 字段建了普通索引,这种前缀带通配符的查询也利用不上,优化器会直接把索引丢掉,因为索引扫描在这个场景下并不比全表扫描快多少,还要多付一次回表查询的代价。
还有一个很多人忽略的点:LIKE '%keyword%' 在扫描过程中要做逐行的字符串匹配,MySQL 会按照字符集和排序规则逐字节比较。如果字段上还加了函数操作、大小写转换或者字符集不一致,那还得额外做隐式转换,进一步放大开销。我见过一个更夸张的版本,查询条件写成 WHERE LOWER(name) LIKE '%wireless%',字段上套了函数,索引彻底失效,连 LIKE 'wireless%' 这种本来可以走索引的形式都救不回来。
1.3 LIKE 不只是慢,还会拖垮其他请求
全表扫描只是问题的第一步。MySQL 的每一次查询都要经过 InnoDB 缓冲池,如果 120 万行的数据页全部扫一遍,缓冲池里原本热点的数据页——订单、库存相关的页——就会被一点点挤出去。换句话说,慢查询不光自己慢,它还会污染整个实例的缓存命中率,把原本很快的业务查询也带慢。
我当时的实测数据是:在没有这条慢查询时,整个库的平均查询响应时间是 8ms 左右;慢查询一跑起来,平均响应时间直接飙升到 300ms 以上,订单查询从 10ms 级变成几百毫秒。这里面的连锁反应就是连接池排队——请求等不到数据库连接,前端拿到的全是超时错误。
另外一个容易被忽略的成本是备份和主从复制。全表扫描会读取大量数据页,产生很高的 IOPS,如果线上启用了主从复制,从库在同步时也要重放相应的读取和缓冲池操作(虽然读操作不重放到 binlog,但缓冲区污染在从库一样存在)。我处理的那套系统,慢查询高峰期间从库的磁盘读延迟比平时高了三倍。所有的这些副作用叠加在一起,让我彻底意识到:在数据量过百万之后,用 LIKE 做核心搜索路径就是一个定时炸弹。
2. 倒排索引为什么能“秒回”:ES 的底层查找逻辑
2.1 从书本目录到搜索词典:先想明白搜索引擎比的是什么
要理解倒排索引,可以先想一个很朴素的场景:你想在一本 500 页的技术书里找到所有包含“倒排索引”这四个字的段落。笨办法是翻开书从第 1 页逐页读到第 500 页,看到“倒排索引”就记下页码——这就是 MySQL LIKE 的全表扫描。聪明一点的办法是书后面附了主题索引,它把每个术语对应的页码列出来,你直接查“倒排索引”就可以跳到对应页面——这就是正排索引。
但真正的搜索引擎用的是另一种思路:不是从文档出发去找词,而是从词出发去找文档。在索引构建阶段,ES 会把每个文档的文字内容切成一个个词条,然后建立一个“词条 → 文档列表”的映射表。这个词条叫做 Term,文档列表叫做 Posting List。查询“无线”的时候,ES 做的事情不是在文档里找“无线”两个字,而是在自己的词典里查“无线”这个 Term,直接拿出它对应的文档列表。这就是“倒排”两个字的含义:把文档和词条的关系从“文档包含哪些词”反转为“词条出现在哪些文档”。
2.2 分词、词典、倒排表:三段式的核心结构
一个完整的倒排索引由三部分组成:Term Dictionary(词典)、Posting List(倒排表)和 Segment(段文件)。Term Dictionary 里保存的是所有被索引的词条,每个词条指向一个倒排表;倒排表里保存的是包含这个词条的文档 ID 列表,以及词频、位置等附加信息。查询时,先查词典,再取倒排表,最后根据文档 ID 去获取原始数据。
这里有个非常关键的区别:MySQL 的索引是建立在“原始值”之上的,WHERE name = '无线' 匹配的是完整的 name 字段;而 ES 的索引是建立在“分词结果”之上的。文档写入 ES 时,分析器会把 name 字段“无线蓝牙耳机”拆成“无线”“蓝牙”“耳机”这些词条,分别建立倒排索引。这样一来,搜索“无线”就能命中,搜索“耳机”也能命中,不需要完整的字符串匹配。
分词器的选择直接决定了搜索质量。英文场景一般用 standard 分词器,按空格和标点切分;中文场景最常用的是 ik_smart 或 ik_max_word,配合自定义词典做领域词扩展。如果分词器选错了,比如对中文文本用 standard 分词器,中文会被逐字切分,“无线”会被切成“无”和“线”,搜索结果就是灾难。我在项目里切换中文搜索场景时,第一件事就是验证分词效果,用 _analyze API 先跑一遍再确认 mapping。
2.3 为什么倒排索引能扛住百万级数据的毫秒查询
MySQL 的 B+ 树索引擅长的是“精确查找”和“范围扫描”,因为底层数据是有序存储的;而倒排索引擅长的是“词条反查文档”,因为每个词条对应的文档列表已经预先构建好了。两者的设计目标根本不同。
在百万级数据量上,ES 的查询优势非常明显。一个词条查完后,拿到的是几十个文档 ID,而不是扫描上百万行。再加上 ES 底层用 Lucene 管理段文件,段内做了压缩、缓存和跳表(Skip List),查询时可以直接跳过大量不相关的文档 ID。配合 filter 缓存,重复查询的耗时能降到个位数毫秒。
我做过一次简单的对比测试:同一份 120 万商品数据,MySQL 上执行 %无线% 的 LIKE 查询耗时 15 秒以上,ES 上用 match 查询“无线”耗时不到 50ms。这三百倍的速度差距不是 ES 比 MySQL“聪明”多少,而是引擎架构决定的——MySQL 做的是全表字符串扫描,ES 做的是词典反查。数据量越大,这种差距越悬殊。理解这一点之后,你就不会迷信“加个索引 MySQL 也能快”这种话了,架构层面的差距不是调参数能补上的。
3. 在 MySQL 内部还能再抢救一下吗:前缀索引与全文索引的边界
3.1 前缀索引、覆盖索引能在多大程度上救场
有人会问:既然 LIKE '无线%' 可以走索引,那能不能设计数据把前面通配符去掉?一定程度上可以。比如搜索词如果固定在标题开头,或者允许只做前缀搜索,那么 LIKE '无线%' 走索引的效率是可以接受的。为此可以建一个前缀索引或者普通索引优化前缀匹配。
但是,前缀索引这里有一个隐藏问题:如果你建的是 name(10) 这样的前缀索引,只取前 10 个字符建立索引,那么查询 WHERE name LIKE '无线%' 虽然能走索引,但返回结果需要回表验证;如果后续排序和分组也要用索引,前缀索引往往又帮不上忙。我在项目里对前缀搜索做过一些优化,比如把搜索字段拆成单独的 short_name,短字段建普通索引,能有效减少回表代价。
覆盖索引则是另一种思路:如果查询只需要索引中包含的字段,不需要回表,效率也会好很多。比如业务上只需要统计数量,SELECT COUNT(*) FROM products WHERE name LIKE '无线%',那么只要索引里包含 name 就够了。可惜的是,对 %无线% 这种形式,前面说过,优化器根本不考虑用 B+ 树索引,前缀和覆盖索引都白搭。
3.2 MySQL 全文索引:能用,但别在核心业务上赌
MySQL 从 5.6 开始支持全文索引,InnoDB 在 5.7 之后也支持了。理论上可以用 MATCH(name) AGAINST('无线' IN NATURAL LANGUAGE MODE) 来替代 LIKE。但实践下来,MySQL 的全文索引有几个很现实的问题。
第一,它默认的最小分词长度是 4 个字符,由 ft_min_word_len 参数控制。中文一句话通常没有空格分词,MySQL 内置的分词器对中文支持非常弱,实际用起来经常搜不到“无线”这种 2 字高频词。第二,全文索引的查询语法有自己的局限,不支持高亮、同义词扩展,相关性评分算法也比较原始。第三,数据量和并发一上来,MySQL 全文索引的性能一样会崩,它本质上是独立的倒排结构,但维护成本和调优手段远不如 Lucene 成熟。
我尝试过在 MySQL 5.7 上用全文索引做商品搜索,小规模数据(十万级)勉强能用,中文分词靠自定义插件实现对内部运维来说是个持续的负担。而且 MySQL 全文索引和事务之间有额外的锁和代价,写入频繁的业务更要慎重。最终我的判断是:MySQL 全文索引适合做轻量级的内容检索,或者某些内部系统的小场景,但核心搜索入口交给它,不太划算。
3.3 什么时候该承认:MySQL 不适合做搜索
这是一道很现实的判断题。当你的业务同时满足以下多个条件时,我认为就该认真考虑引入 ES 了:
- 数据量超过百万行,且还在持续增长。
- 搜索场景包含大量
%关键词%形式的中间模糊匹配。 - 搜索需要分词、去停用词、同义词、拼音纠错等能力。
- 用户会搜各种奇奇怪怪的短词,查询模式不可控。
- 搜索请求并发比较高,现有数据库可能被慢查询拖垮。
MySQL 是事务型数据库,它的本职工作是保证 ACID 特性、处理读写事务、保证数据一致性。搜索功能把它推到能力边界之外,迟早会出事。我做过一个比喻:MySQL 像是仓库管理员,你要取某一块指定的货,他很熟练;但你要找出所有箱子上印着“无线”两个字的货,他就得一个一个翻,翻到天荒地老。ES 则是专门为这个“翻找”场景建了一整套摘要卡片系统,每个词条对应一批货的位置信息。两者的职责天生不同,承认这个边界,其实是架构决策成熟的表现。
4. 让 ES 接住数据:三种 MySQL 同步方案的选型与实践
4.1 同步方案选型:双写、MQ 异步还是 binlog + Canal
决定引入 ES 之后,你面对的第一个现实问题就是:MySQL 里的老数据和新数据怎么进 ES?数据同步方案,我实践过三种主流做法,各有优劣。
方案 A:业务双写。在业务代码里,每次插入、更新、删除时同时操作 MySQL 和 ES。优点是实现最简单,没有额外组件,逻辑在一个事务边界里能覆盖大部分场景。缺点是耦合严重——业务代码里到处散落 ES 写入逻辑,ES 故障会直接污染主流程,而且历史数据初次迁移还要单独写脚本。
方案 B:MQ 异步同步。主业务照常操作 MySQL,事务提交成功后发送一条 MQ 消息,专门的消费服务拿到消息后写入 ES。这样把 ES 写入从主链路中剥离出去,ES 挂了一点不影响主业务。缺点是消息可靠性依赖 MQ,需要处理消息乱序和重复投递。
方案 C:binlog + Canal / Debezium。原理是模拟 MySQL 主从复制协议,订阅 MySQL 的 binlog 增量变更事件,然后解析事件写入 ES。优点是最标准、最解耦,业务代码零改动,所有的写操作都会被捕获。缺点是需要引入额外的中间件,部署和运维复杂度上升。
从长期维护角度,我强烈建议首选方案 C,特别是数据量大、写入频繁的系统。我自己在生产环境用的就是 Canal 监听 binlog,配合 RocketMQ 转发到 ES 消费端。如果团队对 Canal 不熟悉,也可以评估 Debezium + Kafka;它的生态更完整,但对资源占用也更高。方案 A 只适合快速验证的原型项目,方案 B 适合团队已经有 MQ 基础设施、但暂时不想引入 binlog 订阅组件的场景。
4.2 binlog + Canal 配置实操:从 MySQL 到 ES 的增量通道
如果你选择方案 C,最基础的一步是开启 MySQL 的 binlog。在 my.cnf 里这样配置:
关键点在于 binlog-format=ROW。Canal 解析 ROW 格式的 binlog 时,才能拿到每一行变更前后的具体数据,STATEMENT 格式只能拿到 SQL 语句,重建完整文档会很困难。binlog-row-image=FULL 是为了让更新事件里同时包含旧值和新值,对同步系统非常有帮助。
Canal 端的核心配置,我列出几个容易踩坑的项:
filter.regex 是表级过滤,格式是 库名\\..*,如果用默认值会监听所有库,容易把不需要同步的系统表也带进来。我一开始没加这个过滤,导致 Canal 往 MQ 里塞了一堆 MySQL 自带的系统表变更,消费端解析直接报错。
ES 消费端的伪代码逻辑大概是这样的:
以 MySQL 主键作为 ES 的 _id 是同步场景的核心设计,它让同步天然幂等——重复消费同一条消息,对 ES 而言只是覆盖写,不会产生重复文档。这点很重要,因为 MQ 和 Canal 都可能重复投递消息。
4.3 双写与 MQ 方案的取舍,以及 Java 侧异步写入 ES 的细节
如果你只是快速接入,业务双写是最快的路径。但双写最大的风险是数据一致性——MySQL 写入成功、ES 写入失败怎么办?解决思路是引入一个补偿流程:把同步失败的记录写入一张本地任务表,定时重试。核心代码如下:
这个方案能保证最终一致,但业务代码侵入性太强。我的建议是:双写只用来跑通 Demo,生产环境优先 MQ 或 Canal。
Java 消费端写 ES 时,有一个非常关键的组件叫 BulkProcessor。千万不能一条一条发送写请求,那样性能会非常差。正确做法是:
这三个阈值是并行的,谁先达到谁触发。我实测下来,5000~10000 条一批、单批 10MB 左右是写入吞吐最稳的区间。批量太大会导致 ES 内存压力大,批量太小又无法发挥批量写优势。
4.4 同步一致性:删除和更新如何对齐 MySQL 与 ES
同步系统里,新增和更新相对好处理,因为都是覆盖写入。真正容易翻车的是删除。MySQL 删了一行,ES 里对应的文档要能删掉,否则就会出现“搜索出已下架商品”的问题。
最稳妥的同步设计是:业务删除时默认采用软删除,给表加一个 deleted 字段,更新事件只做覆盖写,把 deleted=1 写进 ES。ES 端查询时默认过滤掉 deleted 字段为 true 的文档。这个方案的好处是彻底避开物理删除带来的同步时序问题,而且后续要恢复数据也容易。如果一定要物理删除 MySQL 行,Canal 会捕获到 DELETE 事件,消费端调用 DeleteRequest 删除对应 _id 的文档;为了防止删除消息乱序导致“先删后写又把复活文档写进去”,最好给数据加 updated_at 版本字段,消费端做版本比对,旧版本事件直接丢弃。
热词里有人搜“es删除条数”,这里也多说一句:ES 的物理删除不会立刻释放磁盘空间。删除操作只是给文档打删除标记,真正的清理要等段合并(segment merge)时触发。如果你想确认删了多少文档,可以用 _count 接口查询,或者看 _cat/indices 接口返回的 docs.deleted 指标。如果删除量大,磁盘空间迟迟不释放,可以在低峰期对索引做一次 force merge。正常情况下,我建议保持默认后台合并策略,不要频繁手动 force merge,因为它的 CPU 和 IO 开销很大。
5. ES 写入与查询的关键调参:装好只是开始,会调才算接住
5.1 bulk 写入和 refresh_interval:别让可见性拖垮写入性能
很多团队把数据同步到 ES 之后,发现写入吞吐完全上不去,问题大多出在 refresh 和 translog 的默认配置上。ES 默认每 1 秒刷新一次,刷新后新写入的文档才可被搜索到。这个“近实时”设计是为了平衡搜索可见性和写入性能,但默认值对大批量导入阶段来说太频繁了——每秒钟 flush 一次段文件,对磁盘和 CPU 都有压力。
导入历史数据阶段,我推荐做以下调整:
refresh_interval 调大到 30 秒,让段文件合并的频次降下来;副本数设为 0,避免每写入一份数据还要复制到副本;translog.durability 改成 async,把每次请求的 fsync 从同步改为异步。这三项叠加,导入吞吐能提升好几倍。等数据导完,再调回生产配置并强制刷一次:
然后恢复副本数:
一个常见误区是:同步场景里有人为了让查询实时看到最新数据,把 refresh_interval 设成 1s,结果导入操作频繁触发 flush,CPU 被打满。要明白一个原则:ES 的可见性和写入性能是一对矛盾,必须在业务需求之间选一个平衡点。商品库这类对实时性要求不高的场景,完全没必要追求秒级可见。
5.2 routing 路由:为什么它决定查询命中的分片数
热词里有人搜“es routing”,我就多说几句。ES 的分片散布在多个节点上,默认情况下,一个查询请求进来后会广播到所有主分片,然后各分片执行查询、返回局部结果,最后协调节点合并。如果索引有 5 个主分片,一次查询实际上是在 5 个分片上都跑了一遍,然后做归并。数据量很大、分片很多的时候,这种广播的开销是实实在在的。
routing 的核心作用是在写入和查询时指定一个路由键,ES 通过 hash(routing) % number_of_primary_shards 计算出目标分片,让相关数据尽量落在同一个分片上。这个功能最经典的使用场景是“用户数据只查某个用户自己的文档”或者“商家只查自己店铺的商品”。
写入和查询时带上同一个路由键:
查询时也带:
用了 routing 之后,写入只打到 1 个分片,查询也只在 1 个分片上执行,性能提升非常明显。但要注意:routing 必须一致,写入和查询如果路由键不一致,数据就会查不到。如果你使用 ES 做多租户隔离,务必在查询入口强制统一指定租户 ID 作为 routing 值。
5.3 删除慢、磁盘不释放:段合并背后的原理
很多初学者会遇到这样的困惑:用了 _delete_by_query 删了一大批文档,为什么 _cat/indices 看到的 store.size 还是那么大?
原因在 Lucene 的段(Segment)机制。ES 的每个分片是由多个不可变的段文件组成的,删除操作不会真正去段文件里抹掉数据,而是在一个叫 .del 的文件里记录被删除的文档 ID。查询时,段文件会先过滤掉这些被标记为删除的文档,所以你的搜索结果看不到了,但物理空间还被占着。段合并是 Lucene 后台自动执行的:将多个小段合并成大段,在合并过程中真正丢弃被删除的文档,然后删除旧的段文件,磁盘空间才会释放。
遇到删除量大、空间迟迟不回收时,可以手动触发合并:
这个操作会把分片里的所有段合并成一个段,效果是彻底清理删除标记并释放空间。但你要知道代价:force merge 期间会有很大的 IO 和 CPU 开销,在线业务可能感受到性能波动。我一般把它放在凌晨低峰期,并且只对确认不再写入的索引做。对高频写入的索引长期保持一个段反而是坏事,因为合并后的段一旦被标记删除,后续的更新会需要反复写极大的段,性能会更差。
5.4 查询 DSL 的常见坑:match、term 与 keyword
把数据同步进 ES 只是第一步,查询写不对照样拿不到好结果。最常见的坑是混淆 match 和 term 语义。
match是全文检索的入口,查询文本会被分词器分词,然后对每个词干做倒排索引查找,返回相关性最高的文档。term是精确查找,它不做分词,直接把查询词当作整体去词典里匹配。
如果你的 mapping 里把 name 字段定义成了 text 类型,那么 term 查询“无线”会失败或返回空,因为 text 字段在索引阶段已经被切成“无”“线”,索引里根本没有“无线”这个词项。想要对“无线”做精确匹配,就得用 keyword 子字段。
这样设计之后,name 支持全文检索,name.keyword 支持精确匹配和排序。没有这个 keyword 子字段,你会发现大量聚合、排序、精确筛选的需求全部不好使。我在做搜索功能时,凡是需要展示的字段,都会认真想清楚要不要同时保留 text 和 keyword 两种映射,上线之后再改 mapping 就要 reindex,非常痛苦。
还有一个很多人忽略的语义问题:term 查询 numeric 类型时,查询值不能带引号,不然 ES 会把它解析成字符串,和数字类型的 term 匹配不上,结果就是你明明有数据却查不到。
6. 选型边界:哪些搜索场景该留在 MySQL,哪些必须交给 ES
6.1 一个实用的决策框架:看数据量、查询模式和实时性
经历过这次事故之后,我给自己总结了一套判断标准,在这里分享给有类似困惑的人,可以用来快速判断要不要上 ES:
先看查询模式。如果业务里搜索都是 LIKE 'prefix%' 这种前缀匹配,数据量在几十万以内,MySQL 普通索引足够。如果是 %keyword% 或者更复杂的分词检索,且数据量在百万以上,直接考虑 ES,不要纠结。
再看实时性。MySQL 的数据天然实时,事务提交后立即可见;ES 是近实时系统,默认有 1 秒的刷新间隔,加上同步链路(binlog 解析 + MQ + 消费端)的延迟,从 MySQL 写入到 ES 可见,通常会滞后几百毫秒到几秒。这个级别的延迟在商品搜索、博客检索、日志分析场景完全可以接受,但如果你的业务需要搜索结果和数据库数据严格一致,比如交易记录搜索,那么 ES 只能作为辅助索引,最终要回 MySQL 查明细。
最后看运维复杂度。ES 是一个完整的分布式系统,它需要自己的集群监控、索引生命周期管理、容量规划;MySQL 团队要学的是一套全新的东西。如果团队只有一两个人,并且业务规模不大,硬上 ES 带来的运维负担可能超过收益。我见过很多小团队为了搜索功能搭了三个节点的 ES,后来因为无人维护,集群状态变黄都没人管。这种情况下,我反而建议先用 MySQL 硬扛或者引入简单的外部搜索服务。
6.2 从真实场景看边界:商品搜索、日志检索和事务查询
结合这次事故的具体场景,我把两类典型场景再展开一下:
商品搜索是最典型的“MySQL 做不了、ES 很擅长”的场景。用户可能搜品牌、品类、功能、颜色,输入五花八门,搜索词不是完整的字段值,而是对标题的分词匹配。ES 能提供分词、同义词、拼写纠错、高亮、相关性排序,这些能力 MySQL 几乎给不了。我自己在做了那次事故处理之后,新项目的搜索索引全部走 ES,MySQL 只保留商品数据的主读写通道。
日志检索是另一类必须交给 ES 的场景。日志没有固定结构,数据量巨大,查询模式往往是按时间范围加关键词组合检索。ES 的倒排索引结合时间范围过滤,加上聚合分析能力,能撑住这种场景。这也是 ES 最成熟的使用场景之一。顺带一提,热词里有人搜“tdengine”——如果你做的是物联网时序数据,TDengine 这类时序数据库可能比 ES 更合适,它能兼顾写入和时序聚合查询。选型没有银弹,关键是看清数据形态。
事务查询则是 MySQL 的绝对主场。账户余额、订单状态、库存数量这类强一致性的读写,必须留在 MySQL 用事务保证。ES 不适合做事务数据的唯一事实来源,它的更新机制没有行级锁和事务隔离级别,并发更新很容易出现版本冲突。我的经验是:ES 同步事务数据时,只把它当作“读模型”或者“搜索视图”,真正的权威数据永远在 MySQL。搜索命中后,需要展示详情时再回 MySQL 查一次,或者干脆在同步到 ES 的文档里冗余一份展示所需的全量字段,减少回源。
6.3 一个稳妥的落地路径:先解决搜索,再平滑演进
如果你看完前面所有的内容,觉得自己的系统确实需要 ES,我给一个比较稳妥的落地路径:
第一步,先不要去动正在跑的 MySQL。用一个独立的 ES 集群,把 MySQL 的业务库通过 binlog 同步一份过去,索引只建最核心的搜索字段,跑一两周观察同步延迟和稳定性。
第二步,在测试环境把搜索接口从 MySQL 切到 ES,对比同样的关键词,看返回结果和响应时间是否达到预期。
第三步,灰度上线。发布时保留 MySQL LIKE 查询作为兜底开关,ES 查询失败或者超时的时候自动回落 MySQL。我在生产环境就是这么做的,用一个配置开关 search.engine=es|mysql,随时可以切换。
第四步,等 ES 搜索稳定运行一段时间后,把历史数据补齐完整,再逐步下线 MySQL 侧的搜索路径,把它彻底还给我最早的职责——事务和基础数据存储。
这套路径本质上是把搜索流量逐步迁出 MySQL,避免突如其来的重构风险。我处理那次事故时,就是先加了一个急停开关,让后端在 LIKE 慢查询爆发时自动降级为只允许前缀匹配,稳住了线上;然后再花了两周时间把同步链路和 ES 查询跑起来,最后切流量。
结尾
我在这套 MySQL + ES 架构里踩过的最大一个坑,不是同步链路搭建,而是数据一致性预期没对齐。业务方以为搜索结果是实时的,但实际同步链路的延迟有几秒,于是经常有“刚上架的商品搜索不到”的反馈。后来我在商品文档里加了 created_at 字段,搜索时对刚写入的数据做了特殊标记,同时允许前端提示“数据同步中”,这个问题才算解决。很多时候,架构方案本身不难,真正难的是把各方的预期校准到一个合理的水平。最后再分享一个经验:搜索架构改造不是一锤子买卖,先让 MySQL 别死撑,再让 ES 平稳接住,这条路径最值得走。