深入MySQL全文索引:Insert操作的执行流程

间隙填充
正睿科技  发布时间:2024-05-28 12:16:54  浏览数:736

关于正睿.png

全文索引在信息检索领域扮演着重要的角色,它主要用于解决全文搜索的问题。简单来说,就是当你在浏览器中输入一个关键词时,搜索引擎需要找出所有包含这个关键词的文档,并将它们按照相关性进行排序。 而全文索引的底层实现则是基于一种叫做倒排索引的技术。倒排索引描述了单词和文档之间的映射关系,它的表现形式是一个列表,其中包含了每个单词以及它所在文档的信息和在文档中的位置。 在接下来的内容中,我将用一个示例来详细解释全文索引的组织方式。希望这样的表达方式能让你觉得更自然、更易于理解。

mysql> CREATE TABLE opening_lines (           id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,           opening_line TEXT(500),           author VARCHAR(200),           title VARCHAR(200),           FULLTEXT idx (opening_line)           ) ENGINE=InnoDB;     mysql> INSERT INTO opening_lines(opening_line,author,title) VALUES           ('Call me Ishmael.','Herman Melville','Moby-Dick'),           ('A screaming comes across the sky.','Thomas Pynchon','Gravity\'s Rainbow'),           ('I am an invisible man.','Ralph Ellison','Invisible Man'),           ('Where now? Who now? When now?','Samuel Beckett','The Unnamable');       mysql> SET GLOBAL innodb_ft_aux_table='test/opening_lines'; mysql> select * from information_schema.INNODB_FT_INDEX_TABLE; +-----------+--------------+-------------+-----------+--------+----------+   | WORD      | FIRST_DOC_ID | LAST_DOC_ID | DOC_COUNT | DOC_ID | POSITION |   +-----------+--------------+-------------+-----------+--------+----------+   | across        |                    4 |               4 |                 1 |          4 |       18 |   | call             |                    3 |               3 |                 1 |          3 |        0 |  

| comes        |                    4 |               4 |                 1 |          4 |      12 |  

| invisible     |                    5 |               5 |                 1 |          5 |        8 |   | ishmael      |                    3 |               3 |                 1 |          3 |        8 |   | man           |                    5 |               5 |                 1 |          5 |      18 |   | now           |                    6 |               6 |                 1 |          6 |        6 |   | now           |                    6 |               6 |                 1 |          6 |        9 |   | now           |                    6 |               6 |                 1 |          6 |      10 |   | screaming |                    4 |               4 |                 1 |          4 |        2 |   | sky             |                   4 |               4 |                  1 |           4 |      29 |   +-----------+--------------+-------------+-----------+--------+----------+

我们成功创建了一个数据表,并特别在opening_line列上实施了全文索引的构建。以插入文本“Call me Ishmael.”作为示例,此文本视为一个文档,其被赋予的文档ID为3。在进行全文索引生成的过程中,该文档被切分为三个基础词汇单元:'call', 'me', 'ishmael'。值得注意的是,根据预设的最小单词长度阈值ft_min_word_len(在此情景下为4),单词'me'因其长度未达到此标准而被系统忽略。因此,最终进入全文索引存储的仅有'call'与'ishmael'这两个词。具体到位置信息,'call'位于文档初始位置,字符偏移量为0,而'ishmael'则始于文档的第12个字符,相应地,其偏移量记录为12。

欲深入探索全文索引的高级特性和详细功能,推荐查阅MySQL 8.0官方参考手册以获取全面信息。

全文索引缓存机制:

全文索引表的设计核心在于维护一种映射关系,即将文档中的单词与其出现的具体位置相关联。这种设计模式通常采用{单词,{文档ID,出现的位置}}的数据结构来实现。然而,当处理大量文档的插入操作时,若每次插入都立即进行分词并同步到磁盘,将极大地影响系统的性能。

为了解决这一问题,Innodb引擎引入了全文索引缓存(Full-Text Search Cache)机制。这一机制的工作原理与变更缓冲区(Change Buffer)相似,旨在优化数据写入流程。具体来说,当新文档被插入时,系统首先将其内容进行分词处理,并将分词结果临时存储在缓存中。这样做的好处是,可以避免频繁的直接磁盘I/O操作,因为缓存会在达到一定容量后,以批处理的方式统一将数据刷新到磁盘。

为了有效管理这一缓存机制,Innodb定义了一个名为fts_cache_t的数据结构。该结构负责跟踪缓存的当前状态,并在适当的时候触发数据的持久化过程。通过这种方式,Innodb能够在保证全文索引功能的同时,显著提升处理大量文档插入操作的效率。

1716867601072099789.png


Innodb的全文索引缓存机制为每个建立了全文索引的表单独维护一个缓存对象,即fts_cache_t。这个对象是在内存中为每个表动态创建的,确保了数据处理的隔离性和高效性。值得注意的是,即使一个表上创建了多个全文索引,内存中也只会对应一个fts_cache_t实例。

