天天看點

一文了解MySQL的Buffer Pool

摘要:Innodb 存儲引擎設計了一個緩沖池(Buffer Pool),來提高資料庫的讀寫性能。

本文分享自華為雲社群《​​MySQL 的 Buffer Pool,終于被我搞懂了​​》,作者:小林coding 。

今天就聊 MySQL 的 Buffer Pool,發車!

一文了解MySQL的Buffer Pool

為什麼要有 Buffer Pool?

雖然說 MySQL 的資料是存儲在磁盤裡的,但是也不能每次都從磁盤裡面讀取資料,這樣性能是極差的。

要想提升查詢性能,加個緩存就行了嘛。是以,當資料從磁盤中取出後,緩存記憶體中,下次查詢同樣的資料的時候,直接從記憶體中讀取。

為此,Innodb 存儲引擎設計了一個緩沖池(Buffer Pool),來提高資料庫的讀寫性能。

一文了解MySQL的Buffer Pool

有了緩沖池後:

  • 當讀取資料時,如果資料存在于 Buffer Pool 中,用戶端就會直接讀取 Buffer Pool 中的資料,否則再去磁盤中讀取。
  • 當修改資料時,首先是修改 Buffer Pool 中資料所在的頁,然後将其頁設定為髒頁,最後由背景線程将髒頁寫入到磁盤。

Buffer Pool 有多大?

Buffer Pool 是在 MySQL 啟動的時候,向作業系統申請的一片連續的記憶體空間,預設配置下 Buffer Pool 隻有 128MB 。

可以通過調整 innodb_buffer_pool_size 參數來設定 Buffer Pool 的大小,一般建議設定成可用實體記憶體的 60%~80%。

Buffer Pool 緩存什麼?

InnoDB 會把存儲的資料劃分為若幹個「頁」,以頁作為磁盤和記憶體互動的基本機關,一個頁的預設大小為 16KB。是以,Buffer Pool 同樣需要按「頁」來劃分。

在 MySQL 啟動的時候,InnoDB 會為 Buffer Pool 申請一片連續的記憶體空間,然後按照預設的16KB的大小劃分出一個個的頁, Buffer Pool 中的頁就叫做緩存頁。此時這些緩存頁都是空閑的,之後随着程式的運作,才會有磁盤上的頁被緩存到 Buffer Pool 中。

是以,MySQL 剛啟動的時候,你會觀察到使用的虛拟記憶體空間很大,而使用到的實體記憶體空間卻很小,這是因為隻有這些虛拟記憶體被通路後,作業系統才會觸發缺頁中斷,接着将虛拟位址和實體位址建立映射關系。

Buffer Pool 除了緩存「索引頁」和「資料頁」,還包括了 undo 頁,插入緩存、自适應哈希索引、鎖資訊等等。

一文了解MySQL的Buffer Pool

為了更好的管理這些在 Buffer Pool 中的緩存頁,InnoDB 為每一個緩存頁都建立了一個控制塊,控制塊資訊包括「緩存頁的表空間、頁号、緩存頁位址、連結清單節點」等等。

控制塊也是占有記憶體空間的,它是放在 Buffer Pool 的最前面,接着才是緩存頁,如下圖:

一文了解MySQL的Buffer Pool

上圖中控制塊和緩存頁之間灰色部分稱為碎片空間。

為什麼會有碎片空間呢?

你想想啊,每一個控制塊都對應一個緩存頁,那在配置設定足夠多的控制塊和緩存頁後,可能剩餘的那點兒空間不夠一對控制塊和緩存頁的大小,自然就用不到喽,這個用不到的那點兒記憶體空間就被稱為碎片了。

當然,如果你把 Buffer Pool 的大小設定的剛剛好的話,也可能不會産生碎片。

查詢一條記錄,就隻需要緩沖一條記錄嗎?

不是的。

當我們查詢一條記錄時,InnoDB 是會把整個頁的資料加載到 Buffer Pool 中,因為,通過索引隻能定位到磁盤中的頁,而不能定位到頁中的一條記錄。将頁加載到 Buffer Pool 後,再通過頁裡的頁目錄去定位到某條具體的記錄。

關于頁結構長什麼樣和索引怎麼查詢資料的問題可以在這篇找到答案:​​換一個角度看 B+ 樹​​

如何管理 Buffer Pool?

如何管理空閑頁?

Buffer Pool 是一片連續的記憶體空間,當 MySQL 運作一段時間後,這片連續的記憶體空間中的緩存頁既有空閑的,也有被使用的。

