MySQL 引擎特性:InnoDB Buffer Pool

發表於2017-12-13

前言

使用者對資料庫的最基本要求就是能高效的讀取和儲存資料,但是讀寫資料都涉及到與低速的裝置互動,為了彌補兩者之間的速度差異,所有資料庫都有快取池,用來管理相應的資料頁,提高資料庫的效率,當然也因為引入了這一中間層,資料庫對記憶體的管理變得相對比較複雜。本文主要分析MySQL Buffer Pool的相關技術以及實現原理,原始碼基於阿里雲RDS MySQL 5.6分支,其中部分特性已經開源到AliSQL。Buffer Pool相關的原始碼在buf目錄下,主要包括LRU List,Flu List,Double write buffer, 預讀預寫,Buffer Pool預熱,壓縮頁記憶體管理等模組,包括標頭檔案和IC檔案,一共兩萬行程式碼。

基礎知識

Buffer Pool Instance: 大小等於innodb_buffer_pool_size/innodb_buffer_pool_instances,每個instance都有自己的鎖,訊號量,物理塊(Buffer chunks)以及邏輯連結串列(下面的各種List),即各個instance之間沒有競爭關係,可以併發讀取與寫入。所有instance的物理塊(Buffer chunks)在資料庫啟動的時候被分配,直到資料庫關閉記憶體才予以釋放。當innodb_buffer_pool_size小於1GB時候,innodb_buffer_pool_instances被重置為1,主要是防止有太多小的instance從而導致效能問題。每個Buffer Pool Instance有一個page hash連結串列,通過它,使用space_id和page_no就能快速找到已經被讀入記憶體的資料頁,而不用線性遍歷LRU List去查詢。注意這個hash表不是InnoDB的自適應雜湊,自適應雜湊是為了減少Btree的掃描,而page hash是為了避免掃描LRU List。
資料頁: InnoDB中,資料管理的最小單位為頁,預設是16KB,頁中除了儲存使用者資料,還可以儲存控制資訊的資料。InnoDB IO子系統的讀寫最小單位也是頁。如果對錶進行了壓縮,則對應的資料頁稱為壓縮頁,如果需要從壓縮頁中讀取資料,則壓縮頁需要先解壓,形成解壓頁,解壓頁為16KB。壓縮頁的大小是在建表的時候指定,目前支援16K,8K,4K,2K,1K。即使壓縮頁大小設為16K,在blob/varchar/text的型別中也有一定好處。假設指定的壓縮頁大小為4K,如果有個資料頁無法被壓縮到4K以下,則需要做B-tree分裂操作,這是一個比較耗時的操作。正常情況下,Buffer Pool中會把壓縮和解壓頁都快取起來,當Free List不夠時,按照系統當前的實際負載來決定淘汰策略。如果系統瓶頸在IO上,則只驅逐解壓頁,壓縮頁依然在Buffer Pool中,否則解壓頁和壓縮頁都被驅逐。
Buffer Chunks: 包括兩部分:資料頁和資料頁對應的控制體,控制體中有指標指向資料頁。Buffer Chunks是最低層的物理塊,在啟動階段從作業系統申請,直到資料庫關閉才釋放。通過遍歷chunks可以訪問幾乎所有的資料頁,有兩種狀態的資料頁除外:沒有被解壓的壓縮頁(BUF_BLOCK_ZIP_PAGE)以及被修改過且解壓頁已經被驅逐的壓縮頁(BUF_BLOCK_ZIP_DIRTY)。此外資料頁裡面不一定都存的是使用者資料,開始是控制資訊,比如行鎖,自適應雜湊等。
邏輯連結串列: 連結串列節點是資料頁的控制體(控制體中有指標指向真正的資料頁),連結串列中的所有節點都有同一的屬性,引入其的目的是方便管理。下面其中連結串列都是邏輯連結串列。
Free List: 其上的節點都是未被使用的節點,如果需要從資料庫中分配新的資料頁,直接從上獲取即可。InnoDB需要保證Free List有足夠的節點,提供給使用者執行緒用,否則需要從FLU List或者LRU List淘汰一定的節點。InnoDB初始化後,Buffer Chunks中的所有資料頁都被加入到Free List,表示所有節點都可用。
LRU List: 這個是InnoDB中最重要的連結串列。所有新讀取進來的資料頁都被放在上面。連結串列按照最近最少使用演算法排序,最近最少使用的節點被放在連結串列末尾,如果Free List裡面沒有節點了,就會從中淘汰末尾的節點。LRU List還包含沒有被解壓的壓縮頁,這些壓縮頁剛從磁碟讀取出來,還沒來的及被解壓。LRU List被分為兩部分,預設前5/8為young list,儲存經常被使用的熱點page,後3/8為old list。新讀入的page預設被加在old list頭,只有滿足一定條件後,才被移到young list上,主要是為了預讀的資料頁和全表掃描汙染buffer pool。
FLU List: 這個連結串列中的所有節點都是髒頁,也就是說這些資料頁都被修改過,但是還沒來得及被重新整理到磁碟上。在FLU List上的頁面一定在LRU List上,但是反之則不成立。一個資料頁可能會在不同的時刻被修改多次,在資料頁上記錄了最老(也就是第一次)的一次修改的lsn,即oldest_modification。不同資料頁有不同的oldest_modification,FLU List中的節點按照oldest_modification排序,連結串列尾是最小的,也就是最早被修改的資料頁,當需要從FLU List中淘汰頁面時候,從連結串列尾部開始淘汰。加入FLU List,需要使用flush_list_mutex保護,所以能保證FLU List中節點的順序。
Quick List: 這個連結串列是阿里雲RDS MySQL 5.6加入的,使用帶Hint的SQL查詢語句,可以把所有這個查詢的用到的資料頁加入到Quick List中,一旦這個語句結束,就把這個資料頁淘汰,主要作用是避免LRU List被全表掃描汙染。
Unzip LRU List: 這個連結串列中儲存的資料頁都是解壓頁,也就是說,這個資料頁是從一個壓縮頁通過解壓而來的。
Zip Clean List: 這個連結串列只在Debug模式下有,主要是儲存沒有被解壓的壓縮頁。這些壓縮頁剛剛從磁碟讀取出來,還沒來的及被解壓,一旦被解壓後,就從此連結串列中刪除,然後加入到Unzip LRU List中。
Zip Free: 壓縮頁有不同的大小,比如8K,4K,InnoDB使用了類似記憶體管理的夥伴系統來管理壓縮頁。Zip Free可以理解為由5個連結串列構成的一個二維陣列,每個連結串列分別儲存了對應大小的記憶體碎片,例如8K的連結串列裡儲存的都是8K的碎片,如果新讀入一個8K的頁面,首先從這個連結串列中查詢,如果有則直接返回,如果沒有則從16K的連結串列中分裂出兩個8K的塊,一個被使用,另外一個放入8K連結串列中。

