本文分享自華為雲社群《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找到行記錄, 獲取對應的文件;
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函式。
點選關注,第一時間瞭解華為雲新鮮技術~