InnoDB原理篇:Buffer Pool 為了讓 MySQL 變快都做了什麼?

語言: CN / TW / HK

前言

大家好,我是阿星,又跟大家見面了。

相信很多小夥伴在面試中都被問過「為什麼要用快取?」,大部分人都是回答:「減少資料庫的磁碟IO壓力」。

但是MySQL真的有如此不堪嗎?

每次增刪改查都要去走磁碟IO嗎?

今天就聊聊InnoDBBuffer Pool的奇思妙想。

Buffer Pool

先梳理出問題,再思考如何解決問題。

假設我們就是InnoDB,我們要如何去解決磁碟IO問題?

這個簡單,做快取就好了,所以MySQL需要申請一塊記憶體空間,這塊記憶體空間稱為Buffer Pool

Buffer Pool是申請下來了,但是Buffer Pool裡面放什麼,要怎麼規劃?

快取頁

MySQL資料是以頁為單位,每頁預設16KB,稱為資料頁,在Buffer Pool裡面會劃分出若干個快取頁與資料頁對應。

感覺還少了點什麼,我們如何知道快取頁對應那個資料頁呢?

描述資料

所有還需要快取頁的元資料資訊,可以稱為描述資料,它與快取頁一一對應,包含一些所屬表空間、資料頁的編號、Buffer Pool中的地址等等。

後續對資料的增刪改查都是在Buffer Pool裡操作

  • 查詢:從磁碟載入到快取,後續直接查快取
  • 插入:直接寫入快取
  • 更新刪除:快取中存在直接更新,不存在載入資料頁到快取更新

可能有小夥伴擔心,MySQL宕機了,資料不就全丟了嗎?

這個不用擔心,因為InnoDB提供了WAL技術(Write-Ahead Logging),通過redo logMySQL擁有了崩潰恢復能力。

再配合空閒時,會有非同步執行緒做快取頁刷盤,保證資料的永續性與完整性。

如果不能理解redo log是如何恢復資料的,可以看看阿星前面兩篇文章

另外,直接更新資料的快取頁稱為髒頁,快取頁刷盤後稱為乾淨頁

Free連結串列

MySQL資料庫啟動時,按照設定的Buffer Pool大小,去找作業系統申請一塊記憶體區域,作為Buffer Pool假設申請了512MB)。

申請完畢後,會按照預設快取頁的16KB以及對應的800Byte的描述資料,在Buffer Pool中劃分出來一個一個的快取頁和它們對應的描述資料。

MySQL執行起來後,會不停的執行增刪改查,需要從磁碟讀取一個一個的資料頁放入Buffer Pool對應的快取頁裡,把資料快取起來,以後就可以在記憶體裡執行增刪改查。

但是這個過程必然涉及一個問題,哪些快取頁是空閒的

為了解決這個問題,我們使用連結串列結構,把空閒快取頁的描述資料放入連結串列中,這個連結串列稱為free連結串列。

針對free連結串列我們要做如下設計

  • 新增free基礎節點
  • 描述資料新增free節點指標

最終呈現出來的,是由空閒快取頁的描述資料組成的free連結串列。

有了free連結串列之後,我們只需要從free連結串列獲取一個描述資料,就可以獲取到對應的快取頁。

描述資料快取頁寫入資料後,就將該描述資料移出free連結串列。

快取頁雜湊表

資料頁是快取進去了,但是又一個問題來了。

下次查詢資料時,如何在Buffer Pool裡快速定位到對應的快取頁呢?

難道需要一個非空閒的描述資料連結串列,再通過表空間號+資料頁編號遍歷查詢嗎?

這樣做也可以實現,但是效率不太高,時間複雜度是O(N)

所以我們可以換一個結構,使用雜湊表來快取它們間的對映關係,時間複雜度是O(1)

表空間號+資料頁號,作為一個key,然後快取頁的地址作為value

每次載入資料頁到空閒快取頁時,就寫入一條對映關係到快取頁雜湊表中。

後續的查詢,就可以通過快取頁雜湊表路由定位了。

Flush連結串列

還記得之前有說過「空閒時會有非同步執行緒做快取頁刷盤,保證資料的永續性與完整性」嗎?

新問題來了,難道每次把Buffer Pool裡所有的快取頁都刷入磁碟嗎?

當然不能這樣做,磁碟IO開銷太大了,應該把髒頁刷入磁碟才對(更新過的快取頁)。

可是我們怎麼知道,那些快取頁是髒頁

