今天上午刷到掘金热榜那篇《从 LIKE 到倒排索引:MySQL 搜不动的内容,ES 是怎么接住的》,写得挺清楚:LIKE 从第一个字符开始扫全表,数据量一大就完蛋,ES 用倒排索引把这个事接住了。 道理我都懂。但这个论证让我想起上周组里那个争论:产品要在管理后台加个"按内容搜工单",数据量预估五十万条,组里有人说"这还不简单,上ES",另一个说"为个搜索框再养一套集群?"。争了半天谁也没拿数据出来。
我正好有台1核2G的破机器,索性把这个问题从头到尾量了一遍:LIKE到底多慢、MySQL自带的ngram全文索引能顶到哪、什么时候才真的非ES不可。结果里有两个数据跟我预想的完全相反。
先测LIKE:慢,但没慢在你以为的地方
造数据的脚本很简单,50万条"技术文章",标题加正文,正文一百多字,往里面按比例埋了几个关键词:缓存、数据库连接池、消息队列,还有专门用来捣乱的数据结构和数仓(原因后面说)。脚本贴在文末,可复现。然后用三档数据量(1万、10万、50万)测 body 的 LIKE '%关键词%',每个查询跑3次取中位。为了让结果可比,用 WHERE id <= N 限定扫描范围,近似"数据量只有N的表":
规模 | 查询词 | COUNT耗时 | 命中数
10000 | 无命中词 | 16ms | 0
100000 | 无命中词 | 169ms | 0
500000 | 无命中词 | 886ms | 0
500000 | 缓存 | 684ms | 253889
无命中词 是个语料里根本不存在的词,它代表最坏情况:既然一行都搜不到,就得把全表扫完才能死心。886ms,在1核机上,50万行就是这个价。 但接下来这个数据打破了我的预期。同样是50万行,搜缓存这种命中25万条的高频词,取前20条的分页查询,耗时0ms。
规模 | 查询 | COUNT耗时 | LIMIT 20耗时 | 命中数
500000 | 缓存 | 684ms | 0ms | 253889
500000 | 消息队列 | 785ms | 0ms | 159020
原因不复杂:LIMIT 20 凑够行数就提前收工,命中率高的时候扫几百行就够了。慢的是"搜了个稀罕玩意"------命中稀少甚至没有时,每次查询都得扫到最后一行。后台管理系统里,用户输错字、搜一个不存在的单号,恰恰是高频操作。 所以"从第一个字符开始扫"没错,但完整的故事是:扫全表不可怕,可怕的是每次都要扫到底
上全文索引之前,先看看代价
MySQL 内置的 ngram 全文解析器,就是给中日韩这种没空格分词的语言准备的:把文本切成连续两个字一组的片段建倒排。它从 5.7.6 就有,WITH PARSER ngram 一个词缀就能开,写在 MySQL 8.0 手册 14.9.8 节,不是野路子特性。建索引之前我以为就是加个索引的事,代价主要体现在两个地方:最直观的是速度:不是秒级操作。我在一张10万行的表上实测,加 FULLTEXT(body) WITH PARSER ngram 花了 52.8 秒。1核机,50万行表换算下来四五分钟起步,这期间写入还得排队。还有个更隐蔽的:加了索引之后查表体积,data_length 从 213MB 涨到了 311MB。查了资料才明白,InnoDB 会自动给表补一个隐藏的 FTS_DOC_ID 列,等于把聚簇索引整个重建了一遍。生产大表上做这个操作,得当成一次重型变更来评审。
快是真快,坑也是真坑
索引建完先看速度,这部分符合预期:
scss
查询(50万行) | LIKE | 全文索引-自然语言 | 全文索引-布尔模式
无命中词 | 886ms | 0ms | 0ms
缓存(2字) | 684ms | 150ms | 215ms
消息队列(4字) | 785ms | 323ms | 1710ms
无命中词从 886ms 直接归零,2字词快4倍,EXPLAIN 里 type=fulltext 替换了 type=ALL。到这里一切正常,如果文章在这就结束,结论就是"装个全文索引解决了"。 但坑是从这里开始的。
布尔模式搜长词,比LIKE还慢。 上表最后一行,搜4字的消息队列,布尔模式1710ms,是LIKE(785ms)的2.2倍,也是自然语言模式(323ms)的5倍多。一开始我以为测错了,反复跑了几遍,稳定复现。翻文档和源码注释才对上号:布尔模式下的中文查询会被转成ngram短语搜索------拿消息队列举例,引擎先从倒排里捞出含消息、息队、队列这些片段的所有文档,再逐个校验这些片段在原文里是不是连续出现。50万行里16万条命中,每条都要取回位置信息做连续性校验,还是单核。这一套做完,比全表扫一遍文本还贵。
这还没完。我的实验机只有2G内存,跑到一个更长的查询词时,mysqld直接被系统的OOM killer杀了两次,dmesg里赫然写着 Out of memory: Killed process (mysqld)。长词的布尔短语搜索,是在拿内存换精确度。2G内存的机器上,这是个能打死数据库的操作。
自然语言模式的相关性,也会给你惊喜。 MySQL的全文搜索默认是自然语言模式,它对查询词的处理方式是切成ngram片段后取并集------官方文档原话是"the search term is converted to a union of ngram terms"。并集意味着什么,我用数据库连接池实测了一下:
makefile
LIKE 精确子串命中: 196892
布尔模式(短语语义)命中: 196892 ← 和LIKE完全一致
自然语言模式(并集)命中: 379058 ← 虚高92%
同一张表,自然语言模式比精确命中多出18万条。我统计了一下这37.9万条里有多少条正文根本不含完整的数据库连接池:182166条,占48% 。数据结构梳理、数仓建模这些埋进去的捣乱词,全被数据、库、结构这些片段捞了进来。
也就是说,用默认模式做搜索框,用户搜"数据库连接池",将近一半的结果是"沾边但不是"的内容。排序上相关文档的分数确实更高(完整命中的文档分数3.2,只有片段命中的在1上下),把结果限制在前几页问题不大。但一旦用户翻页,或者按score做了个不严格的过滤,体验就开始劣化。
顺带一个更冷门的坑:单字查询直接失效 。搜缓一个字:
sql
LIKE '%缓%' 命中 253889
MATCH AGAINST('缓') 命中 0
ngram_token_size=2 时索引里根本没有单字token,一个字都搜不到。而ngram_token_size是个只读参数,改它要改配置重启,还会影响库里所有ngram索引。如果业务里有"搜姓"这种需求------比如在通讯录里搜张------这个方案直接出局。
所以,到底什么时候才需要ES
把数据摊开,我的结论是这样的:如果你的场景是后台系统的内容搜索框、几十万行、能接受"整词/多字词"检索,MySQL的ngram全文索引+布尔模式完全够用。记得两条纪律:查询词必须套双引号走布尔模式(它和LIKE的精确结果是严格等价的,我实测19.7万条一条不差),模式别用默认的自然语言模式,除非你能接受48%的误召回。
但要满足下面任何一条,别犹豫,上ES:搜索框是产品主路径、高频长词查询(布尔模式的短语校验成本会反噬你)、需要单字检索、需要真正的中文分词相关性排序、要聚合和高亮。ES解决的问题不只是快,是这套检索能力的完整度。回到开头组里那个争论,现在有了答案:五十万条的工单搜索框,一个全文索引就够了。但我也得承认,这次测试的语料是模板造的,词频分布比真实工单规整,真到生产上,建议照这个思路用自己的一小撮真实数据再量一遍------附录的代码就是按这个路子写的,换个数据源就能跑。
附录:核心实验代码
生成语料(节选,关键词按概率植入):
python
IMPLANTS = { # 关键词 -> 植入概率
"数据库连接池": 0.05,
"数据结构梳理": 0.03, # 干扰:含"数据"
"数仓建模": 0.02, # 干扰:含"数"
"缓存穿透": 0.08,
"消息队列积压": 0.03,
}
def gen_doc(rng):
kw = rng.choices(list(IMPLANTS.keys()), weights=IMPLANTS.values())[0]
# ...模板拼句,每条正文必含一个植入词
建索引与查询:
sql
-- 建索引(InnoDB,10万行实测52.8s,注意data_length会涨:聚簇被重建)
ALTER TABLE articles ADD FULLTEXT INDEX ft_body_ng (body) WITH PARSER ngram;
-- 布尔模式:与 LIKE 精确等价(实测 196892 = 196892)
SELECT COUNT(*) FROM articles
WHERE MATCH(body) AGAINST('"数据库连接池"' IN BOOLEAN MODE);
-- 自然语言模式(默认):并集语义,误召回48%,慎用于搜索框
SELECT title, MATCH(body) AGAINST('数据库连接池') AS score
FROM articles WHERE MATCH(body) AGAINST('数据库连接池')
ORDER BY score DESC LIMIT 20;