那當我們從磁盤讀取資料的時候,總不能通過周遊這一片連續的記憶體空間來找到空閑的緩存頁吧,這樣效率太低了。

是以,為了能夠快速找到空閑的緩存頁,可以使用連結清單結構,将空閑緩存頁的「控制塊」作為連結清單的節點,這個連結清單稱為 Free 連結清單(空閑連結清單)。

一文了解MySQL的Buffer Pool

Free 連結清單上除了有控制塊,還有一個頭節點,該頭節點包含連結清單的頭節點位址,尾節點位址,以及目前連結清單中節點的數量等資訊。

Free 連結清單節點是一個一個的控制塊,而每個控制塊包含着對應緩存頁的位址,是以相當于 Free 連結清單節點都對應一個空閑的緩存頁。

有了 Free 連結清單後,每當需要從磁盤中加載一個頁到 Buffer Pool 中時,就從 Free連結清單中取一個空閑的緩存頁,并且把該緩存頁對應的控制塊的資訊填上,然後把該緩存頁對應的控制塊從 Free 連結清單中移除。

如何管理髒頁?

設計 Buffer Pool 除了能提高讀性能,還能提高寫性能,也就是更新資料的時候,不需要每次都要寫入磁盤,而是将 Buffer Pool 對應的緩存頁标記為髒頁,然後再由背景線程将髒頁寫入到磁盤。

那為了能快速知道哪些緩存頁是髒的,于是就設計出 Flush 連結清單,它跟 Free 連結清單類似的,連結清單的節點也是控制塊,差別在于 Flush 連結清單的元素都是髒頁。

一文了解MySQL的Buffer Pool

有了 Flush 連結清單後,背景線程就可以周遊 Flush 連結清單,将髒頁寫入到磁盤。

如何提高緩存命中率?

Buffer Pool 的大小是有限的,對于一些頻繁通路的資料我們希望可以一直留在 Buffer Pool 中,而一些很少通路的資料希望可以在某些時機可以淘汰掉,進而保證 Buffer Pool 不會因為滿了而導緻無法再緩存新的資料,同時還能保證常用資料留在 Buffer Pool 中。

要實作這個,最容易想到的就是 LRU(Least recently used)算法。

該算法的思路是,連結清單頭部的節點是最近使用的,而連結清單末尾的節點是最久沒被使用的。那麼,當空間不夠了,就淘汰最久沒被使用的節點,進而騰出空間。

簡單的 LRU 算法的實作思路是這樣的:

  • 當通路的頁在 Buffer Pool 裡,就直接把該頁對應的 LRU 連結清單節點移動到連結清單的頭部。
  • 當通路的頁不在 Buffer Pool 裡,除了要把頁放入到 LRU 連結清單的頭部,還要淘汰 LRU 連結清單末尾的節點。

比如下圖,假設 LRU 連結清單長度為 5,LRU 連結清單從左到右有 1,2,3,4,5 的頁。

一文了解MySQL的Buffer Pool

如果通路了 3 号的頁,因為 3 号頁在 Buffer Pool 裡,是以把 3 号頁移動到頭部即可。

一文了解MySQL的Buffer Pool

而如果接下來,通路了 8 号頁,因為 8 号頁不在 Buffer Pool 裡,是以需要先淘汰末尾的 5 号頁,然後再将 8 号頁加入到頭部。

一文了解MySQL的Buffer Pool

到這裡我們可以知道,Buffer Pool 裡有三種頁和連結清單來管理資料。

一文了解MySQL的Buffer Pool

圖中:

  • Free Page(空閑頁),表示此頁未被使用,位于 Free 連結清單;
  • Clean Page(幹淨頁),表示此頁已被使用,但是頁面未發生修改,位于LRU 連結清單。
  • Dirty Page(髒頁),表示此頁「已被使用」且「已經被修改」,其資料和磁盤上的資料已經不一緻。當髒頁上的資料寫入磁盤後,記憶體資料和磁盤資料一緻,那麼該頁就變成了幹淨頁。髒頁同時存在于 LRU 連結清單和 Flush 連結清單。

簡單的 LRU 算法并沒有被 MySQL 使用,因為簡單的 LRU 算法無法避免下面這兩個問題:

  • 預讀失效;
  • Buffer Pool 污染;
什麼是預讀失效?

先來說說 MySQL 的預讀機制。程式是有空間局部性的,靠近目前被通路資料的資料,在未來很大機率會被通路到。

是以,MySQL 在加載資料頁時,會提前把它相鄰的資料頁一并加載進來,目的是為了減少磁盤 IO。

但是可能這些被提前加載進來的資料頁,并沒有被通路,相當于這個預讀是白做了,這個就是預讀失效。