很簡單,參照free連結串列,弄個flush連結串列出來就好了,只要快取頁被更新,就將它的描述資料加入flush連結串列。

針對flush連結串列我們要做如下設計

  • 新增flush基礎節點
  • 描述資料新增flush節點指標

最終呈現出來的,是由更新過資料的快取頁描述資料組成的flush連結串列。

後續非同步執行緒都從flush連結串列刷快取頁,當Buffer Pool記憶體不足時,也會優先刷flush連結串列裡的快取頁。

LRU連結串列

目前看來Buffer Pool的功能已經比較完善了。

但是仔細思考下,發現還有一個問題沒處理。

MySQL資料庫隨著系統的執行會不停的把磁碟上的資料頁載入到空閒的快取頁裡去,因此free連結串列中的空閒快取頁會越來越少,直到沒有,最後磁碟的資料頁無法載入。

為了解決這個問題,我們需要淘汰快取頁,騰出空閒快取頁。

可是我們要優先淘汰那些快取頁? 總不能一股腦直接全部淘汰吧?

這裡就要借鑑LRU演算法思想,把最少使用的快取頁淘汰(命中率低),提供LRU連結串列出來。

針對LRU連結串列我們要做如下設計

  • 新增LRU基礎節點
  • 描述資料新增LRU節點指標

實現思路也很簡單,只要是查詢或修改過快取頁,就把該快取頁的描述資料放入連結串列頭部,也就說近期訪問的資料一定在連結串列頭部。

free連結串列為空的時候,直接淘汰LRU連結串列尾部快取頁即可。

LRU連結串列優化

麻雀雖小五臟俱全,基本Buffer Pool裡與快取頁相關的元件齊全了。

但是快取頁淘汰這裡還有點問題,如果僅僅只是使用LRU連結串列的機制,有兩個場景會讓熱點資料被淘汰。

  • 預讀機制
  • 全表掃描

預讀機制是指MySQL載入資料頁時,可能會把它相鄰的資料頁一併載入進來(區域性性原理)。

這樣會帶來一個問題,預讀進來的資料頁,其實我們沒有訪問,但是它卻排在前面。

正常來說,淘汰快取頁時,應該把這個預讀的淘汰,結果卻把尾部的淘汰了,這是不合理的。

我們接著來看第二個場景全表掃描,如果表資料量大,大量的資料頁會把空閒快取頁用完。

最終LRU連結串列前面都是全表掃描的資料,之前頻繁訪問的熱點資料全部到隊尾了,淘汰快取頁時就把熱點資料頁給淘汰了。

為了解決上述的問題。

我們需要給LRU連結串列做冷熱資料分離設計,把LRU連結串列按一定比例,分為冷熱區域,熱區域稱為young區域,冷區域稱為old區域。

以7:3為例,young區域70%,old`區域30%

如上圖所示,資料頁第一次載入進快取頁的時候,是先放入冷資料區域的頭部,如果1秒後再次訪問快取頁,則會移動到熱區域的頭部。

這樣就保證了預讀機制全表掃描載入的資料都在連結串列隊尾。

young區域其實還可以做一個小優化,為了防止young區域節點頻繁移動到表頭。

young區域前面1/4被訪問不會移動到連結串列頭部,只有後面的3/4被訪問了才會。

記住是按照某個比例將LRU連結串列分成兩部分,不是某些節點固定是young區域的,某些節點固定是old區域的,隨著程式的執行,某個節點所屬的區域也可能發生變化。

小結

其實MySQL就是這樣實現Buffer Pool快取頁的,只不過它裡面的連結串列全是雙向連結串列,阿星這裡偷個懶,但是不影響理解思路。

讀到這裡,我相信大家對Buffer Pool快取頁有了深刻的認知,也知道從一個增刪改查時如何快取資料、定位快取、快取刷盤、快取淘汰。

這裡留問題給大家思考,Free、Flush、LRU這三個連結串列之間的聯絡,隨著MySQL一直在執行,它們會產生怎樣的聯動。

MySQL好文推薦

關於我

阿星是一個熱愛技術的 Java 程式猿,公眾號 「程式猿阿星」 定期分享有趣有料的精品原創文章!

非常感謝各位小哥哥小姐姐們能看到這裡,原創不易,文章有幫助可以關注、點個贊、分享與評論,都是支援(莫要白嫖)!

願你我都能奔赴在各自想去的路上,我們下篇文章見。

福利

低調領取《零基礎到Java就業全套教程、架構師全套教程》