❝掃描下方二維碼或者微信搜索公衆號
❞菜鳥飛呀飛
,即可關注微信公衆號,閱讀更多Spring源碼分析
、Java併發編程
、Netty源碼系列
和MySQL工作原理
文章。
1. 開篇
MySQL 在查詢數據時,對於 InnoDB 存儲引擎而言,會先將磁盤上的數據以頁爲單位,先將數據頁加載進內存,然後以緩存頁的形式存放在「Buffer Pool」中。Buffer Pool 是 InnoDB 的一塊內存緩衝區,在 MySQL 啓動時,會按照配置的緩存頁的大小,將 Buffer Pool 緩存區初始化爲許多個緩存頁,默認情況下,緩存頁大小爲 16KB。
❝爲了方便理解,對於磁盤上的數據所在的頁,叫做數據頁,當加載進 Buffer Pool 中後,叫做緩存頁,這兩者是一一對應的
❞
在 MySQL 啓動初期,Buffer Pool 中的這些緩存頁都是處於空閒狀態,也就是還沒有被使用,而隨着 MySQL 的運行時間越來越長,這些緩存頁漸漸地都被使用了,用來存放從磁盤上加載的數據頁,導致空閒的緩存頁就越來越少。當某一時刻,所有空閒的緩存頁都被使用了,那麼這個時候,從磁盤加載到內存中的數據頁該怎麼辦呢?
2. LRU算法
既然沒有空閒緩存頁了,而又想使用緩存頁,那麼最簡單的辦法就是淘汰一個緩存頁。應該淘汰誰呢?當然是淘汰那個最近一段時間最少被訪問過的緩存頁了,這種思想就是典型的 LRU 算法了。
LRU 是 Least Recently Used 的簡寫,這個算法的實現就是淘汰最久未使用的數據,它通過維護一個鏈表,每當訪問了某個數據時,就將這個數據加入到鏈表的頭部,如果數據本身存在於鏈表中,那麼就將數據從鏈表的中間移動到鏈表的頭部,這樣最終下來,在鏈表尾部的數據一定就是最久未被使用的數據了,因此可以將其淘汰。
將 LRU 算法應用到緩存頁的淘汰策略上,那麼就是在 InnoDB 存儲引擎層內部,維護了一個鏈表,這些鏈表中的元素存儲的就是指向緩存頁的指針。
在 MySQL 啓動的時候,這個鏈表爲空。MySQL 啓動以後,在進行數據查詢時,InnoDB 會先判斷要查詢的數據所在的數據頁,是否存在於 Buffer Pool 的緩存頁當中,如果不存在,就從磁盤中讀取對應數據頁,存放到 Buffer Pool 一個空閒的緩存頁當中,然後將這個緩衝頁放入到鏈表的頭部;如果要查詢的數據已經存在於 Buffer Pool 當中了,那麼就將對應的緩存頁從鏈表的中間移動到鏈表頭部。
這樣隨着 MySQL 的運行,空閒的緩存頁越來越少,LRU 鏈表越來越長,直到某一時刻,剩餘的空閒緩存頁數爲 0,當需要申請一個新的空閒緩存頁的時候,就需要淘汰一個緩存頁了,此時只需要把鏈表尾部的那個緩存頁刷入到磁盤,然後清空緩存頁裏面的數據,這樣就空餘出一個新的緩存頁了。
3. LRU 鏈表帶來的問題
咋一看,似乎 LRU 鏈表完美解決了緩存頁淘汰的問題。但理想很豐滿,現實卻很骨感,如果直接使用這種 LRU 算法的話,在 MySQL 中就會存在很大的問題。
3.1 全表掃描
在 MySQL 中經常會出現全表掃描,一種是開發人員對索引的使用不當導致的,一種是業務如此,無法避免。
當出現全表掃描時,InnoDB 會將該表的數據頁全部從磁盤文件加載進緩存頁中,這些緩存頁會被加入到 LRU 鏈表中。如果進行全表掃描的對象是一張非常大的表,可能是幾十 GB 的數據,而且這張表記錄的是類似於賬戶流水、操作日誌等使用不頻繁的數據,這個時候如果 LRU 鏈表已經滿了,現在我們就要淘汰一部分緩存頁,騰出空間來存放全表掃描出來的數據。這樣就會因爲全表掃描的數據量大,需要淘汰的緩存頁多,導致在淘汰的過程中,極有可能將需要頻繁使用到的緩存頁給淘汰了,而放進來的新數據卻是使用頻率很低的數據,甚至是這一次使用之後,後面幾乎再也不用,如操作日誌等。
最終導致的現象就是,當我們在對這些使用不頻繁的大表進行全表掃描之後,在一段時間內,Buffer Pool 緩存的命中率明顯下降,SQL 的性能也明顯下降,因爲常用的緩存頁被淘汰了,再進行查詢時,需要從重新磁盤讀取,發生磁盤 IO,性能下降。所以,如果 MySQL 只是簡單的使用 LRU 算法,那麼碰到全表掃描時,就會存在性能下降的問題,甚至在高併發場景下,成爲性能瓶頸。
3.2 預讀
預讀是 InnoDB 引擎的一個優化機制,當你從磁盤上讀取某個數據頁,InnoDB 可能會將與這個數據頁相鄰的其他數據頁也讀取到 Buffer Pool 中。
InnoDB 爲什麼要這樣做呢?因爲從磁盤讀取數據時發生的磁盤 IO 是隨機 IO,性能差。當你讀取某一個數據頁時,InnoDB 會猜測你可能也需要下一個數據頁的數據,如果一次能連着讀取多個數據頁,那麼對其他的數據頁而言,這是順序讀取(節省了尋道時間、磁頭旋轉時間),相對較快,那這樣就能提升一點性能。
在兩種情況下會觸發預讀機制:
順序的訪問了磁盤上一個區的多個數據頁,當這個數量超過一個閾值時,InnoDB 就會認爲你對下一個區的數據也感興趣,因此觸發預讀機制,將下一個區的數據頁也全都加載進 Buffer Pool。這個閾值由參數 「innodb_read_ahead_threshold」 控制,默認爲 56。 可以通過如下命令查看:
show variables like 'innodb_read_ahead_threshold';
如果 Buffer Pool 中已經緩存了同一個區數據頁的個數超過 13 時,InnoDB 就會將這個區的其他數據頁也讀取到 Buffer Pool 中。這個開關由參數 「innodb_random_read_ahead」 控制,默認是關閉的。
show variables like 'innodb_random_read_ahead';
如果 MySQL 僅是簡單的使用 LRU 算法,那麼預讀機制和全表掃描帶來的問題類似,預讀機制會將其他的數據頁也加載進內存,當 LRU 鏈表滿時,可能將我們頻繁訪問的緩存頁給淘汰,從而導致性能下降。
4. 冷熱分離
實際上,MySQL 確實沒有直接使用 LRU 算法,而是在 LRU 算法上進行了優化。
從上面的全表掃描和預讀機制的問題分析中,我們可以看到,根本原因就是從磁盤上新讀取到的數據頁,在加載進 Buffer Pool 時,可能將我們頻繁訪問的數據給淘汰,也就是出現了冷熱數據的現象。因此,MySQL 的優化思路就是:對數據進行冷熱分離,將 LRU 鏈表分成兩部分,一部分用來存放冷數據,也就是剛從磁盤讀進來的數據,另一部分用來存放熱點數據,也就是經常被訪問到數據。
其中,存放冷數據的區域佔這個 LRU 鏈表的多少呢?這由參數 「innodb_old_blocks_pct」 控制,默認是 37%(約八分之三)。冷熱分離的 LRU 鏈表示意圖如下(圖片來自於MySQL官方文檔)。
show variables like 'innodb_old_blocks_pct';
優化過後的 LRU 鏈表,又是如何進行數據頁的存放的呢?
「當從磁盤讀取數據頁後,會先將數據頁存放到 LRU 鏈表冷數據區的頭部,如果這些緩存頁在 1 秒之後被訪問,那麼就將緩存頁移動到熱數據區的頭部;如果是 1 秒之內被訪問,則不會移動,緩存頁仍然處於冷數據區中。1 秒這個數值,是由參數 innodb_old_blocks_time 控制。」
show variables like 'innodb_old_blocks_time';
當遇到全表掃描或者預讀時,如果沒有空閒緩存頁來存放它們,那麼將會淘汰一個數據頁,而此時淘汰地是冷數據區尾部的數據頁。冷數據區的數據就是不經常訪問的,因此這解決了誤將熱點數據淘汰的問題。如果在 1 秒後,因全表掃描和預讀機制額外加載進來的緩存頁,仍然沒有人訪問,那麼它們會一直待在冷數據區,當再需要淘汰數據時,首先淘汰地就是這一部分數據。
至此,基於冷熱分離優化後的 LRU 鏈表,完美解決了直接使用 LRU 鏈表帶來的問題。
5. LRU 鏈表的極致優化
實際上,MySQL 在冷熱分離的基礎上還做了一層優化。
當一個緩存頁處於熱數據區域的時候,我們去訪問這個緩存頁,這個時候我們真的有必要把它移動到熱點數據區域的頭部嗎?
從代碼的角度來看,將鏈表中的數據移動到頭部,實際上就是修改元素的指針指向,這個操作是非常快的。但是爲了安全起見,在修改鏈表的時候,我們需要對鏈表加上鎖,否則容易出現併發問題。
當併發量大的時候,因爲要加鎖,會存在鎖競爭,每次移動顯然效率就會下降。因此 MySQL 針對這一點又做了優化,如果一個緩存頁處於熱數據區域,且在熱數據區域的前 1/4 區域(注意是熱數據區域的 1/4,不是整個鏈表的 1/4),那麼當訪問這個緩存頁的時候,就不用把它移動到熱數據區域的頭部;如果緩存頁處於熱數據的後 3/4 區域,那麼當訪問這個緩存頁的時候,會把它移動到熱數據區域的頭部。
6. 生產上的 MySQL 調優
理解了上面的原理,下面則基於這些原理,說一些MySQL可以優化的方案。
MySQL 的數據最終是存儲在磁盤上的,每次查詢數據時,我們先需要把數據加載進緩存,然後讀取,如果每次查詢的數據都已經存在於緩存了,那麼就不用去磁盤讀取,避免了一次磁盤 IO,這是我們最期望的。因此爲了儘量在 LRU 鏈表中緩存更多的緩存頁,我們「可以根據服務器的配置,儘量調大 Buffer Pool 的大小」。
另外,在進行增刪改查的時候,需要涉及到對 Buffer Pool 中 LRU 鏈表、Free 鏈表、Flush 鏈表的修改,爲了線程安全,我們需要進行加鎖。因此爲了提高併發度,MySQL 支持配置多個 Buffer Pool 實例。當有多個Buffer Pool實例時,就能將請求分別分攤到這些Buffer Pool中,減少了鎖的競爭。
可以通過如下命令去查看 Buffer Pool 的大小以及 Buffer Pool 實例的個數。
# buffer pool大小
show variables like 'innodb_buffer_pool_size';
# buffer pool實例個數
show variables like 'innodb_buffer_pool_instances';
❝Free 鏈表是維護空閒緩存頁的列表,Flush 鏈表是維護髒頁的鏈表。什麼是髒頁,感興趣的同學可以先自己查閱相關資料,本公衆號的後續文章也會介紹。
❞
另外在實際應用中,在沒有外部監控工具的情況下,我們該如何知道 MySQL 的一些狀態信息呢?如:緩存命中率、緩存頁的空閒數、髒頁數量、LRU 鏈表中緩存頁個數、冷熱數據的比例、磁盤 IO 讀取的數據頁數量等信息。可以通過如下命令查看:
show engine innodb status;
這個命令的查詢結果是一個很長的字符串,可以複製出來,放在文本文件中查看分析,部分信息截圖如下:
如果看到 「youngs/s」 這個值較高,說明數據從冷數據區移到熱數據的頻率較大,因此可以適當調大熱數據所佔的比例,也就是減小「innodb_old_blocks_pct」參數的值,也可以調大「innodb_old_blocks_time」參數的值
如果看到 「non-youngs/s」 這個值較高,說明數據被加載進緩存當中後,沒有被移動到熱數據區,這是因爲在 1s 內被訪問了,這很可能是全表掃描造成的,這個時候就可以去檢查一下代碼,是不是SQL語句寫得不恰當。
7. 總結
總結一下,本文詳細說明了普通的 LRU 鏈表並不適用於 MySQL,全表掃描和預讀機制均會導致熱點數據被淘汰,從而導致性能下降的問題。MySQL 在 LRU 算法的基礎上做了優化,將鏈表拆分爲冷、熱兩部分,從而解決了冷熱數據的問題。最後介紹了幾種 MySQL 優化的方法,可以通過調到 Buffer Pool 的大小以及個數來提升性能,也可以結合 MySQL 的運行狀態信息來決定是否需要調整 LRU 鏈表的冷熱數據區的比例。
另外,將數據進行冷熱分離的這種思路,非常值得借鑑。
最後,實踐是檢驗理論的唯一標準,MySQL 相關的原理明白了,至於生產環境的 MySQL 應該如何優化,還需要結合實際情況以及機器的配置來決定如何配置 MySQL 的參數。
8. 參考資料
《高性能 MySQL》 極客時間林曉斌《MySQL 實戰 45 講》 MySQL5.7 官方文檔 Buffer Pool 章節