核心資料結構

InnoDB Buffer Pool有三種核心的資料結構:buf_pool_t,buf_block_t,buf_page_t。
but_pool_t: 儲存Buffer Pool Instance級別的控制資訊,例如整個Buffer Pool Instance的mutex,instance_no, page_hash,old_list_pointer等。還儲存了各種邏輯連結串列的連結串列根節點。Zip Free這個二維陣列也在其中。
buf_block_t: 這個就是資料頁的控制體,用來描述資料頁部分的資訊(大部分資訊在buf_page_t中)。buf_block_t中第一欄位就是buf_page_t,這個不是隨意放的,是必須放在第一欄位,因為只有這樣buf_block_t和buf_page_t兩種型別的指標可以相互轉換。第二個欄位是frame欄位,指向真正存資料的資料頁。buf_block_t還儲存了Unzip LRU List連結串列的根節點。另外一個比較重要的欄位就是block級別的mutex。
buf_page_t: 這個可以理解為另外一個資料頁的控制體,大部分的資料頁資訊存在其中,例如space_id, page_no, page state, newest_modification,oldest_modification,access_time以及壓縮頁的所有資訊等。壓縮頁的資訊包括壓縮頁的大小,壓縮頁的資料指標(真正的壓縮頁資料是儲存在由夥伴系統分配的資料頁上)。這裡需要注意一點,如果某個壓縮頁被解壓了,解壓頁的資料指標是儲存在buf_block_t的frame欄位裡。

這裡介紹一下buf_page_t中的state欄位,這個欄位主要用來表示當前頁的狀態。一共有八種狀態。這八種狀態對初學者可能比較難理解,尤其是前三種,如果看不懂可以先跳過。
BUF_BLOCK_POOL_WATCH: 這種型別的page是提供給purge執行緒用的。InnoDB為了實現多版本,需要把之前的資料記錄在undo log中,如果沒有讀請求再需要它,就可以通過purge執行緒刪除。換句話說,purge執行緒需要知道某些資料頁是否被讀取,現在解法就是首先檢視page hash,看看這個資料頁是否已經被讀入,如果沒有讀入,則獲取(啟動時候通過malloc分配,不在Buffer Chunks中)一個BUF_BLOCK_POOL_WATCH型別的哨兵資料頁控制體,同時加入page_hash但是沒有真正的資料(buf_blokc_t::frame為空)並把其型別置為BUF_BLOCK_ZIP_PAGE(表示已經被使用了,其他purge執行緒就不會用到這個控制體了),相關函式buf_pool_watch_set,如果檢視page hash後發現有這個資料頁,只需要判斷控制體在記憶體中的地址是否屬於Buffer Chunks即可,如果是表示對應資料頁已經被其他執行緒讀入了,相關函式buf_pool_watch_occurred。另一方面,如果使用者執行緒需要這個資料頁,先檢視page hash看看是否是BUF_BLOCK_POOL_WATCH型別的資料頁,如果是則回收這個BUF_BLOCK_POOL_WATCH型別的資料頁,從Free List中(即在Buffer Chunks中)分配一個空閒的控制體,填入資料。這裡的核心思想就是通過控制體在記憶體中的地址來確定資料頁是否還在被使用。
BUF_BLOCK_ZIP_PAGE: 當壓縮頁從磁碟讀取出來的時候,先通過malloc分配一個臨時的buf_page_t,然後從夥伴系統中分配出壓縮頁儲存的空間,把磁碟中讀取的壓縮資料存入,然後把這個臨時的buf_page_t標記為BUF_BLOCK_ZIP_PAGE狀態(buf_page_init_for_read),只有當這個壓縮頁被解壓了,state欄位才會被修改為BUF_BLOCK_FILE_PAGE,並加入LRU List和Unzip LRU List(buf_page_get_gen)。如果一個壓縮頁對應的解壓頁被驅逐了,但是需要保留這個壓縮頁且壓縮頁不是髒頁,則這個壓縮頁被標記為BUF_BLOCK_ZIP_PAGE(buf_LRU_free_page)。所以正常情況下,處於BUF_BLOCK_ZIP_PAGE狀態的不會很多。前述兩種被標記為BUF_BLOCK_ZIP_PAGE的壓縮頁都在LRU List中。另外一個用法是,從BUF_BLOCK_POOL_WATCH型別節點中,如果被某個purge執行緒使用了,也會被標記為BUF_BLOCK_ZIP_PAGE。
BUF_BLOCK_ZIP_DIRTY: 如果一個壓縮頁對應的解壓頁被驅逐了,但是需要保留這個壓縮頁且壓縮頁是髒頁,則被標記為BUF_BLOCK_ZIP_DIRTY(buf_LRU_free_page),如果該壓縮頁又被解壓了,則狀態會變為BUF_BLOCK_FILE_PAGE。因此BUF_BLOCK_ZIP_DIRTY也是一個比較短暫的狀態。這種型別的資料頁都在Flush List中。
BUF_BLOCK_NOT_USED: 當連結串列處於Free List中,狀態就為此狀態。是一個能長期存在的狀態。
BUF_BLOCK_READY_FOR_USE: 當從Free List中,獲取一個空閒的資料頁時,狀態會從BUF_BLOCK_NOT_USED變為BUF_BLOCK_READY_FOR_USE(buf_LRU_get_free_block),也是一個比較短暫的狀態。處於這個狀態的資料頁不處於任何邏輯連結串列中。
BUF_BLOCK_FILE_PAGE: 正常被使用的資料頁都是這種狀態。LRU List中,大部分資料頁都是這種狀態。壓縮頁被解壓後,狀態也會變成BUF_BLOCK_FILE_PAGE。
BUF_BLOCK_MEMORY: Buffer Pool中的資料頁不僅可以儲存使用者資料,也可以儲存一些系統資訊,例如InnoDB行鎖,自適應雜湊索引以及壓縮頁的資料等,這些資料頁被標記為BUF_BLOCK_MEMORY。處於這個狀態的資料頁不處於任何邏輯連結串列中
BUF_BLOCK_REMOVE_HASH: 當加入Free List之前,需要先把page hash移除。因此這種狀態就表示此頁面page hash已經被移除,但是還沒被加入到Free List中,是一個比較短暫的狀態。
總體來說,大部分資料頁都處於BUF_BLOCK_NOT_USED(全部在Free List中)和BUF_BLOCK_FILE_PAGE(大部分處於LRU List中,LRU List中還包含除被purge執行緒標記的BUF_BLOCK_ZIP_PAGE狀態的資料頁)狀態,少部分處於BUF_BLOCK_MEMORY狀態,極少處於其他狀態。前三種狀態的資料頁都不在Buffer Chunks上,對應的控制體都是臨時分配的,InnoDB把他們列為invalid state(buf_block_state_valid)。
如果理解了這八種狀態以及其之間的轉換關係,那麼閱讀Buffer pool的程式碼細節就會更加遊刃有餘。

