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_olddom

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加速查找等,致使具體的實現細節相對比較複雜,尤爲是引入壓縮頁這個特性後,有些核心代碼變得晦澀難懂,須要讀者細細琢磨。

相關文章
相關標籤/搜索