MySQL 的 LIKE 查询大概是每个后端开发都写过的东西,WHERE title LIKE '%关键词%'一敲,功能就跑通了。可当数据量从几万涨到几百万,这条语句的响应时间从 50ms 变成 5s 的时候,你就知道事情没那么简单了。这篇文章想聊的,就是怎么从 MySQL 的模糊匹配一步步走到 ES 的倒排索引,再把全文检索和混合检索这两件事讲清楚。不管你是刚接触搜索功能的新手,还是正在做搜索架构升级的老手,这里面的选型逻辑、原理拆解和实操细节,应该都能让你少走一些弯路。
1. 为什么 LIKE '%xxx%' 注定撑不起搜索场景
1.1 从一次线上慢查询说起
我印象特别深的一次经历,是帮一个朋友看他的内容管理系统。文章表大概 80 万行,标题和正文加起来平均每行 2KB 左右。他做了一个站内搜索,SQL 大概长这样:
SELECT id, title, summary, created_at FROM articles WHERE title LIKE '%机器学习%' OR content LIKE '%机器学习%' ORDER BY created_at DESC LIMIT 20;单看这条语句,逻辑没问题,结果也对。问题是每次执行要 4 到 6 秒,赶上高峰期数据库 CPU 直接飙到 90% 以上。他一开始以为是索引没建好,给 title 加了索引,结果发现根本没用——因为%关键词%这种前后都带通配符的写法,B+ 树索引完全用不上。
这里就引出了第一个核心问题:MySQL 的索引结构决定了它不擅长做"包含式"匹配。B+ 树索引的本质是一棵排好序的树,它能快速定位"等于某个值""大于某个值""以某个前缀开头"这类查询,因为这些都是有序区间。但%关键词%要求的是"任意位置包含",这等于让索引放弃它最擅长的有序性,退化成全表扫描。
1.2 全表扫描到底慢在哪
很多人知道全表扫描慢,但说不清慢在哪。我拆一下这个过程你就明白了。
假设 articles 表 80 万行,每行平均 2KB,整张表大约 1.6GB。执行LIKE '%机器学习%'时,MySQL 需要:
- 从磁盘(或缓冲池)把每一行的数据读出来;
- 对每一行的 title 和 content 字段做字符串匹配;
- 匹配成功的行再参与排序和分页。
这里面最要命的是第 1 步和第 2 步。数据量大到内存装不下时,就要反复读磁盘;字符串匹配本身又是逐字符比较,中文还要考虑编码,CPU 开销也不小。80 万行扫一遍,几秒钟太正常了。
更麻烦的是,这种查询没法有效利用缓存。因为每次搜索的关键词不同,扫描的路径虽然一样,但匹配计算每次都要重来。你没法像等值查询那样把结果缓存起来。
1.3 前缀匹配能救一部分,但救不了全部
有人会说,那我把%关键词%改成关键词%不就行了?确实,前缀匹配能用上索引:
-- 能用索引,因为是有序区间的起点 SELECT * FROM articles WHERE title LIKE '机器学习%';但问题是,用户搜索的时候,关键词往往出现在标题中间或结尾。比如"深入理解机器学习算法",用户搜"机器学习",前缀匹配就匹配不到。而且正文内容的搜索,几乎不可能用前缀匹配解决。
所以结论很明确:只要你的搜索需求涉及"任意位置包含"或者"多字段联合搜索",MySQL 的 LIKE 就不是一个可扩展的方案。数据量小的时候能用,是因为全表扫描的成本还能接受;一旦上了规模,必须换思路。
2. 倒排索引:把"找文档"变成"查词典"
2.1 正排索引和倒排索引的本质区别
要理解 ES 为什么快,得先搞清楚倒排索引和正排索引的区别。这两个词听起来玄乎,其实用一本书的目录就能解释清楚。
正排索引就像书的正文顺序:第 1 页讲了什么,第 2 页讲了什么。你知道页码,去翻就能找到内容。MySQL 的 B+ 树索引本质上也是正排思路——通过主键或索引键定位到行。
倒排索引则像书后面的索引页:你想找"机器学习"这个词,索引页告诉你它出现在第 15、28、103 页。你不需要翻遍整本书,直接跳到那几页就行。
用技术语言描述:倒排索引建立的是"词项(term)到文档列表(posting list)"的映射。每个词项记录它出现在哪些文档里,以及出现的位置、频率等信息。
| 对比维度 | 正排索引 | 倒排索引 |
|---|---|---|
| 映射方向 | 文档 → 内容 | 词项 → 文档列表 |
| 典型代表 | MySQL B+ 树 | Elasticsearch、Lucene |
| 擅长查询 | 等值、范围、前缀 | 全文检索、多词组合 |
| 写入成本 | 较低 | 较高(需分词、建表) |
| 存储开销 | 较小 | 较大(需存词典和倒排表) |
2.2 分词:倒排索引的第一道工序
倒排索引要建立"词项到文档"的映射,第一步就是把文档拆成词项,这个过程叫分词(tokenization)。
英文分词相对简单,按空格和标点切就行。中文就麻烦多了,因为中文没有天然的词边界。"机器学习"这四个字,可以切成"机器/学习",也可以切成"机/器/学/习"。切法不同,检索效果天差地别。
ES 默认的中文分词器是 standard,它会把中文按单字切分。也就是说"机器学习"会被切成"机""器""学""习"四个词项。这样搜"机器学习"能搜到,但搜"学习"也能搜到,精度很差,而且倒排表会膨胀得很大。
实际项目中,中文场景一般会换成 IK 分词器。IK 有两种模式:
- ik_smart:粗粒度,尽量切出有意义的词,"机器学习"切成"机器学习";
- ik_max_word:细粒度,穷举所有可能的词,"机器学习"切成"机器""学习""机器学习"等。
选哪个取决于你的场景。搜索精度要求高、词典覆盖全的,用 ik_smart;召回率优先、不怕多切一点的,用 ik_max_word。我个人的经验是,标题字段用 ik_max_word 提高召回,正文字段用 ik_smart 控制索引大小。
2.3 倒排表的物理结构长什么样
光知道"词项到文档列表"还不够,真正让 ES 快的是倒排表的物理组织方式。Lucene(ES 的底层引擎)的倒排表大致包含这几部分:
- 词典(Term Dictionary):所有词项的有序集合,通常用 FST(有限状态转换器)存储,既省空间又支持快速查找;
- 倒排列表(Posting List):每个词项对应的文档 ID 列表,按文档 ID 升序排列;
- 频率信息(Term Frequency):词项在文档中出现的次数,用于相关性打分;
- 位置信息(Positions):词项在文档中的位置,用于短语查询和高亮。
这里有个关键设计:倒排列表里的文档 ID 是有序的。有序意味着可以用跳表(skip list)加速求交集。比如搜"机器 学习",需要找出同时包含这两个词的文档,就是对两个有序列表求交集。有序列表求交集可以边扫边跳,比无序列表快得多。
还有一个优化叫Frame of Reference(FOR)编码,把文档 ID 的差值而不是绝对值存下来,差值通常很小,可以用更少的位存储。这些底层优化叠加起来,才让 ES 在亿级文档上还能做到毫秒级响应。
2.4 相关性打分:为什么搜出来的顺序不一样
用 LIKE 搜索,结果是按你指定的字段排序的,比如按时间倒序。但 ES 默认会按相关性排序,这就是为什么同样的关键词,ES 搜出来的第一条往往比 MySQL 更"对味"。
ES 默认用 BM25 算法打分,核心思想是:
- 词项在文档中出现次数越多,越相关(但增长会饱和,不是线性);
- 词项在越少的文档中出现,区分度越高,权重越大(IDF 思想);
- 文档越长,单个词项的贡献会被稀释。
举个直观的例子:搜"苹果",如果一篇文章里"苹果"出现了 10 次,另一篇只出现 1 次,前者通常更相关。但如果"苹果"这个词在 90% 的文档里都出现,那它的区分度就很低,权重会被压低。BM25 就是把这些因素综合起来算一个分数。
理解这一点很重要,因为它决定了你后面做混合检索时,怎么把 ES 的分数和别的信号融合。
3. 从 MySQL 到 ES:数据同步的几种打法
3.1 同步方案选型:先想清楚一致性要求
把数据从 MySQL 同步到 ES,是绕不开的一步。但同步方案不是越实时越好,得先想清楚你的业务能接受多大的延迟。
| 方案 | 实时性 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 双写 | 准实时 | 低 | 写入量小、能接受偶发不一致 |
| 定时任务全量 | 分钟级 | 低 | 数据量小、实时性要求低 |
| 定时任务增量 | 分钟级 | 中 | 有更新时间字段、能接受延迟 |
| Binlog 订阅 | 秒级 | 高 | 实时性要求高、写入量大 |
双写是最容易想到的方案:业务代码里写完 MySQL 紧接着写 ES。但它有个致命问题——两个写操作不在一个事务里,MySQL 写成功、ES 写失败怎么办?网络抖动、ES 集群短暂不可用,都会导致数据不一致。而且双写会让业务代码耦合搜索逻辑,后期维护很痛苦。
Binlog 订阅是目前主流方案。MySQL 的 binlog 记录了所有数据变更,用一个中间件(比如 Canal)伪装成从库订阅 binlog,解析出变更事件后推送到 ES。这个方案的好处是业务代码零侵入,实时性也好。代价是要维护额外的中间件,而且要注意 binlog 格式必须是 ROW 模式。
我自己的项目里,中小规模用定时增量同步就够了,简单可靠;上了千万级数据、实时性要求高的,才上 Binlog 方案。
3.2 增量同步的一个坑:删除和更新怎么处理
增量同步最容易踩的坑,是删除操作。很多人的增量逻辑是"查更新时间大于上次同步时间的记录",但物理删除的记录,你根本查不到,ES 里就会残留脏数据。
解决办法有两个:
- 软删除:表里加
is_deleted字段,删除时标记而不是物理删除,增量查询带上这个字段; - Binlog 订阅:binlog 里能捕获 DELETE 事件,直接同步删除操作到 ES。
我强烈建议用软删除,不光是为了同步,数据恢复、审计都用得上。物理删除在生产环境里能少用就少用。
更新操作也有讲究。ES 的更新本质是"删旧文档 + 写新文档",不是原地修改。所以频繁更新同一个文档,会产生大量段(segment),触发频繁的段合并,影响性能。如果某个字段更新特别频繁(比如浏览量),建议不要放进 ES,或者单独存一个字段用 ES 的 update API 局部更新。
3.3 全量同步的限流与断点续传
首次全量同步,或者数据修复时的全量重建,一定要做限流和断点续传。
限流是因为:全量同步会瞬间给 ES 写入大量数据,如果不控制速率,ES 的写入队列会堆积,严重时触发写入拒绝(EsRejectedExecutionException),甚至影响线上查询。
断点续传是因为:全量同步动辄几小时,中途失败很常见。如果没有断点记录,每次失败都要从头再来,效率极低。
一个实用的做法是:按主键 ID 分片,每批处理比如 5000 条,记录最后成功处理的 ID。失败重启时从记录的 ID 继续。同时用令牌桶或信号量控制每秒写入的文档数,观察 ES 的写入延迟动态调整。
// 伪代码示意:分批 + 断点 + 限流 long lastId = loadCheckpoint(); int batchSize = 5000; while (true) { List<Article> batch = queryFromMySQL(lastId, batchSize); if (batch.isEmpty()) break; for (Article a : batch) { rateLimiter.acquire(); // 限流 bulkIndex(a); } lastId = batch.get(batch.size() - 1).getId(); saveCheckpoint(lastId); }4. 全文检索的实战配置与调优
4.1 索引映射设计:字段类型决定检索能力
ES 的索引映射(mapping)一旦建好,大部分字段类型就不能改了。所以建索引之前一定要想清楚每个字段怎么用。
以文章搜索为例,一个典型的 mapping 大概是这样:
{ "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "ik_max_word", "fields": { "keyword": { "type": "keyword" } } }, "content": { "type": "text", "analyzer": "ik_smart" }, "author": { "type": "keyword" }, "category": { "type": "keyword" }, "created_at": { "type": "date" }, "view_count": { "type": "integer" } } } }几个关键点:
- text vs keyword:text 会分词,用于全文检索;keyword 不分词,用于精确匹配、聚合、排序。title 同时建了两种,因为既要全文搜,又要按标题精确匹配或聚合。
- analyzer 选择:标题用 ik_max_word 提高召回,正文用 ik_smart 控制索引体积。
- 数值和日期字段:view_count 用 integer 而不是 text,这样能范围查询和排序;created_at 用 date,支持时间范围过滤。
注意:mapping 里字段类型定错了,后期改起来非常麻烦,往往要重建索引再 reindex。所以设计阶段多花点时间,比后期返工划算得多。
4.2 查询 DSL:match、term、bool 到底怎么选
ES 的查询 DSL 很丰富,新手最容易搞混的是 match 和 term。
- match:会分词,把查询词也走一遍分词器,然后去倒排索引里匹配。适合全文检索。
- term:不分词,精确匹配词项。适合 keyword 字段、数值、日期。
举个例子,搜"机器学习":
// match:会分词,能匹配到"机器学习""机器""学习" { "query": { "match": { "title": "机器学习" } } } // term:不分词,只有当 title 分词后恰好有"机器学习"这个词项才匹配 { "query": { "term": { "title": "机器学习" } } }实际查询往往是组合的,用 bool 把多个条件拼起来:
{ "query": { "bool": { "must": [ { "match": { "title": "机器学习" } } ], "filter": [ { "term": { "category": "技术" } }, { "range": { "created_at": { "gte": "2024-01-01" } } } ], "should": [ { "match": { "content": "算法" } } ], "minimum_should_match": 1 } } }这里有个重要的性能技巧:能用 filter 就别用 must。filter 不参与打分,结果可以被缓存;must 要算相关性分数,开销更大。像分类过滤、时间范围这种不影响相关性的条件,一律放 filter。
4.3 分页的深坑:from + size 为什么不能太大
ES 的分页用 from 和 size,但from + size超过 10000 会直接报错。这不是 bug,是设计使然。
原因在于 ES 是分布式的,一个查询要发到所有分片,每个分片都要返回from + size条结果,协调节点再合并排序。如果 from 是 10000,size 是 10,每个分片都要返回 10010 条,5 个分片就是 50050 条,内存和网络开销巨大。
解决办法有两个:
- search_after:用上一页最后一条的排序值作为游标,适合深度翻页,但不支持随机跳页;
- scroll:适合导出全量数据,会生成快照,不适合实时查询。
// search_after 示例 { "size": 10, "query": { "match": { "title": "机器学习" } }, "sort": [ { "created_at": "desc" }, { "id": "desc" } ], "search_after": [1704067200000, 12345] }提示:search_after 的排序字段必须唯一,否则可能漏数据或重复。通常用"时间 + ID"组合排序。
4.4 分词器调优:自定义词典解决业务词
IK 分词器自带词典,但业务里的专有名词它不一定认识。比如"云原生""大模型""向量数据库"这些词,早期版本的 IK 可能切得乱七八糟。
解决办法是配置自定义词典。IK 支持扩展词典和停用词典:
- 扩展词典:把业务专有名词加进去,让 IK 认识它们;
- 停用词典:把"的""了""是"这类无意义词去掉,减小索引体积。
配置方式是在 IK 的 config 目录下建custom.dic,每行一个词,然后在IKAnalyzer.cfg.xml里引用。改完要重启 ES 或者热更新(IK 支持远程词典热更新)。
我踩过的一个坑是:自定义词典里的词如果包含特殊字符或者编码不对,会导致整个词典加载失败,分词退化成单字切分。所以加完词一定要用_analyzeAPI 验证一下:
POST /_analyze { "analyzer": "ik_max_word", "text": "云原生向量数据库" }看返回的分词结果对不对,不对就检查词典文件。
5. 混合检索:当全文检索不够用时
5.1 什么是混合检索,为什么需要它
纯全文检索有个天然短板:它只懂"字面匹配",不懂"语义"。用户搜"怎么提升搜索准确率",如果文档里写的是"如何提高检索精度",字面不匹配,全文检索就搜不到,但语义上它们是同一个意思。
混合检索(Hybrid Search)就是把多种检索信号融合起来,通常包括:
- 全文检索(关键词匹配):基于倒排索引,精确、可解释,但对同义词、语义变体无能为力;
- 向量检索(语义匹配):把文本转成向量,用向量相似度找语义相近的文档,能处理同义表达,但可能召回不精确的结果;
- 结构化过滤:分类、时间、标签等硬条件。
把这几路结果融合,既能保证精确性,又能提升召回和语义理解能力。这就是混合检索的价值。
5.2 向量检索的基本原理
向量检索的核心是文本向量化(embedding)。用一个模型把文本编码成一个高维向量(比如 768 维),语义相近的文本,向量在空间里的距离也近。
检索时,把查询也编码成向量,然后在向量库里找距离最近的 Top-K 个文档。距离度量常用余弦相似度或欧氏距离。
ES 从 7.x 开始支持dense_vector字段类型,8.x 之后原生支持 kNN 检索:
{ "mappings": { "properties": { "title_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" } } } }查询时用 kNN:
{ "knn": { "field": "title_vector", "query_vector": [0.1, 0.2, ...], "k": 10, "num_candidates": 100 } }num_candidates是每个分片预选的候选数,越大越准但越慢。一般设成 k 的 10 倍左右起步,再根据效果调。
5.3 分数融合:RRF 和加权求和怎么选
全文检索和向量检索的分数不在一个量纲上,不能直接相加。常见的融合方式有两种:
加权求和:把两路分数归一化后按权重相加。
final_score = α * normalize(bm25_score) + (1 - α) * normalize(vector_score)优点是直观可控,缺点是归一化方式对结果影响大,而且权重需要反复调。
RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数,只看排名。
RRF_score(d) = Σ 1 / (k + rank_i(d))其中 k 是常数(通常取 60),rank_i(d) 是文档 d 在第 i 路结果里的排名。RRF 的好处是不用归一化,对分数尺度不敏感,鲁棒性强。缺点是丢失了分数信息,只用了排名。
我自己的经验是:两路检索质量差不多时用 RRF,简单省心;如果某一路明显更可靠,用加权求和,给可靠的那路更高权重。
5.4 一个可落地的混合检索流程
把上面的东西串起来,一个完整的混合检索流程大概是这样:
- 查询预处理:对用户输入做分词、同义词扩展、拼写纠错;
- 并行发起两路检索:全文检索走 match 查询,向量检索走 kNN;
- 结构化过滤:两路都带上 filter 条件(分类、时间等);
- 分数融合:用 RRF 或加权求和合并结果;
- 重排序(可选):用更精细的模型对 Top-N 结果重排;
- 返回结果:带上高亮、分页信息。
// 8.x 支持在 search 里同时写 query 和 knn,ES 会自动做融合 { "query": { "match": { "title": "搜索准确率" } }, "knn": { "field": "title_vector", "query_vector": [...], "k": 10, "num_candidates": 100 }, "size": 10 }ES 8.x 之后,query 和 knn 可以写在同一个请求里,底层用 RRF 融合。这大大简化了混合检索的实现,不用自己在应用层做融合了。
6. 性能与踩坑:那些文档里不会写的事
6.1 段合并:ES 写入性能的隐形杀手
ES 的索引是不可变的,每次写入其实是生成一个新的段(segment),查询时要扫所有段。段太多,查询就慢。所以 ES 后台会不断做段合并(merge),把多个小段合并成大段。
段合并本身很耗 IO 和 CPU。如果写入量很大,段合并跟不上,就会出现写入延迟飙升。几个应对经验:
- 控制写入速率:用 bulk 批量写,每批 5-15MB 比较合适,太大反而容易触发拒绝;
- 调整 refresh_interval:默认 1 秒刷新一次,写入密集时可以调大到 30 秒,减少段生成;
- 用 bulk 而不是单条写:单条写的开销是批量的几十倍。
PUT /articles/_settings { "index": { "refresh_interval": "30s", "number_of_replicas": 1 } }注意:refresh_interval 调大后,新写入的数据要等更久才能被搜到。如果业务对实时性要求高,就别调太大。
6.2 分片设计:不是越多越好
新手容易犯的错是分片设太多,觉得分片多就快。实际上分片过多会带来:
- 每个分片都是一个 Lucene 索引,有固定开销;
- 查询要发到所有分片,分片越多,协调节点合并结果的负担越重;
- 集群元数据膨胀,主节点压力大。
经验法则:单个分片大小控制在 10GB 到 50GB 之间。假设你的数据总量 100GB,那 3 到 5 个主分片就够了。分片数在索引创建时定好,后期改要 reindex,所以一开始就要估算好数据增长。
副本数则看查询压力和可用性要求。副本能分担查询,但会占用额外存储,而且写入时要同步到副本,影响写入性能。一般 1 到 2 个副本。
6.3 中文搜索的精度问题
中文搜索经常遇到"搜不准"的问题,原因往往在分词。几个典型场景:
- 专有名词被切碎:比如"鸿蒙系统"被切成"鸿蒙""系统",搜"鸿蒙系统"能搜到,但搜"鸿蒙"会召回一堆不相关的;
- 同义词搜不到:搜"手机"搜不到只写了"移动电话"的文档;
- 停用词处理不当:把"的"去掉后,"我的世界"变成"我世界",语义变了。
解决办法:
- 专有名词加自定义词典;
- 同义词用 synonym 过滤器配置;
- 停用词要谨慎,中文里很多单字停用词去掉后会影响语义。
同义词配置示例:
{ "settings": { "analysis": { "filter": { "my_synonym": { "type": "synonym", "synonyms": [ "手机,移动电话", "电脑,计算机,PC" ] } }, "analyzer": { "my_analyzer": { "tokenizer": "ik_max_word", "filter": ["my_synonym"] } } } } }6.4 监控指标:哪些数字必须盯
ES 上线后,有几个指标必须持续监控,出问题能第一时间发现:
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 查询延迟 P99 | 99% 查询的耗时 | 持续上升说明查询变重或资源不足 |
| 写入拒绝数 | 被拒绝的写入请求 | 大于 0 说明写入队列满了 |
| 段数量 | 索引的段总数 | 持续增长说明合并跟不上 |
| JVM 堆使用率 | 堆内存占用 | 超过 75% 要警惕 GC 问题 |
| 磁盘使用率 | 数据盘占用 | 超过 85% 要扩容 |
| 线程池队列 | 各线程池排队数 | 队列满说明处理不过来 |
其中 JVM 堆和段数量是最容易被忽视的。堆用满会触发长时间 GC,查询直接卡死;段数量失控会让查询越来越慢。建议给这两个指标设告警。
7. 写在最后的一点个人体会
从 MySQL 的 LIKE 走到 ES 的倒排索引,再走到混合检索,这条路我走了好几年,踩过的坑比上面写的多得多。如果让我给正在做搜索功能的朋友几句实在话:
第一,别一上来就上 ES。数据量不到百万、搜索需求简单的,MySQL 的 LIKE 加个缓存可能就够了。ES 的运维成本不低,集群、分片、段合并、JVM 调优,每一项都要投入精力。技术选型要看投入产出比,不是越先进越好。
第二,同步方案先想清楚一致性要求。很多团队一上来就追求实时同步,结果维护成本高得吓人。其实大部分业务能接受分钟级延迟,定时增量同步简单又可靠。真需要实时的,再上 Binlog 方案。
第三,混合检索不是银弹。向量检索能提升语义召回,但也会引入噪声,而且 embedding 模型的选型、向量维度的确定、融合权重的调整,都需要反复实验。先把纯全文检索做好,再考虑加向量。
第四,监控和压测要提前做。ES 的问题往往在数据量涨上来之后才暴露,等线上出问题再排查就晚了。上线前用真实数据量压测,把查询延迟、写入拒绝、段数量这些指标摸清楚,心里才有底。
搜索这件事,没有一劳永逸的方案,只有不断根据业务调整的平衡。希望这篇内容能帮你少踩几个坑,把搜索做得更稳一点。