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

华为云开发者联盟發表於2024-05-20

本文分享自華為雲社群《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中獲取的,如下:

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

而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函式。

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

相關文章