fts_cache_t结构中包含了几个关键的成员变量,它们各自承担着不同的职责以确保缓存机制的正确运行和高效管理:

optimize_lock、deleted_lock、doc_id_lock:这些是互斥锁,用于控制对缓存结构的并发访问,确保在多线程环境下的数据一致性和完整性。

deleted_doc_ids:这是一个向量(vector)类型的成员,专门用来存储已经被删除的文档标识符(doc_id)。这对于维护全文索引的准确性和及时性至关重要。

indexes:同样是一个向量类型的成员,它存储了与每个全文索引相关的数据结构。每当创建一个新的全文索引时,就会在这个向量中添加一个新的元素。每个索引内部使用红黑树这种自平衡二叉搜索树来存储分词结果,其中键(key)是单词,值(value)则包含了文档标识符(doc_id)以及单词在文档中的偏移量。

total_size:这个成员记录了缓存及其所有子结构所分配的总内存量。这有助于监控和管理内存使用,确保缓存机制不会消耗过多的系统资源。

Insert语句执行过程

在MySQL 8.0.22版本中,INSERT语句的执行可以被划分为三个主要阶段:写入行记录阶段、事务提交阶段和刷脏阶段。每个阶段都有其特定的任务和目的,共同确保数据的正确插入和持久化。

写入行记录阶段

此阶段的主要目标是将新的行记录插入到数据库中。工作流程大致如下:

1715856508461617454.png

如图,这一环节核心在于创建文档标识(doc_id),并确保它被稳妥地保存在InnoDB的数据库记录中。同时,采取了一项高效措施,即对生成的doc_id进行暂存,以便在事务最终确认提交时,能迅速根据这个标识抓取相应的文本信息,其函数调用栈如下

 ha_innobase::write_row        ->row_insert_for_mysql            ->row_insert_for_mysql_using_ins_graph                ->row_mysql_convert_row_to_innobase                    ->fts_create_doc_id                        ->fts_get_next_doc_id                ->fts_trx_add_op                    ->fts_trx_table_add_op

fts_get_next_doc_id和fts_trx_table_add_op是关键操作,前者负责生成新的doc_id,后者用于添加操作到事务中。在InnoDB行数据里,含有多项隐式列,如row_id、trx_id。建立全文索引时,会额外加入一个隐式字段FTS_DOC_ID,其值由fts_get_next_doc_id赋予。如下

1.   dberr_t fts_get_next_doc_id(const dict_table_t *table, doc_id_t *doc_id) {   2.       3.       ...   4.         5.       mutex_enter(&cache->doc_id_lock);   6.       *doc_id = ++cache->next_doc_id;   7.       mutex_exit(&cache->doc_id_lock);   8.         9.       ...   10. }  

fts_trx_add_op则确保全文索引操作被纳入事务处理队列,待事务提交时一并执行。

事务提交阶段

事务提交阶段工作流如图:

1715856521931244681.png

这是FTS插入流程中的核心步骤,涉及文档的分词处理,产生{单词,{文档ID,位置}}的映射,并将这些数据暂存至缓存中,全部在此阶段密集进行。数调用栈如下:

fts_commit_table      ->fts_add          ->fts_add_doc_by_id              ->fts_cache_add_doc                    // 根据doc_id获取文档,对文档分词                  ->fts_fetch_doc_from_rec                    // 将分词结果添加到cache中                  ->fts_cache_add_doc              ->fts_optimize_request_sync_table                    // 创建FTS_MSG_SYNC_TABLE消息,通知刷脏线程刷脏                  ->fts_optimize_create_msg(FTS_MSG_SYNC_TABLE)

fts_add_doc_by_id是这一过程中极为关键的一环,其职责包括:

定位文档: 利用doc_id在数据库中锁定对应文档的行记录。

分词处理: 对文档进行细致的分词分析,产出一系列{单词, (所属文档ID, 文档内偏移量)}的配对信息,并安全地存储到缓存中。

