倒排索引:为什么 ES 检索比 LIKE 快几个数量级
数万级 SKU 上 Elasticsearch,真正的原因不是 MySQL 扛不住数据量,而是「分词检索」这件事 MySQL 没有索引结构支持。这篇从 LIKE 的代价算起,讲清倒排索引的三层结构与它快在哪。
一、先把结论说反一次
五万条商品数据放进 MySQL,随便一条主键查询都在毫秒级,全表扫描也不过几十毫秒——这个量级对 MySQL 完全不构成压力。
但同一批数据,只要搜索框里输入「洗地机」,用 LIKE 写出来的查询就会慢到不可接受。
很多人由此得出「数据量太大了,得上 ES」,这个结论是错的。五万行在数据库领域属于很小的表。真正的区别在于:LIKE '%关键词%' 做的是逐字符比对,而检索需要的是按词查找——后者 MySQL 没有索引结构支持。
二、LIKE '%躺平%' 为什么走不了索引
B+ 树索引能加速查询,前提是数据有序,而且这个有序性是从字段开头算起的。所以:
| 写法 | 能否用索引 | 原因 |
|---|---|---|
WHERE title = '躺平洗地' |
能 | 有确定的值,可以在树上定位 |
WHERE title LIKE '躺平%' |
能 | 有确定的前缀,退化成范围查询 |
WHERE title LIKE '%躺平%' |
不能 | 没有确定前缀,无法定位到任何一段有序区间 |
WHERE title LIKE '%躺平' |
不能 | 同上 |
前置通配符的代价是全表扫描,而且比普通全表扫描更贵:优化器不知道匹配会出现在哪个位置,只能从每一行标题的每一个起始位置开始尝试匹配。
按五万条商品、标题平均 25 个字估算:
| 代价 | |
|---|---|
| MySQL 全表扫描 | 5 万 SKU × 平均 25 字 ≈ 125 万次字符比较,且每个起始位置都要试 |
| ES 倒排索引 | 1 次词典查找,直接拿到 posting list |
两者差了六个数量级,而且这个差距会随着数据量增长继续拉大。
三、倒排索引:把「文档 → 内容」翻过来
普通数据库的存储思路是「文档 → 内容」:给一个主键,读出它的内容。要反过来问「哪些文档包含某个词」,就只能挨个文档看一遍。
倒排索引把这个方向翻了过来,建的是一张「词 → 文档列表」的表:
| term | posting list |
|---|---|
| 洗地机 | [1, 2, 3, 10] |
| 躺平 | [1, 2, 3, 10, 12] |
| 吹风机 | [4, 5, 11] |
查「躺平」就是一次词典查找,直接拿到 [1, 2, 3, 10, 12]。
比较次数与文档总量无关,只与命中数有关。
这句话是理解 ES 性能的关键。文档从五万涨到五百万,词典的大小增长远慢于文档数,一次词典查找的代价几乎不变;而 LIKE 的 125 万次比较会变成 1.25 亿次。
四、倒排索引内部长什么样
倒排索引不是一张简单的哈希表,Lucene 把它拆成了三层:
| 层 | 数据结构 | 解决什么问题 |
|---|---|---|
| term index | FST(有限状态转换器),前缀共享 | 让整个词典不必全部读进内存,也能快速定位 |
| term dictionary | 按字典序排列的 term,分块存储 | 二分查找定位到具体 term |
| posting list | 有序的 doc id 列表 + 跳表 | 多个词求交并集时可以归并,跳过不可能命中的块 |
两个设计细节值得展开:
第一,term 按字典序排序。 求「洗地机 AND 躺平」时,两个倒排表都是有序的,可以像归并排序那样线性扫描求交集。如果 term 是无序存放的,就只能对每个文档逐一判断,复杂度完全不同。
第二,doc id 也有序,并且带跳表。 假设「洗地机」命中 1 万篇、「躺平」只命中 10 篇,求交集时不需要遍历那 1 万条:可以从短表出发,在长表里用跳表跳着找,比较次数接近 10 次而不是 10000 次。
还有一个工程上的取舍:posting list 里存的是 doc id 增量(delta)并做压缩,所以列表既能顺序读,又能随机跳——这是在磁盘空间和查询速度之间的折中。
五、中文为什么必须先分词
英文天生有空格作为分隔符,切词几乎不需要额外工作。中文没有分隔符,「洗地机」对人是三个字一个词,对一个不认识它的分词器来说,就是「洗」「地」「机」三个独立的字。
所以 ES 在写索引之前要先过一遍分析器(analyzer),它由三部分组成:
| 组件 | 作用 | 例子 |
|---|---|---|
| character filter | 字符层面的预处理 | 去 HTML 标签、全角转半角 |
| tokenizer | 把文本切成词 | ik_smart、ik_max_word、standard |
| token filter | 对词做二次加工 | 小写化、去停用词、同义词替换 |
分词粒度直接决定了倒排表里有哪些 term,也就直接决定了「用户搜的词能不能命中」。可以先看一眼实际切出来的结果:
GET /sku/_analyze
{
"analyzer": "ik_smart",
"text": "洗地机 X1 躺平洗地 全屋清洁"
}
如果切出来的词里没有「洗地机」,那么用户搜「洗地机」时就永远匹配不上——倒排索引再快也没用,因为要查的 term 根本不在词典里。
六、一次检索实际发生了什么
GET /sku/_search
{
"query": {
"match": {
"title": "洗地机"
}
}
}
match 查询的执行流程:
- 用查询分析器把「洗地机」切成 term
- 拿 term 去词典里查找,得到 posting list
- 多个 term 时做归并,求交集或并集
- 按 BM25 打分排序,返回 Top N
要注意第 1 步:查询侧也要分词。这就是「索引分词器和查询分词器必须配套」的原因——两边切出来的 term 对不上,倒排表里就算有数据也命中不了。
对比一下 term 查询:
GET /sku/_search
{
"query": {
"term": {
"brand": "品牌A"
}
}
}
term 不分词,直接拿原值去查词典,所以它只适合 keyword 类型的精确字段。用 term 去查 text 字段的「洗地机」,会拿整个「洗地机」当 term 去查,只有恰好存在这个整词时才命中。
七、那 MySQL 的全文索引呢
需要公平地说一句:InnoDB 从 5.7 起支持 ngram 全文索引,也能给中文建倒排结构,不是完全做不到。差异在于能力边界:
| MySQL 全文索引(ngram) | Elasticsearch | |
|---|---|---|
| 分词 | 固定 n-gram(默认 2 字),不能挂自定义词典 | 可插拔分词器,支持自定义词典与热更新 |
| 相关性排序 | 可调空间小 | BM25,可加权、可用 function_score 干预 |
| 多维筛选 | 全文条件与普通条件混用时,优化器选择有限 | filter context 与 query context 语义分离 |
| 聚合统计 | 每个维度一条 GROUP BY |
aggregation 原生支持,可一次返回多个维度 |
| 运维 | 与业务库同生共死 | 独立集群,需要同步与一致性方案 |
MySQL 全文索引能解决「能不能搜到」,但解决不了「搜得准不准」「筛选面板怎么统计」「排序怎么干预」。
八、什么情况下真的该上 ES
| 判断维度 | MySQL 够用 | 该上 ES |
|---|---|---|
| 检索形态 | 等值、范围、前缀匹配、精确 LIKE |
中文分词检索、相关性排序 |
| 筛选条件 | 一到两个固定条件 | 品牌、价格、参数等多维动态组合 |
| 统计需求 | 少量固定统计 | 筛选后重算各维度数量 |
| 数据量 | 数万到数十万行 | 不是决定因素 |
对数万级 SKU 来说,「数据量大到 MySQL 扛不住」是个伪命题。真正的理由是:分词检索这件事,MySQL 没有对应的索引结构。
上 ES 也不是零成本,要额外处理数据同步与最终一致、双份存储、索引重建、集群运维。如果需求只是「按商品名精确查一条」,加一条普通索引就够了,引入 ES 反而是在给自己挖坑。
小结
LIKE '%关键词%'走不了 B+ 树索引,因为前置通配符没有确定前缀,只能全表扫描,且每个起始位置都要试- 五万 SKU × 平均 25 字 ≈ 125 万次字符比较,这是
LIKE的代价量级 - 倒排索引把结构翻过来:
term → posting list,一次词典查找直接拿到命中列表,比较次数与文档总量无关,只与命中数有关 - 索引分三层:term index(FST 前缀共享)+ term dictionary(有序词典)+ posting list(有序 doc id 与跳表)
- term 有序让多个词的倒排表可以归并求交,doc id 有序让短表可以跳着扫长表
- 中文必须先分词,分词粒度决定倒排表里有哪些 term;索引分词器与查询分词器必须配套
match会分词,term不会;后者只适合keyword精确字段- 数据量大不是上 ES 的理由,检索形态才是;判断标准是「有没有分词检索、多维筛选、聚合统计」这三类需求
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。