MySQL全文索引源碼剖析之Insert語句執行過程

本文分享自華爲雲社區《MySQL全文索引源碼剖析之Insert語句執行過程》 ,作者:GaussDB 數據庫。

0.PNG

1. 背景介紹

全文索引是信息檢索領域的一種常用的技術手段,用於全文搜索問題,即根據單詞,搜索包含該單詞的文檔,比如在瀏覽器中輸入一個關鍵詞,搜索引擎需要找到所有相關的文檔,並且按相關性排好序。

全文索引的底層實現是基於倒排索引。所謂倒排索引,描述的是單詞和文檔的映射關係,表現形式爲(單詞,(單詞所在的文檔,單詞在文檔中的偏移)),下文的示例將會展示全文索引的組織方式:

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.'爲例,'Call me Ishmael.'也即文檔,其ID爲3,在構建全文索引時,該文檔會被分成3個單詞'call', 'me', 'ishmael',由於'me'小於設定的ft_min_word_len(4)最小單詞長度被丟棄,最後全文索引中只會記錄'call'和'ishmael',其中'call'起始位置在文檔中的第0個字符處,偏移爲0,'ishmael'起始位置在文檔中的第12個字符處,偏移即爲12。

關於全文索引更詳細的功能介紹可以參考MySQL 8.0 Reference Manual,本文將從源碼層面,簡要剖析Insert語句的執行過程。

2. 全文索引Cache

全文索引表中記錄的是{單詞,{文檔ID,出現的位置}},即插入一個文檔需要將其分詞成多個{單詞,{文檔ID,出現的位置}}這樣的結構,如果每次分詞完就馬上刷磁盤,其性能會非常差。

爲了緩解該問題,Innodb引入了全文索引cache,其作用與Change Buffer類似。每次插入一個文檔時,先將分詞結果緩存到cache,等到cache滿了再批量刷到磁盤,從而避免頻繁地刷盤。Innodb定義了fts_cache_t的結構來管理cache,如下圖所示:

1.png

每張表維護一個cache,對於每個創建了全文索引的表都會在內存中創建一個fts_cache_t的對象。注意,fts_cache_t是表級別的cache, 若一個表創建了多個全文索引,內存中依舊是對應一個fts_cache_t對象。fts_cache_t的一些重要成員如下:

  • optimize_lock、deleted_lock、doc_id_lock:互斥鎖,與併發操作相關。
  • deleted_doc_ids:vector類型,存儲已刪除的doc_id。
  • indexes:vector類型,每個元素表示一個全文索引,每次創建全文索引時,都會往該數組中添加一個元素,每個索引的分詞結果以紅黑樹結構存儲,key爲word, value就是doc_id及單詞的偏移。
  • total_size:cache已分配的全部內存,包含其子結構使用的內存。

3. Insert語句執行過程

以MySQL 8.0.22源碼爲例,Insert語句的執行主要分爲三個階段,分別爲寫入行記錄階段、事務提交階段和刷髒階段。

3.1 寫入行記錄階段

寫入行記錄的主要工作流如下圖所示:

2.png

如上圖所示,這一階段最主要是生成doc_id,並寫入到Innodb的行記錄中,並且將doc_id緩存,以供事務提交階段根據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是比較重要的兩個函數,fts_get_next_doc_id是爲了獲取doc_id,innodb行記錄中包含了一些隱藏列,比如row_id、trx_id等,若創建了全文索引,其行記錄中也會增加一個隱藏字段FTS_DOC_ID,這個值在fts_get_next_doc_id中獲取的,如下:

而fts_trx_add_op則是將對全文索引的操作添加到trx中,待事務提交時進一步處理。

3.2 事務提交階段

事務提交階段的主要工作流如下圖所示:

3.png

這一階段是整個FTS 插入的最重要的一步,對文檔進行分詞,獲取{單詞,{文檔ID,出現的位置}},插入到cache,這些都是在這一階段完成的。其函數調用棧如下:

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是比較關鍵的一個函數,該函數主要完成了以下幾件事:

1)根據doc_id找到行記錄, 獲取對應的文檔;

2)對文檔執行分詞,獲取 {單詞,(單詞所在的文檔,單詞在文檔中的偏移)}關聯對,並添加到cache中;
3)判斷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 Reference Manual 的InnoDB Full-Text Index Transaction Handling一節,若對全文索引表插入一些行記錄,如果當前事務未提交,我們在當前事務中通過全文索引是查不到已插入的行記錄。其原因在於,全文索引的更新是在事務提交的時完成的,事務未提交時,fts_add_doc_by_id尚未執行,因此,不能通過全文索引查找該記錄。但是,通過3.1小節可以知道,此時Innodb的行記錄是已經插入了的,如果通過全文索引查詢,直接執行SELECT COUNT(*) FROM opening_lines是可以查到該記錄的。

3.3 刷髒階段

刷髒階段的主要工作流如下圖所示:

4.png

InnoDB啓動時,會創建一個後臺線程,線程函數爲fts_optimize_thread,工作隊列爲fts_optimize_wq。3.2節事務提交階段,當cache滿時fts_optimize_request_sync_table函數會往fts_optimize_wq隊列添加一個FTS_MSG_SYNC_TABLE消息,後臺線程取下該消息後將cache刷新到磁盤。其函數調用棧如下:

 

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

該線程主要執行的操作如下:

  1. 從fts_optimize_wq隊列取一個消息;
  2. 判斷消息的類型,若爲FTS_MSG_SYNC_TABLE,則執行刷髒;
  3. 將cache中的內容刷新到磁盤上的輔助表;
  4. 清空cache, 置cache爲初始狀態;
  5. 返回至步驟1,取下一個消息;

在3.2節中,當事務提交時,若fts cache的total_size大於了設定的內存大小閾值,則會寫入一條FTS_MSG_SYNC_TABLE插入到fts_optimize_wq隊列,刷髒線程會處理該消息,將fts cache中的數據刷到磁盤,隨後清空cache。

值得一提的是,當fts cache的total_size大於設定的內存大小閾值時,只會寫條消息到fts_optimize_wq隊列,此時fts cache在未被後臺刷髒線程處理之前,依然可以寫入數據,內存會繼續增加,這也是導致了全文索引併發插入的OOM問題的根因,問題的修復patch Bug #32831765 SERVER HITS OOM CONDITION WHEN LOADING TWO INNODB,感興趣的讀者可以自行查閱。

OOM查閱鏈接:https://bugs.mysql.com/bug.php?id=103523

若刷髒線程還未對某個表的fts cache刷髒,此時MySQL進程crash了,cache中的數據丟失。重啓之後,第一次對該表執行insert或者select時,在fts_init_index函數中會對crash之前cache中的數據進行恢復,此時會從config表中讀取已落盤的synced_doc_id, 將表中大於synced_doc_id的記錄讀取並分詞恢復到cache中,具體實現參考fts_doc_fetch_by_doc_id, fts_init_recover_doc函數。

點擊關注,第一時間瞭解華爲雲新鮮技術~

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章