看完《MySQL必知必会》第18章之后,我才发现自己之前写搜索功能的方式有多笨。早期做内容管理类的模拟项目时,只要输入框一开,第一反应就是在SQL里写LIKE '%关键词%',结果数据量一到几十万行,页面直接卡到让人崩溃。实际上MySQL早就提供了专门干这件事的机制,也就是基于Match()和Against()的全文本搜索(FULLTEXT Search)。这章内容不复杂,但如果你只是把语法背下来、直接抄进项目里,很快会踩到短词搜不到、中文分词奇怪、相关度排序不符合预期这一类坑。这篇文章就把我读这章时的理解、实验过程以及后来在项目里实际用起来的经验一次性说透。
1. 为什么我放弃 LIKE,改用 Match() 和 Against()
1.1 LIKE 通配符的三大软肋
先聊聊大多数人一开始都会用的LIKE '%xxx%'。它的逻辑非常直观:目标字段里只要任意位置包含关键字,就让这行数据进结果集。方便是方便,但放到真实场景里,它有三个硬伤。
第一个硬伤是索引失效。如果你在字段上建了普通B+树索引,那么LIKE 'rabbit%'这种前方一致的模式还能勉强走索引,可一旦写成LIKE '%rabbit%',通配符把字符串开头锁死了,优化器只能老老实实全表扫描。表只有几千行时感受不明显,上了生产环境后查询耗时呈线性上涨,这是最直接的痛点。
第二个硬伤是没有相关度排序。LIKE只回答“哪几行包含这个字符串”,完全不回答“哪一行跟搜索词最相关”。你搜“MySQL”,可能某篇文章只在文末提了一次,排在第一位;另一篇通篇在讲MySQL核心机制,反而排在后面。对用户体验来说,这种结果基本等于不可用。
第三个硬伤是词形和同义处理无能为力。比如你想搜“run”,但文章里写的是“running”,用LIKE '%run%'倒是能误命中一堆包含字符串的噪声,用LIKE '%running%'又漏掉了所有只写“run”的内容。全文搜索在这方面的处理思路完全不同,它分词、算词频、算权重,而不是死板地比对子串。
1.2 全文本搜索到底解决什么问题
MySQL的FULLTEXT索引不是用来做精确匹配的,它服务的是“从一堆自然语言文本里找出相关文档”这个需求。它做的事情可以拆成三步:
- 对文本字段做分词,记录每个词出现在哪一行、出现了多少次、在文档中的位置;
- 计算每行的相关性分数,核心因素是词频(TF)、词在文档中的位置、词在整个索引中的罕见程度(IDF);
- 按相关性分数从高到低返回结果。
这就是Match()...Against()的价值。它跟LIKE最大的差别在于:LIKE是“字符串包含”的硬匹配思维,FULLTEXT是“语义相关度”的软匹配思维。书中有一句值得划线的话,大意是:全文本搜索不是新功能,而是MySQL内置的一套简易搜索引擎。
我在一个文章系统里做过对比测试:同样搜索“fulltext”这个关键词,LIKE版耗时约2.3秒,全表扫描;MATCH...AGAINST版耗时约0.08秒,并且相关性最高的那条记录排在第一。数据量只有不到10万行,差距已经接近30倍。这就是我彻底倒戈的原因。
2. 先建 FULLTEXT 索引,一切搜索才有意义
用Match()可不是上来就能查的。Against()只是查询端的接口,真正干活的是底层的全文索引。如果目标字段没有对应的FULLTEXT索引,MySQL会直接报错,语句一秒都跑不了。
2.1 三种建索引方式和引擎选择
建FULLTEXT索引有三种手段,效果等价,看你的使用习惯:
-- 方式一:建表时直接定义全文索引 CREATE TABLE productnotes ( id INT NOT NULL AUTO_INCREMENT, note_text TEXT NOT NULL, PRIMARY KEY(id), FULLTEXT(note_text) ) ENGINE=InnoDB; -- 方式二:表已经存在,用ALTER TABLE追加 ALTER TABLE productnotes ADD FULLTEXT(note_text); -- 方式三:使用CREATE INDEX语法,语义更明确 CREATE FULLTEXT INDEX ft_note_text ON productnotes(note_text);这里我要多说一句引擎选择。别再用MyISAM了,虽然MyISAM比InnoDB更早支持全文本搜索,但它的表级锁和崩溃恢复能力在业务系统里太吃亏。MySQL 5.6以后InnoDB已经完整支持FULLTEXT索引,包括布尔搜索、查询扩展这些高级操作也都支持了。现在的项目直接上InnoDB,性能和一致性都更省心。
FULLTEXT索引有个特点:它属于倒排索引结构,存储的是“关键词 -> 包含它的文档列表”,跟普通B+树索引完全两个思路。所以你在设计字段时不用纠结“是不是前缀匹配”,只要能分词,任何位置的词都能被命中。
2.2 中文内容必须用 ngram 解析器
这是我在这个章节里吃到的最大教训。MySQL默认的全文分词器对英文、法文这类按空格和标点切分的语言非常友好,但对中文几乎等于失灵——因为中文词与词之间没有空格,默认解析器会把整句话当成一个连续字符串,搜长句可能整句命中,搜其中一两个关键字就完全没戏。
解决办法是给索引指定WITH PARSER ngram,让MySQL按ngram_token_size拆成连续的n个字符序列:
CREATE TABLE articles ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200), body TEXT, FULLTEXT(title, body) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 或者给现有表追加 ALTER TABLE articles ADD FULLTEXT INDEX ft_body (body) WITH PARSER ngram;ngram_token_size默认是2,也就是按“二元组”切词。比如句子“MySQL必知必会”,默认解析器切出来的token是:“my”、“sq”、“ql”、“必知”、“知必”、“必会”。搜“必知必会”时可以匹配到“必知”和“必会”两个token,相关性还不低。你需要明白的是:ngram不是真正的语义分词,它只是用统计方式让MySQL“能切出东西”,对中文场景已经足够可用。如果业务要求精确的中文分词,那就得考虑专业搜索引擎了,这个后面再说。
2.3 停用词与最小词长:默认配置的两个暗坑
MySQL对全文本搜索有一组默认限制,和业务关系很大,书里提到了基础概念,但展开得不多,我补充一下。
坑点一:停用词(Stopword)。MySQL默认有一个停用词表,像“a”“an”“the”“is”“of”这类高频无意义词,在全文本搜索时会被直接忽略。如果是英文文章系统,这个机制问题不大;但如果你存的是技术文档,里面频繁出现“int”“for”“use”这种短小但有业务含义的词,一旦它们被停用词表命中,搜索结果就会莫名其妙少东西。
InnoDB在MySQL 8.0里默认使用的是内置停用词表,可以通过INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD来查。如果想覆盖这套默认行为,可以配置innodb_ft_server_stopword_table指向自己的停用词表,但这属于运维层面的活儿,业务系统一般没必要动。
坑点二:最小词长(Min Token Length)。InnoDB的innodb_ft_min_token_size默认是3,MyISAM的ft_min_word_len默认是4。意味着长度小于这个值的单词根本不会进索引,也就搜不到。英文里的“go”“do”“SQL”这种两个或三个字符的词,默认配置下检索为空。如果你确实需要搜短词,修改配置后要重建全文索引才生效,别指望改完重启就自动好用:
# my.cnf 或 my.ini 中的示例配置 [mysqld] innodb_ft_min_token_size=2有一点必须提醒:配置修改要重启MySQL服务,而且已有索引得ALTER TABLE ... DROP INDEX ...再ADD FULLTEXT INDEX,否则新配置不会作用于旧索引。
3. Match() 和 Against() 的基本用法:自然语言模式
3.1 语法与“相关性分数”是怎么算的
自然语言模式(IN NATURAL LANGUAGE MODE)是Against()的默认模式,语法长这样:
SELECT note_text FROM productnotes WHERE MATCH(note_text) AGAINST('rabbit');MATCH()括号里写的是你要搜的字段,AGAINST()里写的是搜索关键词。这个函数返回的结果本身就带一个隐藏的相关性分数,MySQL内部用这个分数排序,所以你没写ORDER BY,结果也是按相关度从高到低出来的。
关于这个分数,我想用三个词帮你建立直觉:词频、位置、稀有度。
- 词频好理解,同一篇文档里关键词出现次数越多,分数越高;
- 位置指的是词在文档中出现得越靠前,分数加成越大;
- 稀有度是反常识的一点:如果某个词在每个文档里都出现,它的区分度就低,权重反而被压低;如果是生僻词,只要出现一次就说明这篇文档大概率跟搜索意图强相关。
MySQL计算相关性不是完全公开的公式,但整体逻辑就是TF(词频)和IDF(逆文档频率)的合成。不用死记公式,你只要记住一个结论:文档越短、关键词越集中,分数越高;关键词越罕见,区分度越高。
3.2 MATCH列列表必须严格匹配索引列
这个约束书里写得很明白,但我相信很多人会在这里翻车:MATCH()里的列列表必须和FULLTEXT索引定义时的列列表完全一致。
如果你的索引是这么建的:
ALTER TABLE productnotes ADD FULLTEXT(note_text, prod_name);那查询时写WHERE MATCH(note_text) AGAINST('rabbit')会直接报错。要么两边都写全:
WHERE MATCH(note_text, prod_name) AGAINST('rabbit');要么给单列单独建一个全文索引,然后保持MATCH()内部跟索引列完全对应。这个限制的原因是:全文索引是按整组列构建倒排索引的,MySQL只能在索引的粒度上做匹配,不能只取其中一列来参与计算。
3.3 一个能跑通的最小示例
我建议你在本地建一个临时表试验一下,把概念彻底跑通:
CREATE TABLE productnotes ( id INT NOT NULL AUTO_INCREMENT, note_text TEXT NOT NULL, PRIMARY KEY(id), FULLTEXT(note_text) ) ENGINE=InnoDB; INSERT INTO productnotes (note_text) VALUES ('Customer prefers fast shipping'), ('The rabbit is a clever animal'), ('Heavy duty rabbit cage for outdoor use'), ('This product has no warranty for damage'); SELECT id, note_text FROM productnotes WHERE MATCH(note_text) AGAINST('rabbit');这个查询只会返回第二、三行,因为只有这两行的文本分词后包含“rabbit”这个词。第一行里虽然有“fast”,但跟“rabbit”无关,不会被误判。这就是分词和子串匹配的本质区别。
如果你想看具体的相关性分数,可以把MATCH()挪到SELECT列表里:
SELECT id, note_text, MATCH(note_text) AGAINST('rabbit') AS relevance FROM productnotes WHERE MATCH(note_text) AGAINST('rabbit') ORDER BY relevance DESC;这样写完全合法,但要注意在WHERE里也保留了同样的MATCH...AGAINST,否则你只能在所有行上计算分数然后手动过滤,性能差很多。
4. 查询扩展:让“数据库”也能命中“关系型系统”
4.1 WITH QUERY EXPANSION 的两阶段原理
《MySQL必知必会》里我最感兴趣的就是查询扩展(Query Expansion),因为它是全文搜索里少见的“自动放大搜索意图”的设计。平时我们搜关键词,MySQL只去匹配字段里真实出现的词,词没出现就搜不到。但如果开了查询扩展,MySQL会帮你做一次“联想”。
用WITH QUERY EXPANSION的语法:
SELECT note_text FROM productnotes WHERE MATCH(note_text) AGAINST('database' WITH QUERY EXPANSION);它在执行时可以理解成走了两步:
- 先用
'database'做一次普通的自然语言搜索,拿到一批初步结果; - 从这批结果里挑出“高频词”或“共同词”,比如“关系型”“SQL”“数据表”“表结构”之类;
- 再去整个索引里搜这些扩展出来的词,把结果合并返回。
这样做的好处是:用户搜索“database”时,哪怕某篇文章全文都没有“database”这个词、而是通篇讲“关系型数据库设计”,只要第一批结果里有人同时提到了这两类词,那篇文章也能被捞进来。这很符合真实搜索场景——用户输入的词和文章用的词经常不一致。
4.2 查询扩展的适用场景和代价
查询扩展不是所有场景都该用的万金油,我给它总结了三个判断标准:
- 适合:文章正文较长、主题词分散,用户输入通常很短且不精确;
- 不适合:商品名、代码标识符、精确型号这些有唯一指向性的数据,一旦扩展会把完全不想关的东西混进来;
- 代价:扩展搜索会显著增加查询时间,而且有可能降低查准率,结果集变大但前几条不再那么精准。
书里也提到了这一点:查询扩展适合在“用户输入的词太少,直接搜不出几条结果”的场合做补充,不适合作为默认搜索行为。我自己的经验是把它做成一个“兜底策略”:正常自然语言模式搜出来的结果少于3条时,再自动切到WITH QUERY EXPANSION重试,而不是让所有请求都走扩展。这样用户体验更好,数据库压力也可控。
4.3 一个能复现的实验
还是用上面的productnotes表,往里加几行:
INSERT INTO productnotes (note_text) VALUES ('The database stores product information'), ('SQL is used to manipulate relational data'), ('Structured query language helps manage data');然后执行:
SELECT id, note_text FROM productnotes WHERE MATCH(note_text) AGAINST('database');普通模式下只会命中第一行,因为只有它直接出现了“database”。如果换成:
SELECT id, note_text FROM productnotes WHERE MATCH(note_text) AGAINST('database' WITH QUERY EXPANSION);你会看到第二、三行也被带出来,因为它们跟“data”“SQL”这些词产生了关联。这就是查询扩展的实际效果:命中范围变宽,但也引入了更多可能无关的记录。实验时你可以对比两次结果的条数差异,理解它“用准确性换召回率”的本质。
5. 布尔全文搜索:比你想象中更灵活的进阶用法
5.1 操作符语法速查
IN BOOLEAN MODE是另一个重要分支。它跟自然语言模式最大的不同是:不再把整段搜索词当成一个整体去匹配,而是允许你用操作符精细控制每个词必须出现、禁止出现、或者提高权重。语法上依然是MATCH...AGAINST:
SELECT note_text FROM productnotes WHERE MATCH(note_text) AGAINST('rabbit -cage' IN BOOLEAN MODE);这段含义是:文档里必须有“rabbit”,但绝不能包含“cage”。常见的操作符我整理成一张表,平时写搜索功能时对照着用就行:
| 操作符 | 含义 | 示例 |
|---|---|---|
+ | 必须包含 | +MySQL +优化 |
- | 必须排除 | MySQL -MyISAM |
> | 提高排序权重 | >MySQL 索引 |
< | 降低排序权重 | <MySQL 索引 |
"..." | 短语精确匹配 | "full text search" |
* | 通配符,只能放在词尾 | search* |
() | 分组布尔表达式 | (MySQL 索引) |
~ | 取消词的排序贡献 | MySQL ~优化 |
我实际项目里用得最多的是+、-和双引号。比如搜索“MySQL optimize”时,如果希望“optimize”是加分项而不是必选项,就不要写+optimize,直接写词就行。这比自然语言模式对搜索意图的还原能力强得多。
5.2 布尔模式的两个反直觉特性
第一,布尔模式下MySQL不会自动按相关性排序。自然语言模式自带排序,但布尔模式只管匹配,不管排序。你需要自己ORDER BY一个计算出来的相关度分,比如再用一次MATCH...AGAINST算分,否则顺序基本等于物理存储顺序,很反直觉。示例写法:
SELECT note_text, MATCH(note_text) AGAINST('rabbit' IN BOOLEAN MODE) AS score FROM productnotes WHERE MATCH(note_text) AGAINST('rabbit -cage' IN BOOLEAN MODE) ORDER BY score DESC;第二,布尔模式不受“50%阈值”限制。自然语言模式有个隐藏机制:某个词如果在超过50%的行里出现,MySQL会认为它太“大众”而忽略它,直接导致什么都搜不出来。布尔模式没有这个限制,所以对于高频词,比如你搜一篇到处都是“MySQL”文章的站内内容,用布尔模式才是正确选择。
有一点要特别小心:布尔模式里*只能放在词尾,放在词首会报错或者得不到预期结果。另外+、-操作符必须紧贴关键词,写成+ MySQL会被当成“必须包含MySQL这个词以及空格”,逻辑就乱了。
6. 完整案例:一个迷你文章搜索功能从零到能跑
6.1 表结构、数据与三条核心查询
光讲语法太薄,我直接放一个能抄走的迷你案例。假设你要做一个内部知识库的文章搜索,包含标题和正文两个字段:
CREATE TABLE knowledge_articles ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, content TEXT NOT NULL, FULLTEXT(title, content) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;正常情况下用户搜一个词,走默认自然语言模式:
SELECT id, title FROM knowledge_articles WHERE MATCH(title, content) AGAINST('MySQL 索引');搜出来结果太少时,走查询扩展兜底:
SELECT id, title FROM knowledge_articles WHERE MATCH(title, content) AGAINST('MySQL 索引' WITH QUERY EXPANSION);用户明确知道要排除某些词,走布尔模式:
SELECT id, title FROM knowledge_articles WHERE MATCH(title, content) AGAINST('MySQL +索引 -MyISAM' IN BOOLEAN MODE);三个场景就能覆盖站内搜索的大多数需求。注意这里全文索引定义的是两列,所以MATCH()里必须写title, content,少一列都报错。
6.2 我在实际使用中遇到的报错和解决办法
把《MySQL必知必会》这章读完是一回事,能顺利跑起来是另一回事。我把自己踩过的三个高频报错写在这里,如果你也遇到了,可以直接对症下药。
错误一:ERROR 1191: Can't find FULLTEXT index matching the column list
原因:MATCH()里的列和FULLTEXT索引列不一致。要么你查的列没建全文索引,要么索引是复合列而你只写了其中一列。解决办法就是对好两者的列列表,或者在目标单列上额外建一个独立全文索引。
错误二:搜“SQL”“go”这类短词,结果永远是空
原因:默认最小分词长度是3或4,太短的词根本没被收录进索引。修改innodb_ft_min_token_size(InnoDB)或ft_min_word_len(MyISAM)后重启服务,并重建全文索引。如果改完旧的还是搜不到,十有八九是忘了重建索引。
错误三:中文内容搜不到任何东西
原因:表或索引没有指定WITH PARSER ngram。MySQL默认解析器对中文无空格文本的切分能力基本为零。记住,中文表从建索引那一步就要加WITH PARSER ngram。
还有一个不算报错但很容易掉进去的坑:自然语言模式下的50%阈值。当一张表的规模很小,比如只有两条数据,其中一条包含“rabbit”,那么这个词的出现率是50%,正好触发阈值,搜索直接返回空。这不是bug,是设计如此。如果你在开发环境数据量很少时遇到全文搜索查不出来,先别怀疑SQL,把数据往表里多插几行再试。这个我印象很深,因为一开始我都险些以为是自己配置写错了。
6.3 维护全文索引要注意什么
FULLTEXT索引不是零成本的。它在插入和更新时都需要同步维护倒排索引,所以写入频繁的表用起来会有额外开销。我的建议是:
- 全文索引尽量建在文本内容变化不频繁的字段上;
- 大批量导入数据时先删掉全文索引,导入完成后再重建,比一边导入一边维护索引快得多;
- 全文索引占据的存储空间通常比原文本还大,这是倒排索引的特性,规划磁盘时要提前算进去。
规模再往上走,比如单个表超过千万行、并发搜索量很大,MySQL内置全文索引会吃力。那时候再考虑专业搜索引擎不迟,但在大部分中小型项目和内部系统里,Match()...Against()已经是性价比最高的方案了。
我自己的体会是:这一章严格来说不只是一组函数的学习,它背后是“倒排索引”“相关性排序”“查询扩展”这些搜索引擎的基础概念。弄懂了这一章,以后再看专业搜索引擎的文档会顺畅很多。最后分享一个小技巧:平时调试全文搜索时,把EXPLAIN SELECT ...打开看一眼,确认查询走的是FULLTEXT索引扫描而不是全表,就能少走很多弯路。