MySQL是如何對LRU算法進行優化的?又該如何對MySQL進行調優?

掃描下方二維碼或者微信搜索公衆號菜鳥飛呀飛,即可關注微信公衆號,閱讀更多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 會猜測你可能也需要下一個數據頁的數據,如果一次能連着讀取多個數據頁,那麼對其他的數據頁而言,這是順序讀取(節省了尋道時間、磁頭旋轉時間),相對較快,那這樣就能提升一點性能。

在兩種情況下會觸發預讀機制:

  1. 順序的訪問了磁盤上一個區的多個數據頁,當這個數量超過一個閾值時,InnoDB 就會認爲你對下一個區的數據也感興趣,因此觸發預讀機制,將下一個區的數據頁也全都加載進 Buffer Pool。這個閾值由參數 innodb_read_ahead_threshold 控制,默認爲 56。 可以通過如下命令查看:
show variables like 'innodb_read_ahead_threshold';
  1. 如果 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.png

優化過後的 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;

這個命令的查詢結果是一個很長的字符串,可以複製出來,放在文本文件中查看分析,部分信息截圖如下:

MySQL狀態信息

如果看到 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 章節

其他

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