缓存管理: 实时监控缓存占用大小(cache->total_size),一旦触及预设阈值,立即采取行动。通过向刷脏线程的任务队列中插入一条FTS_MSG_SYNC_TABLE指令,触发后台的脏数据刷新流程(fts_optimize_create_msg),确保缓存健康运转,代码如下:

 static ulint fts_add_doc_by_id(fts_trx_table_t *ftt, doc_id_t doc_id)
    {
            /* 1. 根据docid在fts_doc_id_index索引中的查找记录 */
          /* btr_pcur_open_with_no_init函数中会调用btr_cur_search_to_nth_level,btr_cur_search_to_nth_level
            会执行b+树搜索记录的过程,先从根节点找到docid记录所在的叶子节点,再通过二分查找找到docid记录。*/
        btr_pcur_open_with_no_init(fts_id_index, tuple, PAGE_CUR_LE,
                                    BTR_SEARCH_LEAF, &pcur, 0, &mtr);
        if (btr_pcur_get_low_match(&pcur) == 1) { /* 如果找到了docid记录 */
            if (is_id_cluster) {
                 /** 1.1 如果fts_doc_id_index是聚集索引,则意味着已经找到行记录数据, 直接保存行记录 **/
                doc_pcur = &pcur;
              } else {
                /** 1.2 如果fts_doc_id_index是辅助索引,则需要根据1.1找到的主键id在聚集索引上进一步搜 索行记录,找到后保存行记录**/                btr_pcur_open_with_no_init(clust_index, clust_ref, PAGE_CUR_LE,
                                           BTR_SEARCH_LEAF, &clust_pcur, 0, &mtr); 
               doc_pcur = &clust_pcur;
             }        // 遍历cache->get_docs
            for (ulint i = 0; i < num_idx; ++i) {
                /***** 2. 对文档执行分词,获取{单词,(单词所在的文档,单词在文档中的偏移)}关联对,并添加到cache中 *****/
                fts_doc_t doc;
                fts_doc_init(&doc);
        /** 2.1 根据doc_id获取行记录中该全文索引对应列的内容文档,解析文档,主要是为了构建               fts_doc_t结构体的tokens,tokens为一个红黑树结构,每个元素是一个               {单词,[该单词在文档中出现的位置]}的结构,解析结果存于doc中 **/
                fts_fetch_doc_from_rec(ftt->fts_trx->trx, get_doc, clust_index,doc_pcur, offsets, &doc);
                /** 2.2 将2.1步骤获得的{单词,[该单词在文档中出现的位置]}添加到index_cache中 **/
                fts_cache_add_doc(table->fts->cache, get_doc->index_cache, doc_id, doc.tokens);
               /***** 3. 判断cache->total_size是否达到阈值时。  若达到阈值,则往刷脏线程的消息队列添加一个FTS_MSG_SYNC_TABLE消息, 通知该线程刷脏 *****/
                bool need_sync = false;
                if ((cache->total_size - cache->total_size_before_sync >
                     fts_max_cache_size / 10 || fts_need_sync) &&!cache->sync->in_progress) {
                  /** 3.1 判断是达到阈值 **/
                  need_sync = true;
                  cache->total_size_before_sync = cache->total_size;
                }
                    if (need_sync) {
                    /** 3.2 打包FTS_MSG_SYNC_TABLE消息挂载至fts_optimize_wq队列,                   通知fts_optimize_thread线程刷脏,消息的内容为table id **/                  fts_optimize_request_sync_table(table);
                }
            }
        }
    }  

了解到上面流程后,就能认清MySQL 8.0官方文档所述现象:在全文索引表中插入数据的事务未提交前,通过全文索引查询不到这些新记录。因为全文索引的更新是在事务提交时完成,所以fts_add_doc_by_id还没执行,全文索引查不到。但按3.1节所述,即便如此,InnoDB表中的行记录实际已插入,直接进行非全文索引的查询,如SELECT COUNT(*),能发现这些记录。这就是全文索引处理与事务提交时机的差异所在。

刷脏阶段

刷脏的主要工作流如图:

1715856676412898017.png

InnoDB启动时初始化一个后台任务fts_optimize_thread,使用队列fts_optimize_wq来调度工作。事务提交期间,若缓存满,会通过fts_optimize_request_sync_table向该队列发送FTS_MSG_SYNC_TABLE指令。随后,后台线程处理此消息,负责将缓存数据同步至磁盘,关键函数调用序列如下:

 fts_optimize_thread        ->ib_wqueue_timedwait            ->fts_optimize_sync_table                ->fts_sync_table                    ->fts_sync                        ->fts_sync_commit                            ->fts_cache_clear

后台线程作业概要:

从任务队列fts_optimize_wq取任务;
遇到FTS_MSG_SYNC_TABLE,执行磁盘刷写;
缓存数据迁移至磁盘辅助表;
清理并重置缓存,回到第一步循环。
在事务提交流程(3.2节)中,若缓存占用超限,则安排FTS_MSG_SYNC_TABLE至队列,由后台线程执行刷盘操作,清空缓存。但需注意,即使缓存待刷,新数据仍可写入直至真正清理发生,此乃并发插入引发内存溢出(OOM)的主因。相关修复参见Bug #32831765(SERVER HITS OOM CONDITION WHEN LOADING TWO INNODB)【1】

若服务崩溃前缓存未刷新,重启后首次操作(插入或查询)将触发表重建逻辑,在fts_init_index函数中,借助fts_doc_fetch_by_doc_id及fts_init_recover_doc,依据持久化记录synchronized_doc_id从表中找回丢失的分词数据并恢复缓存【2】

[1] Bug修复详情

[2] OOM查阅链接同上

问题没解决? 我们帮您!

如果您在本文中未能找到解决当前问题的办法,不用担心——正睿专业技术支持团队随时待命

服务项目.png

获取更多帮助