注:以下部分内容来自《MySQL技术内幕:InnoDB存储引擎》,以及我个人的一些理解和引申。如有侵权,请联系我删除,谢谢!
为什么需要全文索引
我们都知道,InnoDB中主要使用B+树作为索引(以及少量的哈希索引,主要是自适应哈希)。根据B+树的特点,我们可以在有索引的情况下,使用索引的前缀进行查找,例如,检索以“Covid”作为标题开头的疫情新闻:
|
|
这是可以实现的。(注意,这里和索引的最左匹配原则没有关系。由于LIKE关键字,这里使用的是范围查询,而最左匹配原则在遇到范围查询时无效。)
然而当我们需要将查询的关键字不在字段的开头(更多情况下的确是这样),那么我们的B+树索引就无法奏效了,例如,检索标题包含“Covid”的疫情新闻:
|
|
那么这时,就需要全文索引了。
全文索引
定义
书上是这样定义全文索引的:
全文索引(Full-Text Search)是将存储于数据库中的整本书或整篇文章中的任意内容信息查找出来的技术。它可以根据需要获得全文中有关章、节、段、句、词等信息,也可以进行各种分析和统计。
讲的很清楚。
Inverted Index(倒排索引)
之前一直听过倒排索引(比如ElasticSearch里面),读完书才发现,倒排索引实际上是全文索引的一种常见的实现。它的概念和B+树的索引是等级的。
倒排索引有两种具体的表现形式:
- Inverted File Index,表现形式为(单词,单词所在文档ID)
- Full Inverted Index,表现形式为(单词,(单词所在文档ID,具体位置))
可以看到,二者的差别主要是后者多存储了一个文档中的具体位置,虽然需要维护额外的存储空间,但也更方便我们迅速找到相应的具体段落。InnoDB中的实现也是基于后者的。
InnoDB的实现
InnoDB从1.2.x版本开始支持全文索引。
Auxiliary Table(辅助表)
作为索引,肯定需要空间进行存储。对于全文索引来说,存储索引的地方即是Auxiliary Table,即辅助表,一般是用关联数组实现的。InnoDB中的辅助表有两列,分别为word字段和ilist字段,在word字段上设有索引。而ilist,则是前面提到的(文档ID,位置),用来迅速定位。
在InnoDB中,一共有六张辅助表(为了提高并发性能),每张表根据word的Latin编码进行分区。并且,辅助表持久化在磁盘上。
FTS Index Cache(全文检索索引缓存)
正如其名,FTS Index Cache作为cache,目的非常单纯,就是为了提高检索性能;采用红黑树实现,根据(word,ilist)进行排序。
为什么是红黑树?
书上没有说,我去翻了官方文档也没有具体说明。但其实根据它的特点,我们不难推出:
FTS Index Cache是存储在内存中的(in-memory)。红黑树相较于AVL树,由于在插入/删除的情况下需要的调整代价更小,所以在面对频繁的删改(这里也是同样)时性能更优。而我们知道,InnoDB使用B+树作为索引结构主要是因为索引存储在磁盘上,为了尽量减少磁盘IO,需要树的高度尽可能低。但是在内存中,没有磁盘IO的限制,红黑树显然具有更大的优势。
其实,基于在内存中这一前提,很多设计都选择了红黑树,epoll、JDK8的HashMap,由果溯因,也看得出红黑树效率是很高的。
和Change Buffer对比
我们知道InnoDB中很多地方使用了缓存,而Change Buffer可以很好的与FTS Index Cache进行类比:
- 对于Change Buffer,每次写入都不立刻持久化到磁盘上(不然也就失去了作为buffer的意义),而往往等到记录的数据页被读入内存中,再进行相应的修改;
- 而对于FTS Index Cache也是同样:当对全文检索进行查询时,FTS Index Cache的word字段才被合并到辅助表中,然后再进行查询。
那么这样一来,一定会有一个问题:当数据库宕机时,部分FTS Index Cache的数据可能还没有被写入辅助表中。为了解决这个问题,书上是这样描述的:
那么下次重启数据库时,当用户对表进行全文检索(查询或者插入操作)时,InnoDB存储引擎会自动读取未完成的文档,然后进行分词操作,再将分词的结果放入到FTS Index Cache中。
说白了,就是保留上次的状态,类似于HTTP“断点续传”的功能。但是HTTP断点续传基于头部的range字段,而这里重新开始又基于什么呢?关于这一点,书上并没有给出明确的答复,但是我想,可以通过Redis中相似的操作,进行合理的推论:
我们知道,Redis中对过期键采取的删除策略是惰性删除和定期删除。其中定期删除的实现由serverCron函数调用activeExpireCycle函数,在规定时间内遍历各个数据库。它内部维护了一个current_db的全局变量,记录当前函数的检查进度。如果此时遍历结束但没有遍历完,下一次再开始时就会从current_db标识的数据库开始,而不是从头开始。
那么类比一下,这里对于FTS Index Cache的遍历写入就相当于对数据库中过期键的遍历检查。那么,和current_db相同,InnoDB应该也是维护了一个全局变量(当然,肯定需要持久化到磁盘上),记录当前写入的进度,从而使得下次重启时,能够续着上一次的进度进行写入。
FTS Document ID
如果光凭借辅助表中的word和ilist,我们无法直接将需要进行搜索的文本与辅助表进行联系。而FTS Document ID帮助我们完成了这项工作。它的作用,即是在数据表中,和word进行映射。
在InnoDB中,这一列被命名为FTS_DOC_ID,类型必须为BIGINT UNSIGNED NOT NULL。我们不妨测试一下,自己建一个含有名为FTS_DOC_ID,类型不满足要求的列,不出意外报错:
|
|
我们当然可以手动创建这一列(只要满足类型要求),如果没有手动创建,InnoDB也会为我们自动生成。
此外,在对文档中的分词进行删除时,InnoDB将不会删除辅助表中的记录,而是只删除FTS Index Cache中的记录,并且将被删除记录的FTS_DOC_ID保存在DELETED auxiliary table中。至于为什么不删除,官方文档做了很好的解释:
意思就是说对于全文索引进行删除时,会导致辅助表中非常多的细微的改动,也就影响了对于这些表的并发访问。 所以,不对表中数据做真实删改,而是通过将FTS_DOC_ID保存在DELETED auxiliary table中,很好的避免了这个问题。
实际上,类似的“懒删除”策略在很多地方都有应用,例如Redis中,对于过期键删除采用的策略之一就是惰性删除;对于执行sdstrim之后的SDS也采用了惰性空间释放。
当然,一直不删除,无效的数据始终堆积在辅助表中,会让表变得非常庞大,占据额外的空间。此时我们就可以通过
|
|
来进行删除操作。当然,OPTIMIZE TABLE还有别的一些功能,例如重新统计基数,由于和本文关系不大,这里就不展开了。
Stopword List
顾名思义,就是维护了一张表,对于表中的词(大多是没有太大意义的)不进行索引。具体的表在information_schema下的INNODB_FT_DEFAULT_STOPWORD。
其他限制
书上还列举了当前InnoDB的全文检索的限制:
- 每张表只能有一个全文检索的索引
- 由多列组合而成的全文检索的索引列必须使用相同的字符集与排序规则
- 不支持没有单词界定符(delimiter)的语言,如中文、日文、韩语等
使用全文检索
InnoDB中,使用全文索引的进行检索的方式为:
|
|
其中,MATCH指定需要查询的列,AGAINST指定查询方法,有以下三种。
Natural Language
查询带有指定词的文档,例如查询新闻标题中带有“Covid”的行:
|
|
这一种方法也是InnoDB默认的方法,因而可以简写:
|
|
查询返回的结果根据相关性进行降序排序。相关性是一个非负浮点数,计算依据于:
- word是否在文档中出现
- word在文档中出现的次数
- word在索引列中的数量
- 多少个文档包含该word
此外,查询的word长度也需要在[innodb_ft_min_token_size, innodb_ft_max_token_size]之间,默认值分别为3和84。
Boolean
Boolean模式会允许对查询的word进行符号拼接,规则如下:
- + 表示该word必须存在;- 相反
- 无操作符表示该word可选,如出现则相关性更高
- @distance表示查询的多个单词距离是否在distance之内,单位为字节
- > 表示该word出现时增加相关性; < 相反
- ~ 表示该word出现时相关性为负
- * 表示以该单词开头的单词
- " 表示短语
例如查询新闻标题中带有“Covid”但没有“Today”的行:
|
|
Query Expansion
查询扩展,我的理解是进行二次查询。当条件有限时比较有用。查询分为两个阶段:
- 根据搜索的word进行全文索引查询
- 根据第一阶段产生的分词,再进行一次全文索引的查询
例如,先通过NATURAL LANGUAGE模式查询新闻标题中带有“Covid”的行:
|
|
之后再使用QUERY EXPANSION进行查询,得到的结果就是与第一步结果相关的,而非仅仅满足条件的。