如果使用簡單的 LRU 算法,就會把預讀頁放到 LRU 連結清單頭部,而當 Buffer Pool空間不夠的時候,還需要把末尾的頁淘汰掉。

如果這些預讀頁如果一直不會被通路到,就會出現一個很奇怪的問題,不會被通路的預讀頁卻占用了 LRU 連結清單前排的位置,而末尾淘汰的頁,可能是頻繁通路的頁,這樣就大大降低了緩存命中率。

怎麼解決預讀失效而導緻緩存命中率降低的問題?

我們不能因為害怕預讀失效,而将預讀機制去掉,大部分情況下,局部性原理還是成立的。

要避免預讀失效帶來影響,最好就是讓預讀的頁停留在 Buffer Pool 裡的時間要盡可能的短,讓真正被通路的頁才移動到 LRU 連結清單的頭部,進而保證真正被讀取的熱資料留在 Buffer Pool 裡的時間盡可能長。

那到底怎麼才能避免呢?

MySQL 是這樣做的,它改進了 LRU 算法,将 LRU 劃分了 2 個區域:old 區域 和 young 區域。

young 區域在 LRU 連結清單的前半部分,old 區域則是在後半部分,如下圖:

一文了解MySQL的Buffer Pool

old 區域占整個 LRU 連結清單長度的比例可以通過 innodb_old_blocks_pc 參數來設定,預設是 37,代表整個 LRU 連結清單中 young 區域與 old 區域比例是 63:37。

劃分這兩個區域後,預讀的頁就隻需要加入到 old 區域的頭部,當頁被真正通路的時候,才将頁插入 young 區域的頭部。如果預讀的頁一直沒有被通路,就會從 old 區域移除,這樣就不會影響 young 區域中的熱點資料。

接下來,給大家舉個例子。

假設有一個長度為 10 的 LRU 連結清單,其中 young 區域占比 70 %,old 區域占比 20 %。

一文了解MySQL的Buffer Pool

現在有個編号為 20 的頁被預讀了,這個頁隻會被插入到 old 區域頭部,而 old 區域末尾的頁(10号)會被淘汰掉。

一文了解MySQL的Buffer Pool

如果 20 号頁一直不會被通路,它也沒有占用到 young 區域的位置,而且還會比 young 區域的資料更早被淘汰出去。

如果 20 号頁被預讀後,立刻被通路了,那麼就會将它插入到 young 區域的頭部,young 區域末尾的頁(7号),會被擠到 old 區域,作為 old 區域的頭部,這個過程并不會有頁被淘汰。

一文了解MySQL的Buffer Pool

雖然通過劃分 old 區域 和 young 區域避免了預讀失效帶來的影響,但是還有個問題無法解決,那就是 Buffer Pool 污染的問題。

什麼是 Buffer Pool 污染?

當某一個 SQL 語句掃描了大量的資料時,在 Buffer Pool 空間比較有限的情況下,可能會将 Buffer Pool 裡的所有頁都替換出去,導緻大量熱資料被淘汰了,等這些熱資料又被再次通路的時候,由于緩存未命中,就會産生大量的磁盤 IO,MySQL 性能就會急劇下降,這個過程被稱為 Buffer Pool 污染。

注意, Buffer Pool 污染并不隻是查詢語句查詢出了大量的資料才出現的問題,即使查詢出來的結果集很小,也會造成 Buffer Pool 污染。

比如,在一個資料量非常大的表,執行了這條語句:

select * from t_user where name like "%xiaolin%";      

可能這個查詢出來的結果就幾條記錄,但是由于這條語句會發生索引失效,是以這個查詢過程是全表掃描的,接着會發生如下的過程:

  • 從磁盤讀到的頁加入到 LRU 連結清單的 old 區域頭部;
  • 當從頁裡讀取行記錄時,也就是頁被通路的時候,就要将該頁放到 young 區域頭部;
  • 接下來拿行記錄的 name 字段和字元串 xiaolin 進行模糊比對,如果符合條件,就加入到結果集裡;
  • 如此往複,直到掃描完表中的所有記錄。

經過這一番折騰,原本 young 區域的熱點資料都會被替換掉。

舉個例子,假設需要批量掃描:21,22,23,24,25 這五個頁,這些頁都會被逐一通路(讀取頁裡的記錄)。

一文了解MySQL的Buffer Pool

在批量通路這些資料的時候,會被逐一插入到 young 區域頭部。

一文了解MySQL的Buffer Pool

可以看到,原本在 young 區域的熱點資料 6 和 7 号頁都被淘汰了,這就是 Buffer Pool 污染的問題。