接下來,簡單介紹一下buf_page_t中buf_fix_count和io_fix兩個變數,這兩個變數主要用來做併發控制,減少mutex加鎖的範圍。當從buffer pool讀取一個資料頁時候,會其加讀鎖,然後遞增buf_page_t::buf_fix_count,同時設定buf_page_t::io_fix為BUF_IO_READ,然後即可以釋放讀鎖。後續如果其他執行緒在驅逐資料頁(或者刷髒)的時候,需要先檢查一下這兩個變數,如果buf_page_t::buf_fix_count不為零且buf_page_t::io_fix不為BUF_IO_NONE,則不允許驅逐(buf_page_can_relocate)。這裡的技巧主要是為了減少資料頁控制體上mutex的爭搶,而對資料頁的內容,讀取的時候依然要加讀鎖,修改時加寫鎖。

Buffer Pool記憶體初始化

Buffer Pool的記憶體初始化,主要是Buffer Chunks的記憶體初始化,buffer pool instance一個一個輪流初始化。核心函式為buf_chunk_initos_mem_alloc_large
。閱讀程式碼可以發現,目前從作業系統分配記憶體有兩種方式,一種是通過HugeTLB的方式來分配,另外一種使用傳統的mmap來分配。
HugeTLB: 這是一種大記憶體塊的分配管理技術。類似資料庫對資料的管理,記憶體也按照頁來管理,預設的頁大小為4KB,HugeTLB就是把頁大小提高到2M或者更加多。程式傳送給cpu都是虛擬記憶體地址,cpu必須通過快表來對映到真正的實體記憶體地址。快表的全集放在記憶體中,部分熱點記憶體頁可以放在cpu cache中,從而提高記憶體訪問效率。假設cpu cache為100KB,每條快表佔用1KB,頁大小為4KB,則熱點記憶體頁為100KB/1KB=100條,覆蓋1004KB=400KB的記憶體資料,但是如果也預設頁大小為2M,則同樣大小的cpu cache,可以覆蓋1002M=200MB的記憶體資料,也就是說,訪問200MB的資料只需要一次讀取記憶體即可(如果對映關係沒有在cache中找到,則需要先把對映關係從記憶體中讀到cache,然後查詢,最後再去讀記憶體中需要的資料,會造成兩次訪問實體記憶體)。也就是說,使用HugeTLB這種大記憶體技術,可以提高快表的命中率,從而提高訪問記憶體的效能。當然這個技術也不是銀彈,記憶體頁變大了也必定會導致更多的頁內的碎片。如果需要從swap分割槽中載入虛擬記憶體,也會變慢。當然最終要的理由是,4KB大小的記憶體頁已經被業界穩定使用很多年了,如果沒有特殊的需求不需要冒這個風險。在InnoDB中,如果需要用到這項技術可以使用super-large-pages引數啟動MySQL。
mmap分配: 在Linux下,多個程式需要共享一片記憶體,可以使用mmap來分配和繫結,所以只提供給一個MySQL程式使用也是可以的。用mmap分配的記憶體都是虛存,在top命令中佔用VIRT這一列,而不是RES這一列,只有相應的記憶體被真正使用到了,才會被統計到RES中,提高記憶體使用率。這樣是為什麼常常看到MySQL一啟動就被分配了很多的VIRT,而RES卻是慢慢漲上來的原因。這裡大家可能有個疑問,為啥不用malloc。其實查閱malloc文件,可以發現,當請求的記憶體數量大於MMAP_THRESHOLD(預設為128KB)時候,malloc底層就是呼叫了mmap。在InnoDB中,預設使用mmap來分配。
分配完了記憶體,buf_chunk_init函式中,把這片記憶體劃分為兩個部分,前一部分是資料頁控制體(buf_block_t),在阿里雲RDS MySQL 5.6 release版本中,每個buf_block_t是424位元組,一共有innodb_buffer_pool_size/UNIV_PAGE_SIZE個。後一部分是真正的資料頁,按照UNIV_PAGE_SIZE分隔。假設page大小為16KB,則資料頁控制體佔的記憶體:資料頁約等於1:38.6,也就是說如果innodb_buffer_pool_size被配置為40G,則需要額外的1G多空間來存資料頁的控制體。
劃分完空間後,遍歷資料頁控制體,設定buf_block_t::frame指標,指向真正的資料頁,然後把這些資料頁加入到Free List中即可。初始化完Buffer Chunks的記憶體,還需要初始化BUF_BLOCK_POOL_WATCH型別的資料頁控制塊,page hash的結構體,zip hash的結構體(所有被壓縮頁的夥伴系統分配走的資料頁面會加入到這個雜湊表中)。注意這些記憶體是額外分配的,不包含在Buffer Chunks中。
除了buf_pool_init外,建議讀者參考一下but_pool_free這個記憶體釋放函式,加深對Buffer Pool相關記憶體的理解。

