MySQL系列3:緩衝池Buffer Pool的設計思想

1. 回顧

上一篇我們主要講了InnoDB的存儲引擎,其中主要的一個組件就是緩存池Buffer Pool,緩存了磁盤的真實數據,然後基於緩存做增刪改查操作,同時配合了後續的redo log、刷磁盤等機制和操作。如下圖:

這一篇,深入該組件內部,學習一下其設計思想。

2. Buffer Pool數據結構

Buffer Pool本質其實就是數據庫的一個內存組件,默認情況下是128MB,如果我們的數據庫如果是16核32G的機器,那麼你就可以給Buffer Pool分配個2GB的內存,使用下面的配置就可以了
innodb_buffer_pool_size = 2147483648

磁盤加載數據頁到緩存,數據頁在緩存中被定義爲緩存頁,緩存頁與緩存頁默認16KB,每個緩存頁有對應的描述數據,描述了這個數據頁所屬的表空間、數據頁的編號、這個緩存頁在Buffer Pool中的地址等。在Buffer Pool中,每個緩存頁的描述數據放在最前面,各個緩存頁放在後面。Buffer Pool中的描述數據大概相當於緩存頁大小的5%左右,也就是每個描述數據大概是800個字節左右的大小,然後假設你設置的buffer pool大小是128MB,實際上Buffer Pool真正的最終大小會超出一些,可能有個130多MB的樣子,因爲他裏面還要存放每個緩存頁的描述數據。

數據結構如下圖:

3. free鏈表

接着我們來看下一個問題,當數據庫運行起來之後,肯定會不停的執行增刪改查的操作,此時就需要不停的從磁盤上讀取一個一個的數據頁放入Buffer Pool中的對應的緩存頁裏去,把數據緩存起來,那麼以後就可以對這個數據在內存裏執行增刪改查了。但是此時在從磁盤上讀取數據頁放入Buffer Pool中的緩存頁的時候,必然涉及到一個問題,就是哪些緩存頁是空閒的?

所以數據庫會爲Buffer Pool設計一個free鏈表,他是一個雙向鏈表數據結構,這個free鏈表裏,每個節點就是一個空閒的緩存頁的描述數據塊的地址,也就是說,只要你一個緩存頁是空閒的,那麼他的描述數據塊就會被放入這個free鏈表中。如果要把數據頁寫入緩存頁,就從鏈表中摘除這個節點,將該節點的描述數據寫到Buffer pool,再把寫入對應的緩存頁。

寫入之前還會判斷該數據頁是否已經被緩存,引入哈希表數據結構,他會用表空間號+數據頁號,作爲一個key,然後緩存頁的地址作爲value,當要使用一個數據頁的時候,通過“表空間號+數據頁號”作爲key去這個哈希表裏查一下,如果沒有就讀取數據頁,如果已經有了,就說明數據頁已經被緩存了。

如下圖所示:

4. flush鏈表

更新過的緩存頁與磁盤不一致,需要刷到磁盤的緩衝頁構成的雙向鏈表;也叫待刷盤的髒頁數據頁鏈表;如下圖所示

5. lru鏈表

如果所有的緩存頁都被塞了數據了,此時無法從磁盤上加載新的數據頁到緩存頁裏去了,那麼此時你只有一個辦法,就是淘汰掉一些緩存頁。引入LRU鏈表來判斷哪些緩存頁是不常用的。這個所謂的LRU就是Least Recently Used,最近最少使用的意思。

假設某個緩存頁的描述數據塊本來在LRU鏈表的尾部,後續你只要查詢或者修改了這個緩存頁的數據,也要把這個緩存頁挪動到LRU鏈表的頭部去,也就是說最近被訪問過的緩存頁,一定在LRU鏈表的頭部。如下圖所示

淘汰不常用的緩存頁,尾部淘汰冷數據,頭部插入熱數據。

6. 數據頁預讀帶來的問題

MySQL爲提升讀取性能,引入了預讀機制,就是當從磁盤上加載一個數據頁的時候,他可能會連帶着把這個數據頁相鄰的其他數據頁,也加載到緩存裏去!
舉個例子,假設現在有兩個空閒緩存頁,然後在加載一個數據頁的時候,連帶着把他的一個相鄰的數據頁也加載到緩存裏去了,正好每個數據頁放入一個空閒緩存頁!但是接下來呢,實際上只有一個緩存頁是被訪問了,另外一個通過預讀機制加載的緩存頁,其實並沒有人訪問,此時這兩個緩存頁可都在LRU鏈表的前面。

我們可以看到,這個圖裏很清晰的表明了,前兩個緩存頁都是剛加載進來的,但是此時第二個緩存頁是通過預讀機制捎帶着加載進來的,他也放到了鏈表的前面,但是他實際沒人訪問他。除了第二個緩存頁之外,第一個緩存頁,以及尾巴上兩個緩存頁,都是一直有人訪問的那種緩存頁,只不過上圖代表的是剛剛把頭部兩個緩存頁加載進來的時候的一個LRU鏈表當時的情況。

哪些場景會導致預讀呢?

