Elasticsearch

倒排索引:为什么 ES 检索比 LIKE 快几个数量级

数万级 SKU 上 Elasticsearch,真正的原因不是 MySQL 扛不住数据量,而是「分词检索」这件事 MySQL 没有索引结构支持。这篇从 LIKE 的代价算起,讲清倒排索引的三层结构与它快在哪。

2026-02-17 约 9 分钟 3528 字 Elasticsearch 倒排索引 全文检索 MySQL

一、先把结论说反一次

五万条商品数据放进 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 查询的执行流程:

  1. 用查询分析器把「洗地机」切成 term
  2. 拿 term 去词典里查找,得到 posting list
  3. 多个 term 时做归并,求交集或并集
  4. 按 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_* 四个变量后自动开启。