Buf_page_get函式解析

這個函式極其重要,是其他模組獲取資料頁的外部介面函式。如果請求的資料頁已經在Buffer Pool中了,修改相應資訊後,就直接返回對應資料頁指標,如果Buffer Pool中沒有相關資料頁,則從磁碟中讀取。Buf_page_get是一個巨集定義,真正的函式為buf_page_get_gen,引數主要為space_id, page_no, lock_type, mode以及mtr。這裡主要介紹一個mode這個引數,其表示讀取的方式,目前支援六種,前三種用的比較多。
BUF_GET: 預設獲取資料頁的方式,如果資料頁不在Buffer Pool中,則從磁碟讀取,如果已經在Buffer Pool中,需要判斷是否要把他加入到young list中以及判斷是否需要進行線性預讀。如果是讀取則加讀鎖,修改則加寫鎖。
BUF_GET_IF_IN_POOL: 只在Buffer Pool中查詢這個資料頁,如果在則判斷是否要把它加入到young list中以及判斷是否需要進行線性預讀。如果不在則直接返回空。加鎖方式與BUF_GET類似。
BUF_PEEK_IF_IN_POOL: 與BUF_GET_IF_IN_POOL類似,只是即使條件滿足也不把它加入到young list中也不進行線性預讀。加鎖方式與BUF_GET類似。
BUF_GET_NO_LATCH: 不管對資料頁是讀取還是修改,都不加鎖。其他方面與BUF_GET類似。
BUF_GET_IF_IN_POOL_OR_WATCH: 只在Buffer Pool中查詢這個資料頁,如果在則判斷是否要把它加入到young list中以及判斷是否需要進行線性預讀。如果不在則設定watch。加鎖方式與BUF_GET類似。這個是要是給purge執行緒用。
BUF_GET_POSSIBLY_FREED: 這個mode與BUF_GET類似,只是允許相應的資料頁在函式執行過程中被釋放,主要用在估算Btree兩個slot之前的資料行數。
接下來,我們簡要分析一下這個函式的主要邏輯。

  • 首先通過buf_pool_get函式依據space_id和page_no查詢指定的資料頁在那個Buffer Pool Instance裡面。演算法很簡單instance_no = (space_id << 20 + space_id + page_no >> 6) % instance_num,也就是說先通過space_id和page_no算出一個fold value然後按照instance的個數取餘數即可。這裡有個小細節,page_no的第六位被砍掉,這是為了保證一個extent的資料能被快取到同一個Buffer Pool Instance中,便於後面的預讀操作。
  • 接著,呼叫buf_page_hash_get_low函式在page hash中查詢這個資料頁是否已經被載入到對應的Buffer Pool Instance中,如果沒有找到這個資料頁且mode為BUF_GET_IF_IN_POOL_OR_WATCH則設定watch資料頁(buf_pool_watch_set),接下來,如果沒有找到資料頁且mode為BUF_GET_IF_IN_POOL、BUF_PEEK_IF_IN_POOL或者BUF_GET_IF_IN_POOL_OR_WATCH函式直接返回空,表示沒有找到資料頁。如果沒有找到資料但是mode為其他,就從磁碟中同步讀取(buf_read_page)。在讀取磁碟資料之前,我們如果發現需要讀取的是非壓縮頁,則先從Free List中獲取空閒的資料頁,如果Free List中已經沒有了,則需要通過刷髒來釋放資料頁,這裡的一些細節我們後續在LRU模組再分析,獲取到空閒的資料頁後,加入到LRU List中(buf_page_init_for_read)。在讀取磁碟資料之前,我們如果發現需要讀取的是壓縮頁,則臨時分配一個buf_page_t用來做控制體,通過夥伴系統分配到壓縮頁存資料的空間,最後同樣加入到LRU List中(buf_page_init_for_read)。做完這些後,我們就呼叫IO子系統的介面同步讀取頁面資料,如果讀取資料失敗,我們重試100次(BUF_PAGE_READ_MAX_RETRIES)然後觸發斷言,如果成功則判斷是否要進行隨機預讀(隨機預讀相關的細節我們也在預讀預寫模組分析)。
  • 接著,讀取資料成功後,我們需要判斷讀取的資料頁是不是壓縮頁,如果是的話,因為從磁碟中讀取的壓縮頁的控制體是臨時分配的,所以需要重新分配block(buf_LRU_get_free_block),把臨時分配的buf_page_t給釋放掉,用buf_relocate函式替換掉,接著進行解壓,解壓成功後,設定state為BUF_BLOCK_FILE_PAGE,最後加入Unzip LRU List中。
  • 接著,我們判斷這個頁是否是第一次訪問,如果是則設定buf_page_t::access_time,如果不是,我們則判斷其是不是在Quick List中,如果在Quick List中且當前事務不是加過Hint語句的事務,則需要把這個資料頁從Quick List刪除,因為這個頁面被其他的語句訪問到了,不應該在Quick List中了。
  • 接著,如果mode不為BUF_PEEK_IF_IN_POOL,我們需要判斷是否把這個資料頁移到young list中,具體細節在後面LRU模組中分析。
  • 接著,如果mode不為BUF_GET_NO_LATCH,我們給資料頁加上讀寫鎖。
  • 最後,如果mode不為BUF_PEEK_IF_IN_POOL且這個資料頁是第一次訪問,則判斷是否需要進行線性預讀(線性預讀相關的細節我們也在預讀預寫模組分析)。

