个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL 全文索引实战:FULLTEXT、ngram 中文分词与 MATCH AGAINST 到底该怎么用](#MySQL 全文索引实战:FULLTEXT、ngram 中文分词与 MATCH AGAINST 到底该怎么用)
-
- [一、先回答:LIKE 到底差在哪](#一、先回答:LIKE 到底差在哪)
- [二、全文索引的本质:分词器 + 倒排索引](#二、全文索引的本质:分词器 + 倒排索引)
-
- [2.1 InnoDB 的倒排数据放在哪](#2.1 InnoDB 的倒排数据放在哪)
- 三、版本演进:别再被过时教程误导
- 四、动手:创建全文索引
-
- [4.1 建表时直接定义](#4.1 建表时直接定义)
- [4.2 合并成一个索引,还是拆成多个](#4.2 合并成一个索引,还是拆成多个)
- [五、查询怎么写:MATCH ... AGAINST 的三种模式](#五、查询怎么写:MATCH ... AGAINST 的三种模式)
-
- [5.1 自然语言模式(默认)](#5.1 自然语言模式(默认))
- [5.2 布尔模式(生产主力)](#5.2 布尔模式(生产主力))
- [5.3 三种模式怎么选](#5.3 三种模式怎么选)
- [六、ngram 是怎么把中文切开的](#六、ngram 是怎么把中文切开的)
-
- [6.1 ⚠️ 最致命的坑:短于 token size 的关键词搜不到](#6.1 ⚠️ 最致命的坑:短于 token size 的关键词搜不到)
- 七、必须知道的几个参数
- 八、停止词:中文场景几乎一定要自定义
- 九、踩坑合集
- [十、什么时候该换 Elasticsearch](#十、什么时候该换 Elasticsearch)
- 十一、小结
MySQL 全文索引实战:FULLTEXT、ngram 中文分词与 MATCH AGAINST 到底该怎么用
一个模糊查询把接口拖到超时,十有八九是
LIKE '%关键词%'干的。本篇讲清楚 MySQL 自带的 FULLTEXT 全文索引 :它为什么快、中文要怎么分词、
布尔模式怎么写、哪些参数必须改,以及------什么时候你不该用它。
一、先回答:LIKE 到底差在哪
假设有一张工单表,要在标题和正文里搜「支付失败」:
sql
SELECT * FROM ticket
WHERE title LIKE '%支付失败%' OR content LIKE '%支付失败%';
这条 SQL 的问题不是写法丑,而是结构性缺陷:
| 写法 | 能用索引吗 | 原因 |
|---|---|---|
col LIKE 'abc%' 前缀匹配 |
✅ | 前缀有序,B+Tree 能定位起点后顺序扫 |
col LIKE '%abc' 后缀匹配 |
❌ | 开头不确定,无法在有序索引里定位 |
col LIKE '%abc%' 任意位置 |
❌ | 退化为全表扫描 + 逐行字符串匹配 |
col REGEXP 'abc' |
❌ | 全表扫描,还多一层正则开销 |
LOCATE('abc', col) > 0 |
❌ | 列被函数包裹,索引直接失效 |
只要出现"包含某个词"的需求,普通二级索引就帮不上忙。表里 50 万行、每行正文两三千字,
意味着每次搜索要做 50 万次字符串包含判断,CPU 全花在重复劳动上。
全文索引换了思路:把这项活儿提前到写入时做 。写入时先把文本切成一堆词(token),
建一张"词 → 出现在哪些行"的倒排表;搜索时直接查倒排表,复杂度从 O(行数) 降到接近 O(命中行数)。
LIKE 是每次搜索都在全表做一次暴力匹配;全文索引是写一次词表,之后查表。
二、全文索引的本质:分词器 + 倒排索引
它不再是 B+Tree,而是两样东西:把长串切成 token 的分词器(parser) ,
和记录 token -> (doc_id, 位置...) 的倒排索引(inverted index)。
两条样例数据:
id=1 title: "订单支付失败排查"
id=2 title: "如何优化支付接口性能"
按二元组(bigram)切词后,内部大致是:
| token | 出现的 doc_id |
|---|---|
| 订单 / 单支 / 支付 / 付失 / 失败 | 1 |
| 支付 / 何优 / 优化 / 化支 / 接口 | 2 |
搜"支付",直接反查 支付 -> {1,2},不用碰原表其他行,再按相关性算分回表取行,收工。
2.1 InnoDB 的倒排数据放在哪
InnoDB 全文索引存放在一组**辅助表(auxiliary table)**里,可以通过 information_schema 观察,
前提是显式指定要看哪张表:
sql
-- 指定观察目标,格式 '库名/表名'
SET GLOBAL innodb_ft_aux_table = 'demo/ticket';
SELECT word, doc_count, first_doc_id, last_doc_id
FROM information_schema.INNODB_FT_INDEX_TABLE LIMIT 10; -- 已落盘的倒排词表
SELECT * FROM information_schema.INNODB_FT_INDEX_CACHE LIMIT 10; -- 内存里未落盘的增量分词
SELECT * FROM information_schema.INNODB_FT_CONFIG; -- 当前生效的配置项
其中 INNODB_FT_INDEX_CACHE 解释了一个新手最困惑的现象:刚 INSERT 的数据,全文索引可能查不到。
InnoDB 为了写入性能,新插入的行先进内存 FT cache,等 cache 满了、表被 OPTIMIZE、
或实例正常关闭时才合并到磁盘,所以全文索引存在延迟可见。这是设计取舍,不是 bug。
要强制落盘就 OPTIMIZE TABLE ticket;(重建表并 flush FT cache)。
⚠️ 它是一次重建,别在业务高峰对大表做;同时它也是改了分词参数后让新参数生效的唯一手段。
三、版本演进:别再被过时教程误导
大量老教程写的是"表必须是 MyISAM"、"改 ft_min_word_len 才能搜中文",这在今天完全不适用。
| 阶段 | 版本 | 关键能力 |
|---|---|---|
| MyISAM 时代 | 5.5 及更早 | 只有 MyISAM 支持 FULLTEXT;无事务;无中文分词 |
| InnoDB 支持 | 5.6 | InnoDB 首次支持 FULLTEXT,引入上面说的 FT cache |
| 中文可用 | 5.7 | 内置 ngram 全文解析器,官方支持中文 / 日文 / 韩文 |
| 成熟期 | 8.0 | InnoDB 成为默认引擎,词典表可观测性更好 |
8.0 的标准答案:InnoDB + ngram,忘记 MyISAM。
另外要纠正:ft_min_word_len 是 MyISAM 的变量,InnoDB 对应 innodb_ft_min_token_size;
而且一旦使用 ngram 解析器,这两个都不生效 ,分词粒度改由 ngram_token_size 决定。
这是本节最值得记住的一句话。
四、动手:创建全文索引
4.1 建表时直接定义
sql
CREATE TABLE ticket (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL DEFAULT '',
content TEXT,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_created (created_at),
-- 复合全文索引:把两列当成一个"文档"来分词
FULLTEXT KEY ft_title_content (title, content) WITH PARSER ngram
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
关键点:只有 CHAR / VARCHAR / TEXT 能建 FULLTEXT ,别的类型会报错;
WITH PARSER ngram 是中文场景的核心,不写就用内置解析器按空格标点切词,
整段中文会被当成一个词 ,等于没用。已有表补索引用
ALTER TABLE ticket ADD FULLTEXT INDEX ft_content (content) WITH PARSER ngram;,
等价写法是 CREATE FULLTEXT INDEX ... WITH PARSER ngram。
4.2 合并成一个索引,还是拆成多个
| 方案 | 优点 | 缺点 |
|---|---|---|
复合索引 (title, content) |
一条语句同时命中两列 | 无法只对 title 打分(MATCH 列名必须匹配索引定义) |
单列 ft_title + ft_content |
可分别加权、分别命中 | 搜两列要写两个 MATCH,评分手工融合 |
要给标题更高权重,就拆成两个索引再手工加权:
sql
SELECT id, title,
MATCH(title) AGAINST('支付 失败' IN BOOLEAN MODE) * 3 AS s_title,
MATCH(content) AGAINST('支付 失败' IN BOOLEAN MODE) AS s_body
FROM ticket
WHERE MATCH(title) AGAINST('支付 失败' IN BOOLEAN MODE)
OR MATCH(content) AGAINST('支付 失败' IN BOOLEAN MODE)
ORDER BY (s_title + s_body) DESC
LIMIT 20;
五、查询怎么写:MATCH ... AGAINST 的三种模式
语法骨架是 MATCH (col1, col2, ...) AGAINST ('关键词串' [修饰符])。
⚠️ 硬性约束 :MATCH() 里的列顺序和数量,必须和某个 FULLTEXT 索引定义完全一致
(或是它的前缀),写错直接报 ERROR 1191: Can't find FULLTEXT index matching the column list。
索引是 (title, content) 时,MATCH(content, title) 和 MATCH(content) 都会炸。
5.1 自然语言模式(默认)
sql
SELECT id, title, MATCH(title, content) AGAINST('支付失败') AS score
FROM ticket
WHERE MATCH(title, content) AGAINST('支付失败')
ORDER BY score DESC
LIMIT 20;
不写修饰符就是 IN NATURAL LANGUAGE MODE,会算相关性评分 。注意不加 WHERE 的话它仍会返回所有行
(只是按分数排序),所以过滤条件不能省。
关于流传很广的「50% 阈值」(词出现在超过半数行里就评 0 分、搜不到结果)------
这条规则的经典描述针对的是 MyISAM 的传统实现,InnoDB 用的是自己的 TF-IDF 变体,行为并不完全一样。
建议别背结论,直接在你的库上验证:把 1000 行里的 800 行都写上"支付",再 AGAINST('支付') 看结果集。
5.2 布尔模式(生产主力)
布尔模式下关键词串是一段表达式,这是生产环境用得最多的模式:
| 运算符 | 含义 | 示例 |
|---|---|---|
+ / - |
必须包含 / 必须排除 | +支付 -测试 |
| (无符号) | 可选包含,命中加分 | 支付 退款 |
> < |
提高 / 降低对分数的贡献 | >支付 <测试 |
~ |
出现则降低分数 | ~异常 |
* |
通配符后缀 | 支付* 命中"支付失败""支付中" |
" |
短语,要求按顺序相邻出现 | "支付 失败" |
@distance |
词之间的最大距离(InnoDB 支持) | "订单 支付" @5 |
() |
分组 | +(支付 退款) -测试 |
sql
-- 必须含"支付",含"失败"加分,含"测试"直接排除
... AGAINST('+支付 失败 -测试' IN BOOLEAN MODE)
-- 前缀通配
... AGAINST('退款*' IN BOOLEAN MODE)
-- 短语 + 邻近距离
... AGAINST('"订单 支付" @5' IN BOOLEAN MODE)
💡 默认运算符集由 ft_boolean_syntax 控制(默认 + -><()~*:""&|),
理论上能让"无符号词"默认变成 AND,但不推荐改 ------会坑到不懂这个约定的同事。
⚠️ 还有条硬限制:单个 token 超过 innodb_ft_max_token_size(默认 84)会被直接丢弃,
既不索引也搜不到,所以往全文索引里塞长 URL、Base64 是没有意义的。
5.3 三种模式怎么选
| 模式 | 算分 | 运算符 | 性能 | 适用场景 |
|---|---|---|---|---|
| NATURAL LANGUAGE | ✅ | ❌ | 中 | 简单搜一下,按相关度排序 |
| BOOLEAN | ✅(偏命中与否) | ✅ | 好 | 生产主力:AND / OR / NOT / 通配 / 短语 |
| QUERY EXPANSION | ✅ | ❌ | 差(跑两趟) | 召回优先,几乎用不上 |
查询扩展(WITH QUERY EXPANSION)会先用原词搜一次,再把最相关文档里的高频词拿来扩展第二轮。
听着很美,但结果不可控、延迟翻倍、难以和业务过滤配合,只适合"没更好方案时的兜底"。
六、ngram 是怎么把中文切开的
n-gram 是滑窗切词 :用长度为 n 的窗口从头滑到尾,每个位置取窗口内容作为一个 token。
它不需要词典,对中文这种没有空格分词的语言特别友好。以"订单支付失败"为例:
| ngram_token_size | 切出的 token |
|---|---|
| 1 | 订、单、支、付、失、败 |
| 2(默认) | 订单、单支、支付、付失、失败 |
| 3 | 订单支、单支付、支付失、付失败 |
注意 2 元组切出了"单支""付失"这种无语义跨词组合,这是 n-gram 的固有代价:
索引变大(token 数约等于字符数),也可能带来少量误召回;换来的是零词典维护、零语言依赖 。
思路和 Elasticsearch 的 ngram tokenizer 完全一致,区别只在配置方式------
ES 在 mapping 里指定 analyzer,MySQL 是 WITH PARSER ngram 加一个全局 ngram_token_size。
6.1 ⚠️ 最致命的坑:短于 token size 的关键词搜不到
ngram_token_size 默认 2,于是:
sql
-- 表里确实有 content 含 "库" 的行,但结果是 0
SELECT COUNT(*) FROM ticket WHERE MATCH(content) AGAINST('库' IN BOOLEAN MODE);
因为索引里最小单位就是两个字,"库"没进词典自然查不到。
而用户输入经常是单字("票""钱""单"),这是很真实的线上事故来源。两条路:
- 改成 1 :写进配置文件
ngram_token_size = 1(它是只读变量 ,不能 SET GLOBAL),
重启实例并对所有全文索引OPTIMIZE TABLE重建。代价是索引体积明显膨胀、写放大更重。 - 应用层兜底(更推荐) :保持 2,检测到单字输入时走另一条检索路径
(按字符拆分的辅助检索列,或 ES)。
这个取舍要提前想清楚,它直接决定索引体积和搜索体验。
七、必须知道的几个参数
| 参数 | 作用域 | 默认 | 说明 |
|---|---|---|---|
ngram_token_size |
全局、只读(需重启) | 2 | 中文分词粒度,1 表示按单字切 |
innodb_ft_min_token_size |
InnoDB、动态 | 3 | 内置解析器下 token 最小长度,ngram 下不适用 |
innodb_ft_max_token_size |
InnoDB、动态 | 84 | token 最大长度,超长的词会被丢弃 |
ft_min_word_len |
MyISAM | 4 | 老教程常客,InnoDB 场景跟它没关系 |
innodb_ft_enable_stopword |
InnoDB | ON | 是否启用停止词 |
innodb_ft_server_stopword_table |
InnoDB | 空 | 自定义停止词表,格式 库名/表名 |
innodb_ft_cache_size |
InnoDB | 8MB | 每张表的 FT 插入缓存上限 |
实际动手时,九成情况只需要关心第一行。
八、停止词:中文场景几乎一定要自定义
停止词是不参与索引的词。MySQL 内置的 InnoDB 默认停止词表只有 36 个英文词
(SELECT * FROM information_schema.INNODB_FT_DEFAULT_STOPWORD;),
一来对中文毫无帮助,二来"的""了""是""我们"这类高频无意义字全都被索引,
白白撑大索引并稀释相关性评分。所以中文场景建议自建:
sql
CREATE TABLE ft_stopword_cn (value VARCHAR(30) NOT NULL) ENGINE = InnoDB;
INSERT INTO ft_stopword_cn (value) VALUES
('的'),('了'),('是'),('在'),('和'),('我'),('你'),('他'),
('我们'),('你们'),('他们'),('这'),('那'),('就'),('都');
ini
# my.cnf,建议写进配置文件避免重启丢失
[mysqld]
innodb_ft_server_stopword_table = 'demo/ft_stopword_cn'
⚠️ 停止词表是索引构建期 读取的,改完后已有索引不会自动更新 ,必须 OPTIMIZE TABLE ticket; 重建;
重建期间写放大和磁盘占用都会上来,挑低峰做;进了停止词表的字会彻底搜不到
(ngram_token_size=1 时尤其明显),挑选务必谨慎。
九、踩坑合集
坑 1|索引建了,EXPLAIN 的 key 却是 NULL :依次检查 MATCH 的列是否和索引定义完全一致、
搜索词是否被停止词过滤了、是不是搜了短于 token size 的词。
坑 2|ORDER BY 用 MATCH 分数很慢 :MySQL 要把所有命中行的分数算完再排序。
先用普通条件收窄候选集,再做全文排序------别指望优化器自动替你做:
sql
SELECT t.id, t.title, MATCH(t.content) AGAINST('支付失败' IN BOOLEAN MODE) AS score
FROM ticket t
JOIN (
SELECT id FROM ticket
WHERE created_at >= '2026-01-01' AND status = 1
ORDER BY created_at DESC LIMIT 5000
) k ON k.id = t.id
WHERE MATCH(t.content) AGAINST('支付失败' IN BOOLEAN MODE)
ORDER BY score DESC;
坑 3|以为多个 FULLTEXT 条件能一起走索引 :一次查询通常只能用一个 全文索引,
多个条件 OR 时可能 merge 也可能退化,拆成两个索引分别查更可控。
坑 4|当成万能加速器 :它对 = 比较、范围扫描、JOIN 一点用没有,只服务于 MATCH ... AGAINST。
坑 5|忽略写入代价 :每插入一行长文本都要分词并更新倒排表。
写多读少的表建议把待检索文本拆到独立窄表,让主表保持轻量。
坑 6|以为高亮、聚合也自带 :MySQL 全文只给"匹配 + 打分",
高亮、facet 聚合、拼音搜索、同义词这些 ES 里开箱即用的能力,MySQL 要么自己做要么没有。
坑 7|分区表 + 全文索引 :这个组合历史上长期受限,各版本支持程度不一,
要用分区就先在本机版本验证,别照抄文档结论。
十、什么时候该换 Elasticsearch
| 能力 | MySQL FULLTEXT | Elasticsearch |
|---|---|---|
| 中文分词 | ngram(按字滑窗,无语义) | IK / jieba 等,词典可热更新 |
| 索引实时性 | 有 FT cache 延迟 | 近实时(refresh_interval 可调) |
| 相关度调优 | 固定 TF-IDF 变体,几乎不可调 | BM25 / 自定义打分 / boost |
| 高亮、facet 聚合 | ❌ | ✅ 原生支持 |
| 同义词 / 拼音 / 纠错 | ❌ | ✅ 插件生态 |
| 横向扩容 | ❌ 单机 | ✅ 原生分片副本 |
| 运维复杂度 | 极低(多一个索引而已) | 高(一套独立集群) |
| 数据一致性 | 强一致,与主库同源 | 最终一致,需维护同步链路 |
判断线这样画:数据量几十万到百万级 、QPS 不高、只需要"包含关键词"、
团队不想维护额外组件 → MySQL FULLTEXT 完全够用,性价比极高。而只要命中下面任一条,
就该认真评估 ES------搜索是核心产品能力 (联想词、排序调优、高亮、纠错)、
数据量到千万级以上需要横向扩容、要做复杂相关性工程、多数据源统一检索。
反过来,最容易被低估的是同步链路成本 :上了 ES 就要回答"MySQL 的数据怎么可靠地同步进去",
binlog 订阅、延迟、回溯、双写一致性、索引重建,这一整套运维负担远超一个 FULLTEXT 索引。
十一、小结
LIKE '%x%'因为开头不确定 用不上索引;全文索引把分词提前到写入时构建倒排索引- MySQL 8.0 的标准答案:InnoDB +
WITH PARSER ngram,彻底抛弃 MyISAM 那套老说法 - InnoDB 全文索引存在 FT cache 延迟,新插入的数据不一定能立刻被检索到
MATCH()的列必须和某个 FULLTEXT 索引定义完全一致,否则报 1191 错误- 三种模式里 布尔模式是生产主力 ,支持
+ - * " @n等运算符 - 中文最大坑:
ngram_token_size默认 2,导致单字关键词搜不到;改成 1 或在应用层兜底 - 改了
ngram_token_size、停止词表、token 长度参数后,必须OPTIMIZE TABLE重建索引才生效 - 内置停止词表只有 36 个英文词,中文场景建议自建停用词表
- 全文索引只做"匹配 + 打分",高亮、聚合、拼音、同义词都不是它的活
- 选型边界:百万级以内且非核心搜索 → MySQL;千万级以上或搜索是核心能力 → Elasticsearch
下一篇聊另一条路:当 MySQL 真的扛不住时,怎么用最小成本把搜索搬到 Elasticsearch,
以及同步链路怎么设计才不翻车。