閱讀336 返回首頁    go 技術社區[雲棲]


MySQL · 引擎特性 · InnoDB Buffer Pool

前言

用戶對數據庫的最基本要求就是能高效的讀取和存儲數據,但是讀寫數據都涉及到與低速的設備交互,為了彌補兩者之間的速度差異,所有數據庫都有緩存池,用來管理相應的數據頁,提高數據庫的效率,當然也因為引入了這一中間層,數據庫對內存的管理變得相對比較複雜。本文主要分析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加速查找等,導致具體的實現細節相對比較複雜,尤其是引入壓縮頁這個特性後,有些核心代碼變得晦澀難懂,需要讀者細細琢磨。

最後更新:2017-05-21 09:01:39

  上一篇:go  AliSQL · 特性介紹 · 動態加字段
  下一篇:go  重要通知 | 阿裏雲安全團隊發布WannaCry“一鍵解密和修複”工具