LRU List中young list和old list的維護

當LRU List連結串列大於512(BUF_LRU_OLD_MIN_LEN)時,在邏輯上被分為兩部分,前面部分儲存最熱的資料頁,這部分連結串列稱作young list,後面部分則儲存冷資料頁,這部分稱作old list,一旦Free List中沒有頁面了,就會從冷頁面中驅逐。兩部分的長度由引數innodb_old_blocks_pct控制。每次加入或者驅逐一個資料頁後,都要調整young list和old list的長度(buf_LRU_old_adjust_len),同時引入BUF_LRU_OLD_TOLERANCE來防止連結串列調整過頻繁。當LRU List連結串列小於512,則只有old list。
新讀取進來的頁面預設被放在old list頭,在經過innodb_old_blocks_time後,如果再次被訪問了,就挪到young list頭上。一個資料頁被讀入Buffer Pool後,在小於innodb_old_blocks_time的時間內被訪問了很多次,之後就不再被訪問了,這樣的資料頁也很快被驅逐。這個設計認為這種資料頁是不健康的,應該被驅逐。
此外,如果一個資料頁已經處於young list,當它再次被訪問的時候,不會無條件的移動到young list頭上,只有當其處於young list長度的1/4(大約值)之後,才會被移動到young list頭部,這樣做的目的是減少對LRU List的修改,否則每訪問一個資料頁就要修改連結串列一次,效率會很低,因為LRU List的根本目的是保證經常被訪問的資料頁不會被驅逐出去,因此只需要保證這些熱點資料頁在頭部一個可控的範圍內即可。相關邏輯可以參考函式buf_page_peek_if_too_old

buf_LRU_get_free_block函式解析

這個函式以及其呼叫的函式可以說是整個LRU模組最重要的函式,在整個Buffer Pool模組中也有舉足輕重的作用。如果能把這幾個函式吃透,相信其他函式很容易就能讀懂。

  • 首先,如果是使用ENGINE_NO_CACHE傳送過來的SQL需要讀取資料,則優先從Quick List中獲取(buf_quick_lru_get_free)。
  • 接著,統計Free List和LRU List的長度,如果發現他們再Buffer Chunks佔用太少的空間,則表示太多的空間被行鎖,自使用雜湊等內部結構給佔用了,一般這些都是大事務導致的。這時候會給出報警。
  • 接著,檢視Free List中是否還有空閒的資料頁(buf_LRU_get_free_only),如果有則直接返回,否則進入下一步。大多數情況下,這一步都能找到空閒的資料頁。
  • 如果Free List中已經沒有空閒的資料頁了,則會嘗試驅逐LRU List末尾的資料頁。如果系統有壓縮頁,情況就有點複雜,InnoDB會呼叫buf_LRU_evict_from_unzip_LRU來決定是否驅逐壓縮頁,如果Unzip LRU List大於LRU List的十分之一或者當前InnoDB IO壓力比較大,則會優先從Unzip LRU List中把解壓頁給驅逐,否則會從LRU List中把解壓頁和壓縮頁同時驅逐。不管走哪條路徑,最後都呼叫了函式buf_LRU_free_page來執行驅逐操作,這個函式由於要處理壓縮頁解壓頁各種情況,極其複雜。大致的流程:首先判斷是否是髒頁,如果是則不驅逐,否則從LRU List中把連結串列刪除,必要的話還從Unzip LRU List移走這個資料頁(buf_LRU_block_remove_hashed),接著如果我們選擇保留壓縮頁,則需要重新建立一個壓縮頁控制體,插入LRU List中,如果是髒的壓縮頁還要插入到Flush List中,最後才把刪除的資料頁插入到Free List中(buf_LRU_block_free_hashed_page)。
  • 如果在上一步中沒有找到空閒的資料頁,則需要刷髒了(buf_flush_single_page_from_LRU),由於buf_LRU_get_free_block這個函式是在使用者執行緒中呼叫的,所以即使要刷髒,這裡也是刷一個髒頁,防止刷過多的髒頁阻塞使用者執行緒。
  • 如果上一步的刷髒因為資料頁被其他執行緒讀取而不能刷髒,則重新跳轉到上述第二步。進行第二輪迭代,與第一輪迭代的區別是,第一輪迭代在掃描LRU List時,最多隻掃描innodb_lru_scan_depth個,而在第二輪迭代開始,掃描整個LRU List。如果很不幸,這一輪還是沒有找到空閒的資料頁,從三輪迭代開始,在刷髒前等待10ms。
  • 最終找到一個空閒頁後,page的state為BUF_BLOCK_READY_FOR_USE。