怎麼解決出現 Buffer Pool 污染而導緻緩存命中率下降的問題?

像前面這種全表掃描的查詢,很多緩沖頁其實隻會被通路一次,但是它卻隻因為被通路了一次而進入到 young 區域,進而導緻熱點資料被替換了。

LRU 連結清單中 young 區域就是熱點資料,隻要我們提高進入到 young 區域的門檻,就能有效地保證 young 區域裡的熱點資料不會被替換掉。

MySQL 是這樣做的,進入到 young 區域條件增加了一個停留在 old 區域的時間判斷。

具體是這樣做的,在對某個處在 old 區域的緩存頁進行第一次通路時,就在它對應的控制塊中記錄下來這個通路時間:

  • 如果後續的通路時間與第一次通路的時間在某個時間間隔内,那麼該緩存頁就不會被從 old 區域移動到 young 區域的頭部;
  • 如果後續的通路時間與第一次通路的時間不在某個時間間隔内,那麼該緩存頁移動到 young 區域的頭部;

這個間隔時間是由 innodb_old_blocks_time 控制的,預設是 1000 ms。

也就說,隻有同時滿足「被通路」與「在 old 區域停留時間超過 1 秒」兩個條件,才會被插入到 young 區域頭部,這樣就解決了 Buffer Pool 污染的問題 。

另外,MySQL 針對 young 區域其實做了一個優化,為了防止 young 區域節點頻繁移動到頭部。young 區域前面 1/4 被通路不會移動到連結清單頭部,隻有後面的 3/4被通路了才會。

髒頁什麼時候會被刷入磁盤?

引入了 Buffer Pool 後,當修改資料時,首先是修改 Buffer Pool 中資料所在的頁,然後将其頁設定為髒頁,但是磁盤中還是原資料。

是以,髒頁需要被刷入磁盤,保證緩存和磁盤資料一緻,但是若每次修改資料都刷入磁盤,則性能會很差,是以一般都會在一定時機進行批量刷盤。

可能大家擔心,如果在髒頁還沒有來得及刷入到磁盤時,MySQL 當機了,不就丢失資料了嗎?

這個不用擔心,InnoDB 的更新操作采用的是 Write Ahead Log 政策,即先寫日志,再寫入磁盤,通過 redo log 日志讓 MySQL 擁有了崩潰恢複能力。

下面幾種情況會觸發髒頁的重新整理:

  • 當 redo log 日志滿了的情況下,會主動觸發髒頁重新整理到磁盤;
  • Buffer Pool 空間不足時,需要将一部分資料頁淘汰掉,如果淘汰的是髒頁,需要先将髒頁同步到磁盤;
  • MySQL 認為空閑時,背景線程回定期将适量的髒頁刷入到磁盤;
  • MySQL 正常關閉之前,會把所有的髒頁刷入到磁盤;

在我們開啟了慢 SQL 監控後,如果你發現**「偶爾」會出現一些用時稍長的 SQL**,這可能是因為髒頁在重新整理到磁盤時可能會給資料庫帶來性能開銷,導緻資料庫操作抖動。

如果間斷出現這種現象,就需要調大 Buffer Pool 空間或 redo log 日志的大小。

總結

Innodb 存儲引擎設計了一個緩沖池(Buffer Pool),來提高資料庫的讀寫性能。

Buffer Pool 以頁為機關緩沖資料,可以通過 innodb_buffer_pool_size 參數調整緩沖池的大小,預設是 128 M。

Innodb 通過三種連結清單來管理緩頁:

  • Free List (空閑頁連結清單),管理空閑頁;
  • Flush List (髒頁連結清單),管理髒頁;
  • LRU List,管理髒頁+幹淨頁,将最近且經常查詢的資料緩存在其中,而不常查詢的資料就淘汰出去。;

InnoDB 對 LRU 做了一些優化,我們熟悉的 LRU 算法通常是将最近查詢的資料放到 LRU 連結清單的頭部,而 InnoDB 做 2 點優化:

  • 将 LRU 連結清單 分為young 和 old 兩個區域,加入緩沖池的頁,優先插入 old 區域;頁被通路時,才進入 young 區域,目的是為了解決預讀失效的問題。
  • 當「頁被通路」且「 old 區域停留時間超過 innodb_old_blocks_time 門檻值(預設為1秒)」時,才會将頁插入到 young 區域,否則還是插入到 old 區域,目的是為了解決批量資料通路,大量熱資料淘汰的問題。

可以通過調整 innodb_old_blocks_pc 參數,設定 young 區域和 old 區域比例。

繼續閱讀