(1)有一個參數是innodb_read_ahead_threshold,他的默認值是56,意思就是如果順序的訪問了一個區裏的多個數據頁,訪問的數據頁的數量超過了這個閾值,此時就會觸發預讀機制,把下一個相鄰區中的所有數據頁都加載到緩存裏去。
(2)如果Buffer Pool裏緩存了一個區裏的13個連續的數據頁,而且這些數據頁都是比較頻繁會被訪問的,此時就會直接觸發預讀機制,把這個區裏的其他的數據頁都加載到緩存裏去。

這個機制是通過參數innodb_random_read_ahead來控制的,他默認是OFF,也就是這個規則是關閉的。

(3)接着我們講另外一種可能導致頻繁被訪問的緩存頁被淘汰的場景,那就是全表掃描。

這個所謂的全表掃描,意思就是類似如下的SQL語句:SELECT * FROM USERS,此時他沒加任何一個where條件,會導致他直接一下子把這個表裏所有的數據頁,都從磁盤加載到Buffer Pool裏去。這個時候他可能會一下子就把這個表的所有數據頁都一一裝入各個緩存頁裏去!此時可能LRU鏈表中排在前面的一大串緩存頁,都是全表掃描加載進來的緩存頁!那麼如果這次全表掃描過後,後續幾乎沒用到這個表裏的數據呢?此時LRU鏈表的尾部,可能全部都是之前一直被頻繁訪問的那些緩存頁!然後當你要淘汰掉一些緩存頁騰出空間的時候,就會把LRU鏈表尾部一直被頻繁訪問的緩存頁給淘汰掉了,而留下了之前全表掃描加載進來的大量的不經常訪問的緩存頁。

7. 解決預讀帶來的問題

所以爲了解決上一講我們說的簡單的LRU鏈表的問題,真正MySQL在設計LRU鏈表的時候,採取的實際上是冷熱數據分離的思想。
所以真正的LRU鏈表,會被拆分爲兩個部分,一部分是熱數據,一部分是冷數據,這個冷熱數據的比例是由innodb_old_blocks_pct參數控制的,他默認是37,也就是說冷數據佔比37%。


第一次被加載了數據的緩存頁,都會不停的移動到冷數據區域的鏈表頭部。冷數據區域的緩存頁什麼時候會放到熱數據區域呢?實際上肯定很多人會想,只要對冷數據區域的緩存頁進行了一次訪問,就立馬把這個緩存頁放到熱數據區域的頭部行不行呢?

其實這也是不合理的,如果你剛加載了一個數據頁到那個緩存頁,他是在冷數據區域的鏈表頭部,然後立馬(在1ms以內)就訪問了一下這個緩存頁,之後就再也不訪問他了呢?難道這種情況你也要把那個緩存頁放到熱數據區域的頭部嗎?

所以MySQL設定了一個規則,他設計了一個innodb_old_blocks_time參數,默認值1000,也就是1000毫秒。也就是說,必須是一個數據頁被加載到緩存頁之後,在1s之後,你訪問這個緩存頁,他纔會被挪動到熱數據區域的鏈表頭部去。因爲假設你加載了一個數據頁到緩存去,然後過了1s之後你還訪問了這個緩存頁,說明你後續很可能會經常要訪問它,這個時間限制就是1s,因此只有1s後你訪問了這個緩存頁,他纔會給你把緩存頁放到熱數據區域的鏈表頭部去。

該思想通過冷熱分離+時間訪問限制,解決了誤淘汰熱數據的問題。吸收冷熱隔離思想,結合項目場景,可以優化緩存中的冷熱數據。

LRU鏈表的熱數據區域是如何進行優化的呢?

熱數據區域裏的緩存頁可能是經常被訪問的,所以這麼頻繁的進行移動是不是性能也並不是太好?也沒這個必要。

所以說,LRU鏈表的熱數據區域的訪問規則被優化了一下,即你只有在熱數據區域的後3/4部分的緩存頁被訪問了,纔會給你移動到鏈表頭部去。如果你是熱數據區域的前面1/4的緩存頁被訪問,他是不會移動到鏈表頭部去的。舉個例子,假設熱數據區域的鏈表裏有100個緩存頁,那麼排在前面的25個緩存頁,他即使被訪問了,也不會移動到鏈表頭部去的。但是對於排在後面的75個緩存頁,他只要被訪問,就會移動到鏈表頭部去。這樣的話,他就可以儘可能的減少鏈表中的節點移動了。

8. 總結

本節主要講了三鏈表的作用,free鏈表記錄空閒緩存頁,flush鏈表記錄髒頁,即待刷盤緩存頁,當free鏈表沒有空閒時,lru鏈表淘汰最近不常用的緩存頁。三鏈表動態執行過程可以表述爲:free鏈表移除結點,lru鏈表冷數據區頭部加入該節點;如果修改了緩存頁,flush加入這個髒頁,lru表中還可能會從冷數據區域移動到熱數據區域的頭部去;如果查詢了緩存頁,會把這個緩存頁在lru鏈表中移動到熱數據區域去,或者在熱數據區域中也有可能會移動到頭部去。總之,要麼free鏈表移除節點,flush鏈表加節點,lru鏈表移動節點;要麼free加節點,flush減節點,lru減節點。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章