控制全表掃描不增加cache資料到Buffer Pool

全表掃描對Buffer Pool的影響比較大,即使有old list作用,但是old list預設也佔Buffer Pool的3/8。因此,阿里雲RDS引入新的語法ENGINE_NO_CACHE(例如:SELECT ENGINE_NO_CACHE count(*) FROM t1)。如果一個SQL語句中帶了ENGINE_NO_CACHE這個關鍵字,則由它讀入記憶體的數取據頁都放入Quick List中,當這個語句結束時,會刪除它獨佔的資料頁。同時引入兩個引數。innodb_rds_trx_own_block_max這個引數控制使用Hint的每個事物最多能擁有多少個資料頁,如果超過這個資料就開始驅逐自己已有的資料頁,防止大事務佔用過多的資料頁。innodb_rds_quick_lru_limit_per_instance這個引數控制每個Buffer Pool Instance中Quick List的長度,如果超過這個長度,後續的請求都從Quick List中驅逐資料頁,進而獲取空閒資料頁。

刪除指定表空間所有的資料頁

函式(buf_LRU_remove_pages)提供了三種模式,第一種(BUF_REMOVE_ALL_NO_WRITE),刪除Buffer Pool中所有這個型別的資料頁(LRU List和Flush List)同時Flush List中的資料頁也不寫回資料檔案,這種適合rename table和5.6表空間傳輸新特性,因為space_id可能會被複用,所以需要清除記憶體中的一切,防止後續讀取到錯誤的資料。第二種(BUF_REMOVE_FLUSH_NO_WRITE),僅僅刪除Flush List中的資料頁同時Flush List中的資料頁也不寫回資料檔案,這種適合drop table,即使LRU List中還有資料頁,但由於不會被訪問到,所以會隨著時間的推移而被驅逐出去。第三種(BUF_REMOVE_FLUSH_WRITE),不刪除任何連結串列中的資料僅僅把Flush List中的髒頁都刷回磁碟,這種適合表空間關閉,例如資料庫正常關閉的時候呼叫。這裡還有一點值得一提的是,由於對邏輯連結串列的變動需要加鎖且刪除指定表空間資料頁這個操作是一個大操作,容易造成其他請求被餓死,所以InnoDB做了一個小小的優化,每刪除BUF_LRU_DROP_SEARCH_SIZE個資料頁(預設為1024)就會釋放一下Buffer Pool Instance的mutex,便於其他執行緒執行。

LRU_Manager_Thread

這是一個系統執行緒,隨著InnoDB啟動而啟動,作用是定期清理出空閒的資料頁(數量為innodb_LRU_scan_depth)並加入到Free List中,防止使用者執行緒去做同步刷髒影響效率。執行緒每隔一定時間去做BUF_FLUSH_LRU,即首先嚐試從LRU中驅逐部分資料頁,如果不夠則進行刷髒,從Flush List中驅逐(buf_flush_LRU_tail)。執行緒執行的頻率通過以下策略計算:我們設定max_free_len = innodb_LRU_scan_depth * innodb_buf_pool_instances,如果Free List中的數量小於max_free_len的1%,則sleep time為零,表示這個時候空閒頁太少了,需要一直執行buf_flush_LRU_tail從而騰出空閒的資料頁。如果Free List中的數量介於max_free_len的1%-5%,則sleep time減少50ms(預設為1000ms),如果Free List中的數量介於max_free_len的5%-20%,則sleep time不變,如果Free List中的數量大於max_free_len的20%,則sleep time增加50ms,但是最大值不超過rds_cleaner_max_lru_time。這是一個自適應的演算法,保證在大壓力下有足夠用的空閒資料頁(lru_manager_adapt_sleep_time)。

Hazard Pointer

在學術上,Hazard Pointer是一個指標,如果這個指標被一個執行緒所佔有,在它釋放之前,其他執行緒不能對他進行修改,但是在InnoDB裡面,概念剛好相反,一個執行緒可以隨時訪問Hazard Pointer,但是在訪問後,他需要調整指標到一個有效的值,便於其他執行緒使用。我們用Hazard Pointer來加速逆向的邏輯連結串列遍歷。
先來說一下這個問題的背景,我們知道InnoDB中可能有多個執行緒同時作用在Flush List上進行刷髒,例如LRU_Manager_Thread和Page_Cleaner_Thread。同時,為了減少鎖佔用的時間,InnoDB在進行寫盤的時候都會把之前佔用的鎖給釋放掉。這兩個因素疊加在一起導致同一個刷髒執行緒刷完一個資料頁A,就需要回到Flush List末尾(因為A之前的髒頁可能被其他執行緒給刷走了,之前的髒頁可能已經不在Flush list中了),重新掃描新的可刷盤的髒頁。另一方面,資料頁刷盤是非同步操作,在刷盤的過程中,我們會把對應的資料頁IO_FIX住,防止其他執行緒對這個資料頁進行操作。我們假設某臺機器使用了非常緩慢的機械硬碟,當前Flush List中所有頁面都可以被刷盤(buf_flush_ready_for_replace返回true)。我們的某一個刷髒執行緒拿到隊尾最後一個資料頁,IO fixed,傳送給IO執行緒,最後再從隊尾掃描尋找可刷盤的髒頁。在這次掃描中,它發現最後一個資料頁(也就是剛剛傳送到IO執行緒中的資料頁)狀態為IO fixed(磁碟很慢,還沒處理完)所以不能刷,跳過,開始刷倒數第二個資料頁,同樣IO fixed,傳送給IO執行緒,然後再次重新掃描Flush List。它又發現尾部的兩個資料頁都不能重新整理(因為磁碟很慢,可能還沒刷完),直到掃描到倒數第三個資料頁。所以,存在一種極端的情況,如果磁碟比較緩慢,刷髒演算法效能會從O(N)退化成O(N*N)。
要解決這個問題,最本質的方法就是當刷完一個髒頁的時候不要每次都從隊尾重新掃描。我們可以使用Hazard Pointer來解決,方法如下:遍歷找到一個可刷盤的資料頁,在鎖釋放之前,調整Hazard Pointer使之指向Flush List中下一個節點,注意一定要在持有鎖的情況下修改。然後釋放鎖,進行刷盤,刷完盤後,重新獲取鎖,讀取Hazard Pointer並設定下一個節點,然後釋放鎖,進行刷盤,如此重複。當這個執行緒在刷盤的時候,另外一個執行緒需要刷盤,也是通過Hazard Pointer來獲取可靠的節點,並重置下一個有效的節點。通過這種機制,保證每次讀到的Hazard Pointer是一個有效的Flush List節點,即使磁碟再慢,刷髒演算法效率依然是O(N)。
這個解法同樣可以用到LRU List驅逐演算法上,提高驅逐的效率。相應的Patch是在MySQL 5.7上首次提出的,阿里雲RDS把其Port到了我們5.6的版本上,保證在大併發情況下刷髒演算法的效率。

