MySQL · 引擎特性 · InnoDB Change Buffer介紹
前言
在前麵幾節我們介紹了undo log、redo log以及InnoDB如何崩潰恢複來實現數據ACID的相關知識。本期我們介紹另外一種重要的數據變更日誌,也就是InnoDB change buffer。 Change buffer的主要目的是將對二級索引的數據操作緩存下來,以此減少二級索引的隨機IO,並達到操作合並的效果。
在MySQL5.5之前的版本中,由於隻支持緩存insert操作,所以最初叫做insert buffer,隻是後來的版本中支持了更多的操作類型緩存,才改叫change buffer。這也是為什麼代碼中有大量的ibuf前綴開頭的函數或變量。為了表達方麵,本文也將change buffer縮寫為ibuf。
由於曆史上ibuf的數據格式曾發生過多次變化,本文討論的相關內容基於如下設定:
版本為5.5及之後的版本,不涉及舊版本的邏輯
innodb_change_buffering設置為ALL,表示緩存所有操作
ibuf btree
change buffer的物理上是一顆普通的btree,存儲在ibdata係統表空間中,根頁為ibdata的第4個page(FSP_IBUF_TREE_ROOT_PAGE_NO)。
ibuf btree通過三列(space id, page no , counter)作為主鍵來唯一決定一條記錄, 其中counter是一個遞增值,目的是為了維持不同操作的有序性,例如可以通過counter來保證在merge時執行如下序列時的循序和用戶操作順序是一致的:INSERT x, DELETE-MARK x, INSERT x。
在插入ibuf記錄前我們是不知道counter的值的,因此總是先將對應tuple的counter設置為0xFFFF,然後將cursor以模式PAGE_CUR_LE定位到小於等於(space id, page no, 0xFFFF)的位置,新記錄的counter為當前位置記錄counter值加1。
ibuf btree最大默認為buffer pool size的25%,當超過25%時,可能觸發用戶線程同步縮減ibuf btree。為何要將ibuf btree的大小和buffer pool大小相關聯呢 ? 一個比較重要的原因是防止ibuf本身占用過多的buffer pool資源。
ibuf bitmap
由於ibuf緩存的操作都是針對某個具體page的,因此在緩存操作時必須保證該操作不會導致空Page 或索引分裂。
針對第一種情況,即避免空page,主要是對Purge線程而言。因為隻有Purge線程才會去真正的刪除二級索引上的物理記錄。在準備插入類型為IBUF_OP_DELETE的操作緩存時,會預估在apply完該page上所有的ibuf entry後還剩下多少記錄(ibuf_get_volume_buffered
), 如果隻剩下一條記錄,則拒絕本次purge操作緩存,改走正常的讀入物理頁邏輯。
針對第二種情況,InnoDB通過一種特殊的Page來維護每個數據頁的空閑空間大小,也就是ibuf bitmap page,該page存在於每個ibd文件中,具有固定的page no,其文件結構如下圖所示:
ibuf bitmap使用4個bit來描述一個page:
(a) IBUF_BITMAP_FREE:使用2個bit來描述空間空間大小,以16KB的Page size為例,能表示的空閑空間範圍為0(0 bytes),1(512 bytes),2(1024 bytes),3(2048 bytes)。很顯然,最大能夠緩存的二級索引記錄最大不可能超過2048字節。
由於隻有INSERT操作才可能導致Page記錄滿,因此隻需要對IBUF_OP_INSERT類型的操作進行判斷:
ibuf_insert_low:
if (op == IBUF_OP_INSERT) {
ulint bits = ibuf_bitmap_page_get_bits(
bitmap_page, page_no, zip_size, IBUF_BITMAP_FREE,
&bitmap_mtr);
if (buffered + entry_size + page_dir_calc_reserved_space(1)
> ibuf_index_page_calc_free_from_bits(zip_size, bits)) {
/* Release the bitmap page latch early. */
ibuf_mtr_commit(&bitmap_mtr);
/* It may not fit */
do_merge = TRUE;
ibuf_get_merge_page_nos(FALSE,
btr_pcur_get_rec(&pcur), &mtr,
space_ids, space_versions,
page_nos, &n_stored);
goto fail_exit;
}
}
其中ibuf_bitmap_page_get_bits
函數根據space id 和page no 獲取對應的bitmap page,找到空閑空間描述信息;如果本次插入操作可能超出限製,則從當前cursor位置附近開始,觸發一次異步的ibuf merge,目的是盡量將當前page的緩存操作做一次合並。
在正常的對物理頁的DML過程中,如果Page內空間發生了變化,總是需要去更新對應的IBUF_BITMAP_FREE值。參考函數:btr_compress、btr_cur_optimistic_insert
。
(b)IBUF_BITMAP_BUFFERED:用於表示該page是否有操作緩存,在ibuf_insert_low
函數中,準備插入ibuf btree前設置成true。二級索引物理頁讀入內存時會根據該標記位判斷是否需要進行ibuf merge操作。
(c) IBUF_BITMAP_IBUF:該數據頁是否是ibuf btree的一部分
該標記位主要用於異步AIO讀操作。InnoDB專門為change buffer模塊分配了一個後台AIO線程,如果page屬於change buffer的b樹,則使用該線程做異步讀,參考函數:ibuf_page_low
操作類型
InnoDB change buffer可以對三種類型的操作進行緩存: INSERT、DELETE-MARK 、DELETE操作,前兩種對應用戶線程操作,第三種則由Purge操作觸發。
用戶可以通過參數innodb_change_buffering來控製緩存何種操作:
/** Allowed values of innodb_change_buffering */
static const char* innobase_change_buffering_values[IBUF_USE_COUNT] = {
"none", /* IBUF_USE_NONE */
"inserts", /* IBUF_USE_INSERT */
"deletes", /* IBUF_USE_DELETE_MARK */
"changes", /* IBUF_USE_INSERT_DELETE_MARK */
"purges", /* IBUF_USE_DELETE */
"all" /* IBUF_USE_ALL */
};
innodb_change_buffering默認值為all,表示緩存所有操作。注意由於在二級索引上的更新操作總是先delete-mark,再insert新記錄,因此update會產生兩條Ibuf entry。
緩存條件
隻有滿足一定條件時,操作才會被緩存,所有對ibuf操作的判斷,都從btr_cur_search_to_nth_level
入口,該函數用於定位到btree上滿足條件的記錄,大概的判斷條件如下:
a. 用戶設置了選項innodb_change_buffering;
b. 隻有葉子節點才會去考慮是否使用ibuf;
c. 對於聚集索引,不可以緩存操作;
d. 對於唯一二級索引(unique key),由於索引記錄具有唯一性,因此無法緩存插入操作,但可以緩存刪除操作;
e. 表上沒有flush 操作,例如執行flush table for export時,不允許對表進行ibuf 緩存 (通過dict_table_t::quiesce
進行標識)
參考函數:ibuf_should_try
當滿足ibuf緩存條件時,會使用兩種模式去嚐試獲取數據頁:
BUF_GET_IF_IN_POOL: 如果數據頁在內存中,則獲取page並返回,否則返回NULL;
BUF_GET_IF_IN_POOL_OR_WATCH:如果數據頁在內存中,則獲取page並返回,否則為請求的page設置一個`sentinel`(buf_pool_watch_set),相當於標記這個page,表示這個page上的記錄正在被purge。(下一小節介紹)
前者是前台用戶線程觸發,後者為Purge線程在物理清除無效數據時觸發。如果數據已經在內存中了,則不進行緩存。
隨後進入函數ibuf_insert, 經過一係列的檢查後(不可產生空page 和 索引分裂、未超出最大ibuf size限製)執行操作緩存。
Purge操作緩存
對於Purge操作,當page不存在於內存時設置的sentinel是什麼鬼? 它是如何設置的,什麼時候會被清理掉,這幾個問題涉及到Purge操作的緩存流程:
a. 如何設置sentinel
當Purge線程嚐試讀入page時,若數據頁不在buffer pool中,則調用函數buf_pool_watch_set
,分為兩步:
Step 1. 首先檢查page hash,如果存在於page hash中:1. 若未被設置成`sentinel` (別的線程將數據頁讀入內存時會清理掉對應標記),返回數據頁;2. 否則返回NULL;
Step 2. 若page hash中不存在,則從buf_pool_t::watch數組中找到一個空閑的(狀態為BUF_BLOCK_POOL_WATCH)page控製結構體對象buf_page_t,將其狀態設置為BUF_BLOCK_ZIP_PAGE,初始化相關變量,並插入到page hash中。buf_pool_t::watch數組的大小為purge線程的個數,這意味著即使所有purge線程同時訪問同一個buffer pool instance,總會擁有一個空閑的watch數組對象。
b. 判斷是否可以緩存Purge操作
當設置sentinel並返回後,在決定緩存purge之前,需要去判斷是否有別的線程對同一條記錄緩存了新的操作,舉個簡單的例子:
Step 1: delete-mark X (sec index), session 1
Step 2: insert X (clust index), session 1
Step 3: delete X(sec index), Purge thread
Step 4: insert X (sec index), session 1
如果二級索引頁在內存中,那麼step 3 和Step4必然是有序的,因為需要獲取block鎖才能進行數據變更操作。但數據頁不在內存時,就需要確保Step 4在Step 3之後執行。因此在緩存purge操作之前,需要根據當前要清理的記錄,找到對應的聚集索引記錄,並檢查相比當前Purge線程的readview是否有新版本的聚集索引記錄(即有新的插入操作發生)。
如果檢查到有新的插入,則本次purge操作直接放棄。因為當符合一定條件時,Step 4的操作可以直接把Step1產生的記錄刪除標記清除掉,重用物理空間。
參考函數:row_purge_poss_sec
但是注意上述檢查流程結束時,會在函數row_purge_poss_sec
中將mtr提交掉,對應的聚集索引頁的Latch會被釋放掉,這意味著可能出現如下序列:
Step 1: delete-mark X;
Step 2: delete X,Purge線程為其設置watch,並完成在函數row_purge_poss_sec中的檢查,準備插入ibuf
Step 3: insert X,索引頁不在內存,準備插入Ibuf
在函數ibuf_insert
中,針對IBUF_OP_INSERT和IBUF_OP_DELETE_MARK操作,會去檢查是否對應的二級索引頁被設置成‘sentinel’ (buf_page_get_also_watch
),如果是的話,表明當前有一個pending的purge操作,目前的處理邏輯是放棄insert和delete-mark的緩存操作,轉而讀取物理頁。
綜上,如果purge操作先進入ibuf_insert
,則對應二級索引頁的watch必然被設置,insert操作將放棄緩存,轉而嚐試讀入索引頁;如果insert先進入ibuf_insert
,則Purge操作的緩存放棄。
即使Purge線程完成一係列檢查,進入緩存階段,這時候用戶線程依舊可能會去讀入物理頁;有沒有可能導致purge操作丟失呢 ?答案是否定的! 因為Purge線程在緩存操作時先將cursor定位到ibuf btree上,對應的ibuf page已將加上latch;而用戶線程如果讀入物理頁,為了Merge ibuf entry,也需要請求page latch; 當Purge線程在拿到latch後,會再檢查一次看看物理頁是否已讀入內存(buf_pool_watch_occurred
),如果是的話,則放棄本次緩存。
c. 何時清理sentinel
有兩種情況會清理sentinel:
第一種情況是Purge操作完成緩存後(或者判斷無法進行Purge緩存)進行清理。
第二種情況是從磁盤讀入文件塊的時候,會調用buf_page_init_for_read->buf_page_init
初始化一個page對象。這時候會做一個判斷,如果將被讀入的page被設置為sentinel(在watch數組中被設置),則調用buf_pool_watch_remove
將其從page hash中移除,對應bp->watch
的數據元素被重置成空閑狀態。
ibuf merge
有以下幾種場景會觸發ibuf merge操作:
a. 用戶線程選擇二級索引進行數據查詢,這時候必須要讀入二級索引頁,相應的ibuf entry需要merge到Page中。
b. 當嚐試緩存插入操作時,如果預估Page的空間不足,可能導致索引分裂,則定位到嚐試緩存的page no在ibuf btree中的位置,最多Merge 8個(IBUF_MERGE_AREA) page, merge方式為異步,即發起異步讀索引頁請求。
參考函數:ibuf_insert_low —> ibuf_get_merge_page_nos_func
c. 若當前ibuf tree size 超過ibuf->max_size + 10(IBUF_CONTRACT_DO_NOT_INSERT
)時,執行一次同步的ibuf merge(ibuf_contract
),merge的page no為隨機定位的cursor,最多一次merge 8個page, 同時放棄本次緩存。
其中ibuf->max_size默認為25% * buffer pool size,百分比由參數innodb_change_buffer_max_size控製,可動態調整。
參考函數:ibuf_insert_low —> ibuf_contract
d. 若本次插入ibuf操作可能產生ibuf btree索引分裂(BTR_MODIFY_TREE)時
當前ibuf->size < ibuf->max_size, 不做處理;
當前ibuf->size >= ibuf->max_size + 5 (IBUF_CONTRACT_ON_INSERT_SYNC)時,執行一次同步ibuf merge,位置隨機;
當前Ibuf->size介於ibuf->max_size 和ibuf->max_size +5 之間時。執行一次異步ibuf merge,位置隨機。
參考函數:ibuf_insert_low —> ibuf_contract_after_insert
e. 後台master線程發起merge
master線程有三種工作狀態:
IDLE:實例處於空閑狀態,以100%的io capacity來作merge操作:
n_pages = PCT_IO(100);
相當於一次merge的page數等於innodb_io_capacity
參考函數:srv_master_do_idle_tasks
ACTIVE:實例處於活躍狀態,這時候會以如下算法計算需要merge的page數:
/* By default we do a batch of 5% of the io_capacity */
n_pages = PCT_IO(5);
mutex_enter(&ibuf_mutex);
/* If the ibuf->size is more than half the max_size
then we make more agreesive contraction.
+1 is to avoid division by zero. */
if (ibuf->size > ibuf->max_size / 2) {
ulint diff = ibuf->size - ibuf->max_size / 2;
n_pages += PCT_IO((diff * 100)
/ (ibuf->max_size + 1));
}
mutex_exit(&ibuf_mutex);
可見在係統active時,會以比較溫和的方式去做merge,如果當前ibuf btree size超過最大值的一半,則嚐試多做一些merge操作。
參考函數: srv_master_do_active_tasks
SHUTDOWN:當執行slow shutdown時,會強製做一次全部的ibuf merge
參考函數:srv_master_do_shutdown_tasks
f. 對某個表執行flush table 操作時,會觸發對該表的強製ibuf merge,例如執行:
flush table tbname for export;
flush table tbname with read lock;
實際上強製ibuf merge主要是為flush for export準備的,當執行該命令後,為了保證能安全的將ibd拷貝到其他實例上, 需要對該表應用全部的ibuf 緩存。
參考函數:row_quiesce_table_start
“著名” bug
在change buffer的應用史上,最著名的bug要屬 bug#61104,其現象為當實例意外crash後,無法從崩潰中恢複,錯誤日誌中報如下斷言:
InnoDB: Failing assertion: page_get_n_recs(page) > 1
最初官方花了很長的時間都沒有找到這個問題的root cause,隻能加了一些代碼邏輯避免不斷crash重啟,讓用戶有機會登錄實例,重建二級索引。
後來Percona的開發人員Alexey Kopytov在bug#66819 提出了該問題的根本原因,指出ibuf entry的刪除和merge 並不是一個原子的操作(即處於兩個mtr事務中),當merge ibuf的mtr提交後crash,就可能在重啟時重複做ibuf merge。如果上次執行DELETE操作導致對應索引頁上記錄數隻剩下一條;第二次apply時認為本次操作會產生空頁,從而導致斷言錯誤。
官方很快根據Alexey的意見做了修複,修複方式也比較簡單:
在第一個mtr裏,merge ibuf entry 到二級索引頁,並標記刪除ibuf entry,提交mtr;
在第二個mtr裏,執行真正的悲觀刪除ibuf entry;
在執行merge操作前,對於被delete mark的ibuf entry,不做merge操作。
具體的參考函數:ibuf_merge_or_delete_for_page
和 ibuf_delete_rec
比較烏龍的是,我們發現第一次修複並沒有處理Purge線程產生的delete緩存;我們將該發現公布到社區,很快得到了響應,並由上遊快速fix掉了,因此完整的補丁分布在兩個版本中:
官方第一次fix(MySQL5.5.29)
官方第二次fix(MySQL5.5.31)
最後更新:2017-04-01 13:44:35