本文分享自華為雲社區《MySQL全文索引源碼剖析之Insert語句執行過程》 ,作者:GaussDB 資料庫。 1. 背景介紹 全文索引是信息檢索領域的一種常用的技術手段,用於全文搜索問題,即根據單詞,搜索包含該單詞的文檔,比如在瀏覽器中輸入一個關鍵詞,搜索引擎需要找到所有相關的文檔,並且按相關性排 ...
本文分享自華為雲社區《MySQL全文索引源碼剖析之Insert語句執行過程》 ,作者:GaussDB 資料庫。
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,如下圖所示:
每張表維護一個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 寫入行記錄階段
寫入行記錄的主要工作流如下圖所示:
如上圖所示,這一階段最主要是生成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 事務提交階段
事務提交階段的主要工作流如下圖所示:
這一階段是整個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 刷臟階段
刷臟階段的主要工作流如下圖所示:
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
該線程主要執行的操作如下:
- 從fts_optimize_wq隊列取一個消息;
- 判斷消息的類型,若為FTS_MSG_SYNC_TABLE,則執行刷臟;
- 將cache中的內容刷新到磁碟上的輔助表;
- 清空cache, 置cache為初始狀態;
- 返回至步驟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函數。