Page_Cleaner_Thread

這也是一個InnoDB的後臺執行緒,主要負責Flush List的刷髒,避免使用者執行緒同步刷髒頁。與LRU_Manager_Thread執行緒相似,其也是每隔一定時間去刷一次髒頁。其sleep time也是自適應的(page_cleaner_adapt_sleep_time),主要由三個因素影響:當前的lsn,Flush list中的oldest_modification以及當前的同步刷髒點(log_sys->max_modified_age_sync,有redo log的大小和數量決定)。簡單的來說,lsn – oldest_modification的差值與同步刷髒點差距越大,sleep time就越長,反之sleep time越短。此外,可以通過rds_page_cleaner_adaptive_sleep變數關閉自適應sleep time,這是sleep time固定為1秒。
與LRU_Manager_Thread每次固定執行清理innodb_LRU_scan_depth個資料頁不同,Page_Cleaner_Thread每次執行刷的髒頁數量也是自適應的,計算過程有點複雜(page_cleaner_flush_pages_if_needed)。其依賴當前系統中髒頁的比率,日誌產生的速度以及幾個引數。innodb_io_capacity和innodb_max_io_capacity控制每秒刷髒頁的數量,前者可以理解為一個soft limit,後者則為hard limit。innodb_max_dirty_pages_pct_lwm和innodb_max_dirty_pages_pct_lwm控制髒頁比率,即InnoDB什麼髒頁到達多少才算多了,需要加快刷髒頻率了。innodb_adaptive_flushing_lwm控制需要重新整理到哪個lsn。innodb_flushing_avg_loops控制系統的反應效率,如果這個變數配置的比較大,則系統刷髒速度反應比較遲鈍,表現為系統中來了很多髒頁,但是刷髒依然很慢,如果這個變數配置很小,當系統中來了很多髒頁後,刷髒速度在很短的時間內就可以提升上去。這個變數是為了讓系統執行更加平穩,起到削峰填谷的作用。相關函式,af_get_pct_for_dirtyaf_get_pct_for_lsn

預讀和預寫

如果一個資料頁被讀入Buffer Pool,其周圍的資料頁也有很大的概率被讀入記憶體,與其分開多次讀取,還不如一次都讀入記憶體,從而減少磁碟尋道時間。在官方的InnoDB中,預讀分兩種,隨機預讀和線性預讀。
隨機預讀: 這種預讀發生在一個資料頁成功讀入Buffer Pool的時候(buf_read_ahead_random)。在一個Extent範圍(1M,如果資料頁大小為16KB,則為連續的64個資料頁)內,如果熱點資料頁大於一定數量,就把整個Extend的其他所有資料頁(依據page_no從低到高遍歷讀入)讀入Buffer Pool。這裡有兩個問題,首先數量是多少,預設情況下,是13個資料頁。接著,怎麼樣的頁面算是熱點資料頁,閱讀程式碼發現,只有在young list前1/4的資料頁才算是熱點資料頁。讀取資料時候,使用了非同步IO,結合使用OS_AIO_SIMULATED_WAKE_LATERos_aio_simulated_wake_handler_threads便於IO合併。隨機預讀可以通過引數innodb_random_read_ahead來控制開關。此外,buf_page_get_gen函式的mode引數不影響隨機預讀。
線性預讀: 這中預讀只發生在一個邊界的資料頁(Extend中第一個資料頁或者最後一個資料頁)上(buf_read_ahead_linear)。在一個Extend範圍內,如果大於一定數量(通過引數innodb_read_ahead_threshold控制,預設為56)的資料頁是被順序訪問(通過判斷資料頁access time是否為升序或者逆序來確定)的,則把下一個Extend的所有資料頁都讀入Buffer Pool。讀取的時候依然採用非同步IO和IO合併策略。線性預讀觸發的條件比較苛刻,觸發操作的是邊界資料頁同時要求其他資料頁嚴格按照順序訪問,主要是為了解決全表掃描時的效能問題。線性預讀可以通過引數innodb_read_ahead_threshold來控制開關。此外,當buf_page_get_gen函式的mode為BUF_PEEK_IF_IN_POOL時,不觸發線性預讀。
InnoDB中除了有預讀功能,在刷髒頁的時候,也能進行預寫(buf_flush_try_neighbors)。當一個資料頁需要被寫入磁碟的時候,查詢其前面或者後面鄰居資料頁是否也是髒頁且可以被刷盤(沒有被IOFix且在old list中),如果可以的話,一起刷入磁碟,減少磁碟尋道時間。預寫功能可以通過innodb_flush_neighbors引數來控制。不過在現在的SSD磁碟下,這個功能可以關閉。

Double Write Buffer(dblwr)

伺服器突然斷電,這個時候如果資料頁被寫壞了(例如資料頁中的目錄資訊被損壞),由於InnoDB的redolog日誌不是完全的物理日誌,有部分是邏輯日誌,因此即使奔潰恢復也無法恢復到一致的狀態,只能依靠Double Write Buffer先恢復完整的資料頁。Double Write Buffer主要是解決資料頁半寫的問題,如果檔案系統能保證寫資料頁是一個原子操作,那麼可以把這個功能關閉,這個時候每個寫請求直接寫到對應的表空間中。
Double Write Buffer大小預設為2M,即128個資料頁。其中分為兩部分,一部分留給batch write,另一部分是single page write。前者主要提供給批量刷髒的操作,後者留給使用者執行緒發起的單頁刷髒操作。batch write的大小可以由引數innodb_doublewrite_batch_size控制,例如假設innodb_doublewrite_batch_size配置為120,則剩下8個資料頁留給single page write。
假設我們要進行批量刷髒操作,我們會首先寫到記憶體中的Double Write Buffer(也是2M,在系統初始化中分配,不使用Buffer Chunks空間),如果dblwr寫滿了,一次將其中的資料刷盤到系統表空間指定位置,注意這裡是同步IO操作,在確保寫入成功後,然後使用非同步IO把各個資料頁寫回自己的表空間,由於是非同步操作,所有請求下發後,函式就返回,表示寫成功了(buf_dblwr_add_to_batch)。不過這個時候後續的寫請求依然會阻塞,知道這些非同步操作都成功,才清空系統表空間上的內容,後續請求才能被繼續執行。這樣做的目的就是,如果在非同步寫回資料頁的時候,系統斷電,發生了資料頁半寫,這個時候由於系統表空間中的資料頁是完整的,只要從中拷貝過來就行(buf_dblwr_init_or_load_pages)。
非同步IO請求完成後,會檢查資料頁的完整性以及完成change buffer相關操作,接著IO helper執行緒會呼叫buf_flush_write_complete函式,把資料頁從Flush List刪除,如果發現batch write中所有的資料頁都寫成了,則釋放dblwr的空間。

Buddy夥伴系統

與記憶體分配管理演算法類似,InnoDB中的夥伴系統也是用來管理不規則大小記憶體分配的,主要用在壓縮頁的資料上。前文提到過,InnoDB中的壓縮頁可以有16K,8K,4K,2K,1K這五種大小,壓縮頁大小的單位是表,也就是說系統中可能存在很多壓縮頁大小不同的表。使用夥伴體統來分配和回收,能提高系統的效率。
申請空間的函式是buf_buddy_alloc,其首先在zip free連結串列中檢視指定大小的塊是否還存在,如果不存在則從更大的連結串列中分配,這回導致一些列的分裂操作。例如需要一塊4K大小的記憶體,則先從4K連結串列中查詢,如果有則直接返回,沒有則從8K連結串列中查詢,如果8K中還有空閒的,則把8K分成兩部分,低地址的4K提供給使用者,高地址的4K插入到4K的連結串列中,便與後續使用。如果8K中也沒有空閒的了,就從16K中分配,16K首先分裂成2個8K,高地址的插入到8K連結串列中,低地址的8K繼續分裂成2個4K,低地址的4K返回給使用者,高地址的4K插入到4K的連結串列中。假設16K的連結串列中也沒有空閒的了,則呼叫buf_LRU_get_free_block獲取新的資料頁,然後把這個資料頁加入到zip hash中,同時設定state狀態為BUF_BLOCK_MEMORY,表示這個資料頁儲存了壓縮頁的資料。
釋放空間的函式是buf_buddy_free,相比於分配空間的函式,有點複雜。假設釋放一個4K大小的資料塊,其先把4K放回4K對應的連結串列,接著會檢視其夥伴(釋放塊是低地址,則夥伴是高地址,釋放塊是高地址,則夥伴是低地址)是否也被釋放了,如果也被釋放了則合併成8K的資料塊,然後繼續尋找這個8K資料塊的夥伴,試圖合併成16K的資料塊。如果發現夥伴沒有被釋放,函式並不會直接退出而是把這個夥伴給挪走(buf_buddy_relocate),例如8K資料塊的夥伴沒有被釋放,系統會檢視8K的連結串列,如果有空閒的8K塊,則把這個夥伴挪到這個空閒的8K上,這樣就能合併成16K的資料塊了,如果沒有,函式才放棄合併並返回。通過這種relocate操作,記憶體碎片會比較少,但是涉及到記憶體拷貝,效率會比較低。

Buffer Pool預熱

這個也是官方5.6提供的新功能,可以把當前Buffer Pool中的資料頁按照space_id和page_no dump到外部檔案,當資料庫重啟的時候,Buffer Pool就可以直接恢復到關閉前的狀態。
Buffer Pool Dump: 遍歷所有Buffer Pool Instance的LRU List,對於其中的每個資料頁,按照space_id和page_no組成一個64位的數字,寫到外部檔案中即可(buf_dump)。
Buffer Pool Load: 讀取指定的外部檔案,把所有的資料讀入記憶體後,使用歸併排序對資料排序,以64個資料頁為單位進行IO合併,然後發起一次真正的讀取操作。排序的作用就是便於IO合併(buf_load)。

總結

InnoDB的Buffer Pool可以認為很簡單,就是LRU List和Flush List,但是InnoDB對其做了很多效能上的優化,例如減少加鎖範圍,page hash加速查詢等,導致具體的實現細節相對比較複雜,尤其是引入壓縮頁這個特性後,有些核心程式碼變得晦澀難懂,需要讀者細細琢磨。

相關文章