InnoDB學習(一)之BufferPool

御狐神發表於2021-12-07

我們知道InnoDB資料庫的資料是持久化在磁碟上的,而磁碟的IO速度很慢,如果每次資料庫訪問都直接訪問磁碟,顯然嚴重影響資料庫的效能。為了提升資料庫的訪問效能,InnoDB為資料庫的資料增加了記憶體快取區(BufferPool),避免每次訪問資料庫都進行磁碟IO。

快取區BufferPool

快取區並不是Innodb中特有的概念,作業系統中也有快取區的概念,當使用者第一次從磁碟讀取檔案時,會把檔案快取到記憶體中,後續再對這個檔案進行讀操作就可以直接從記憶體中讀,從而減少磁碟IO次數。快取只是記憶體中的一塊連續空間,InnoDB是如何合理利用快取區的空間的呢?本文會從以下幾個方面介紹InnoDB的快取區:

  1. 快取區概覽:InnoDB快取區的結構和狀態查詢;
  2. 快取區例項(BufferPool Instance):快取區可以劃分為多個例項;
  3. BufferChunk:快取區例項內的資料塊;
  4. 控制塊和資料頁:InnoDB是以什麼形式快取資料庫中的資料的;
  5. 空閒空間管理;快取區內的空閒空間管理邏輯;
  6. 使用者資料管理:資料庫資料和索引在快取區快取的管理;
  7. 自適應雜湊索引:優化熱點資料等值查詢的雜湊索引;
  8. ChangeBuffer簡介:提高資料庫更新效率的ChangeBuffer;
  9. 鎖資訊管理:InnoDB中的行鎖資訊也是存放在快取區中的;

快取區概覽

InnoDB中的快取區叫innodb_buffer_pool,當讀取資料時,就會先從快取中檢視是否資料的頁(page)存在,不存在的話去磁碟上檢索,查到後快取到innodb_buffer_pool中。同理,插入、修改、刪除也是先操作快取裡資料,之後再以一定頻率更新到磁碟上,這個刷盤機制叫做Checkpoint。

如下圖所示,InnoDB中的資料主要有資料頁、索引頁、插入快取、自適應雜湊索引、鎖資訊和資料字典資訊。我們經常聽到的RedoLog不在快取區中。

InnoDB快取區結構

MySQL預設的innodb_buffer_pool的大小是128M,我們可以通過以下命令檢視innodb_buffer_pool的引數,執行結果如下圖所示:

show variables like 'innodb_buffer_pool%';

InnoDB快取區引數示例

在MySQL使用過程中,我們可能需要檢視快取區的狀態,比如已使用空間大小、髒頁大小等狀態,我們可以通過以下命令檢視innodb_buffer_pool的狀態,執行結果如下圖所示,圖中的執行結果中,共有8192頁資料。

show global status like '%innodb_buffer_pool%';

InnoDB快取區狀態示例

快取區例項

快取區本身是一塊記憶體空間,在多執行緒併發訪問快取的情況下,為了保證快取頁資料的正確性,可能會對快取區單例項鎖互斥訪問,如果快取區非常大並且多執行緒併發訪問非常高的情況下,單例項快取區的可能會影響請求的處理速度。如下圖所示,資料庫快取區大小為3G,併發訪問QPS為3000,如果快取區只有一個例項,那麼這3000個請求可能需要競爭同一個互斥鎖。

InnoDB快取區單個例項

MySQL 5.5引入了快取區例項作為減小內部鎖爭用來提高MySQL吞吐量的手段,使用者可以通過設定innodb_buffer_pool_instances引數來指定InnoDB快取區例項的數目,預設快取區例項的數目為1。快取區例項的大小均為`innodb_buffer_pool_size/innodb_buffer_pool_instances。如下圖所示,資料庫快取區大小為3G,併發訪問QPS為3000,如果快取區有3個例項,理想情況下最多每1000個請求會競爭同一個互斥鎖。

InnoDB快取區多個例項

如果快取區總空間大小小於1G,innodb_buffer_pool_instances會被重置為1,因為小空間的多個快取區例項反而會影響查詢效能。

快取區例項有以下特點:

  1. 快取區例項有自己的鎖/訊號量/物理塊/邏輯連結串列,快取區例項之間沒有鎖競爭關係;
  2. 所有快取區例項的空間在資料庫啟動時分配,資料庫關閉後釋放;
  3. 快取頁按照雜湊函式隨機分佈到不同的快取例項中;

快取區例項的BufferChunk

我們知道快取區可以包含多個快取區例項,每個快取區例項包含一塊連續的記憶體空間,InnoDB把這塊空間劃分為多個BufferChunk,BufferChunk是InnoDB中的底層的物理塊,BufferChunck中包含資料頁和控制塊兩部分。

InnoDB快取區引數示例

BufferChunk是最低層的物理塊,在啟動階段從作業系統申請,直到資料庫關閉才釋放。通過遍歷chunks可以訪問幾乎所有的資料頁,有兩種狀態的資料頁除外:

  1. 沒有被解壓的壓縮頁(BUF_BLOCK_ZIP_PAGE);
  2. 修改過且解壓頁已經被驅逐的壓縮頁(BUF_BLOCK_ZIP_DIRTY);

BufferChunck中包含資料頁和控制塊兩部分,二者存放的資料如下:

  1. 控制塊:頁面管理資訊/互斥鎖/頁面的狀態等資料塊控制資訊;
  2. 資料頁:資料庫資料/鎖資料/自適應雜湊資料,資料頁的大小預設為16K;

BufferChunck資料塊的大小是可配置的,MySQL配置中預設BufferChunck資料塊大小如下所示,使用者可以在MySQL例項啟動之前通過修改配置檔案或啟動引數中指定,達到自定義BufferChunck資料塊的大小的目的。

$> mysqld --innodb-buffer-pool-chunk-size=134217728
[mysqld]
innodb_buffer_pool_chunk_size = 134217728

使用者自定義innodb_buffer_pool_chunk_size引數的大小應當小於單個快取區例項的空間大小。如果innodb_buffer_pool_chunk_size值乘以innodb_buffer_pool_instances大於初始化緩衝池總大小時, innodb_buffer_pool_chunk_size則截斷為innodb_buffer_pool_size/innodb_buffer_pool_instances。

控制塊和資料頁

通過上文,我們知道InnoDB中的底層物理塊是BufferChunk,BufferChunk中包含了控制塊和資料頁,本節會介紹資料頁和控制塊分別包含哪些資料。

控制塊

InnoDB中的每個資料頁都有一個相對應的控制塊,用於儲存資料頁的管理資訊,但是這些資訊不需要記錄到磁碟,而是根據讀入資料塊在記憶體中的狀態動態生成的。查詢或者修改資料頁時,總是會通過控制塊進行資料塊操作,控制塊主要包含以下資料:

  1. 頁面管理的普通訊息/互斥鎖/頁面的狀態等;
  2. 空閒連結串列/LRU連結串列/FLU連結串列等連結串列的管理;
  3. 按照一定的雜湊函式快速定位資料頁位置;

InnoDB快取區控制塊

資料頁

InnoDB中,資料管理的最小單位為頁,預設是16KB,頁中除了儲存使用者資料,還可以儲存控制資訊的資料。InnoDB IO子系統的讀寫最小單位也是頁。如果對錶進行了壓縮,則對應的資料頁稱為壓縮頁,如果需要從壓縮頁中讀取資料,則壓縮頁需要先解壓,形成解壓頁,解壓頁為16KB。壓縮頁的大小是在建表的時候指定,目前支援16K,8K,4K,2K,1K。即使壓縮頁大小設為16K,在blob/varchar/text的型別中也有一定好處。假設指定的壓縮頁大小為4K,如果有個資料頁無法被壓縮到4K以下,則需要做B-tree分裂操作,這是一個比較耗時的操作。

資料頁可以用於存放以下型別的資料,下文中我們會對這些型別的資料結構進行詳細介紹:

  • 使用者資料,聚簇索引和非聚簇索引對應的節點資料;
  • 行鎖資訊,InnoDB鎖過多異常時,可以通過增加BufferPool大小解決;
  • 自適應雜湊,用於快取熱點資料;
  • ChangeBuffer快取;

空閒空間管理

當我們最初啟動伺服器的時候,需要完成對的初始化過程,就是分配的記憶體空間,把它劃分成若干對控制塊和快取頁。但是此時並沒有真實的磁碟頁被快取到中(因為還沒有用到),之後隨著程式的執行,會不斷的有磁碟上的頁被快取到中,那麼問題來了,從磁碟上讀取一個頁到中的時候該放到哪個快取頁的位置呢?或者說怎麼區分中哪些快取頁是空閒的,哪些已經被使用了呢?我們最好在某個地方記錄一下哪些頁是可用的,我們可以把所有空閒的頁包裝成一個節點組成一個雙向連結串列,這個連結串列也可以被稱作(或者說空閒連結串列)。

如果InnoDB剛剛啟動,快取區的所有快取頁都是空閒的,每一個快取頁都會被加入到空閒連結串列中,此時空閒列表的結構如下所示(此處省略資料頁,空閒連結串列的指標指向資料塊的控制塊)。

InnoDB快取區空閒空間

在需要載入快取頁到BufferPool的情況下,如果空閒連結串列不為空,我們可以從空閒連結串列中獲取一頁空閒資料頁,將快取放入空閒的資料頁。以LRU(後文詳細介紹)為例,InnoDB啟動後,LRU載入第一個快取頁之後,BufferPool中的資料情況如下所示。

InnoDB快取區空閒空間使用

使用者資料管理

使用者資料管理是BufferPool中最重要的資料,包含表資料與索引資料等資料,使用者資料會按照資料的狀態進行管理,主要包含以下資料管理,下文會一一介紹這幾種連結串列:

  1. 最近最少使用連結串列(Least Recently Used, LRU):InnoDB中最重要的連結串列,包含所有讀取進來的資料頁;
  2. 髒頁連結串列(Flush LRU List):管理LRU中的髒頁,後臺執行緒定時寫入磁碟;
  3. 解壓頁連結串列(Unzip LRU List):管理LRU中的解壓頁資料,解壓頁資料是從壓縮頁通過解壓而來的;
  4. 壓縮頁連結串列(Zip List):顧名思義,對頁資料壓縮後組成的連結串列;

最近最少使用連結串列LRU

最近最少使用連結串列LRU用於快取表資料與索引資料,由於記憶體大小通常遠遠小於磁碟大小,記憶體中無法快取全部的資料庫資料,所以快取通常需要一定的淘汰策略,淘汰快取中不經常使用的資料頁。InnoDB的BufferPool採用了改進版的LRU的淘汰策略。

如下圖所示,LRU連結串列的結構和空閒連結串列的結構類似,是一個雙向連結串列,連結串列中的節點包含指向資料頁控制塊的指標,可以通過控制塊訪問資料頁中的資料。

InnoDB快取區LRU連結串列

當需要將新資料頁新增到緩衝池時,最近最少使用的資料頁會可能會從LRU連結串列中淘汰,並將新資料頁新增到LRU連結串列的中間。此插入點將列LRU連結串列劃分為兩個子連結串列:

  1. 頭部的5/8區域,最近訪問多的熱資料列表;
  2. 尾部的3/8區域,最近訪問少的冷資料列表;

InnoDB快取區LRU連結串列

LRU演算法會將經常使用的資料頁保留在熱資料列表中,冷資料列表中包含了不經常訪問的資料頁,這些資料頁是LRU列表滿了之後最先被淘汰的資料。預設情況下,演算法的流程如下:

  1. LRU連結串列的的後3/8區域用於儲存冷資料;
  2. LRU連結串列的中點是熱資料尾部與冷資料頭部相交的邊界;
  3. 被訪問的冷資料會從冷資料連結串列移動到熱資料連結串列;
  4. 熱資料連結串列中的資料如果長時間不訪問,會逐漸移入冷資料連結串列;
  5. 冷資料長時間不被訪問,並且LRU連結串列滿了,那麼末尾的冷資料會淘汰出LRU連結串列;
  6. 預讀的資料只會插入LRU連結串列,不會被移動到熱資料連結串列;

LRU演算法還有一個問題,當某一個SQL語句,要批量掃描大量資料時,由於這些頁都會被訪問,可能導致把緩衝池的所有頁都替換出去,導致大量熱資料被換出,MySQL效能急劇下降,這種情況叫緩衝池汙染。MySQL緩衝池加入了一個冷資料停留時間視窗的機制:

  1. 假設T=冷資料停留時間視窗;
  2. 插入冷資料頭部的資料頁,即使立刻被訪問,也不會立刻放入新生代頭部;
  3. 只有滿足被訪問並且在冷資料區域停留時間大於T,才會被放入新生代頭部;

加入冷資料停留時間視窗策略後,短時間內被大量載入的頁,並不會立刻插入新生代頭部,而是優先淘汰那些短期內僅僅訪問了一次的頁。

MySQL中LRU連結串列相關的引數:

  • innodb_old_blocks_pct:冷資料佔整個LRU鏈長度的比例,預設是3/8,即整個LRU中熱資料與冷資料長度比例是5:3。
  • innodb_old_blocks_time冷資料停留時間視窗機制中冷資料停留時長;

髒資料連結串列FLU

當需要更新一個資料頁時,如果資料頁在記憶體中就直接更新更新記憶體中的資料,但是由於寫回磁碟的代價比較高,所以InnoDB並不會立刻把修改後的資料寫回磁碟,此時,就出現了快取區資料頁和磁碟資料頁中的資料不一致的情況,這種情況下快取區資料頁被稱為髒頁,管理所有髒頁的連結串列叫髒資料連結串列,以下為髒資料連結串列的示例圖:

InnoDB快取區LRU連結串列

髒資料連結串列是LRU連結串列的子集,LRU連結串列包含了所有的髒頁資料。髒頁中的資料最終是要寫回磁碟的,將記憶體資料頁刷到磁碟的操作稱為刷髒,以下是幾種會觸發InnoDB刷髒的情況

  • InnoDB的RedoLog寫滿了,這時候系統會停止所有更新操作,把Checkpoint往前推進,RedoLog留出空間可以繼續寫;
  • 當系統記憶體不足,需要把一個髒頁要從LRU連結串列中淘汰時,要先把髒頁寫回磁碟;
  • MySQL在空閒時,會自動把一部分髒頁寫回磁碟;
  • MySQL正常關閉時,會把所有髒頁都寫回磁碟;

InnoDB中可以通過一些引數設定刷髒行為:

  • innodb_io_capacity:MySQL資料檔案所在磁碟的IO能力,innodb_io_capacity引數會影響MySQL刷髒頁的速度。磁碟的IOPS可以通過FIO工具來測試,測試命令如下所示:

    fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest
    

    如果不能正確地設定innodb_io_capacity引數,可能能導致資料庫效能問題。舉個例子說明:如果MySQL主機磁碟用的是SSD,但是innodb_io_capacity的值設定的是比較低,只有300。這種情況下,InnoDB認為這個系統的IO能力只有300,所以刷髒頁刷得特別慢,甚至比髒頁生成的速度還慢,這樣就造成了髒頁累積,影響了查詢和更新效能。

  • innodb_flush_neighbors:在準備刷一個髒頁的時候,如果這個資料頁旁邊的資料頁剛好是髒頁,就會把這個“鄰居”也帶著一起刷掉;而且這個把“鄰居”拖下水的邏輯還可以繼續蔓延,也就是對於每個鄰居資料頁,如果跟它相鄰的資料頁也還是髒頁的話,也會被放到一起刷。innodb_flush_neighbors引數就是用來控制這個行為的,值為1的時候會有上述的“連坐”機制,值為0時表示不找鄰居,自己刷自己的。對於SSD這類IOPS比較高的裝置,IOPS往往不是瓶頸,innodb_flush_neighbors應該設定為0。在MySQL8.0中,innodb_flush_neighbors引數的預設值已經是0了。

  • innodb_max_dirty_pages_pct:髒頁比例超過innodb_max_dirty_pages_pct之後,InnoDB會全力刷髒頁,如果沒超過這個比例,那麼刷髒頁速度=max(當前髒頁比例/innodb_max_dirty_pages_pct*innodb_io_capacity, RedoLog的快取大小計算刷髒頁速度);

壓縮頁連結串列(Zip List)

Mysql允許使用者對錶進行壓縮以節省磁碟空間,這些壓縮頁的資料在進入記憶體之後,要進行解壓之後才能使用。

我們可以通過以下SQL語句建立一張InnoDB資料表:

create table user_info
(
    id   int primary key,
    age  int not null,
    name varchar(16),
    sex  bool
)engine=InnoDB;

對於建立好的InnoDB資料表,我們可以通過以下SQL語句對錶進行壓縮,壓縮後表佔用的磁碟空間會減小:

alter table user_info row_format=compressed;

InnoDB中的表壓縮是針對表資料頁的壓縮,不僅可以壓縮表資料,還可以壓縮表索引。壓縮頁的大小可以是1k/2k/4k/8k。

壓縮頁連結串列儲存的就是這些壓縮後的頁,壓縮頁在載入進記憶體之後,並不會立即解壓,而是在需要使用的時候再進行解壓。

壓縮頁有不同的大小1k/2k/4k/8k,InnoDB使用了夥伴管理演算法來管理壓縮頁。有5個ZipFree連結串列分別管理1k/2k/4k/8k/16K的記憶體碎片,8K的連結串列裡儲存的都是8K的碎片,如果新讀入一個8K的頁面,首先從這個連結串列中查詢,如果有則直接返回,如果沒有則從16K的連結串列中分裂出兩個8K的塊,一個被使用,另外一個放入8K連結串列中。

解壓頁連結串列(Unzip LRU List)

壓縮頁連結串列中的資料都是被壓縮的,不能直接CRUD,使用前需要解壓,解壓後的資料都儲存在解壓頁連結串列中,解壓頁連結串列中的資料寫回磁碟時需要壓縮。

自適應雜湊索引

我們知道B+樹預設的索引資料結構是B+樹,B+樹對範圍查詢或者LIKE語法的支援比較好。

如果資料庫中有大量的等值查詢,使用雜湊索引能顯著提升查詢效率。Innodb儲存引擎會監控對錶上二級索引的查詢,如果發現某二級索引被頻繁訪問,二級索引成為熱資料,會對該熱點資料建立記憶體雜湊索引,這個索引被稱為自適應雜湊索引。

自適應雜湊索引預設是開啟狀態,可以通過設定innodb_adaptive_hash_index變數或在啟動MySQL時新增--skip-innodb-adaptive-hash-index變數啟用自適應雜湊索引。

InnoDB中可以檢視到雜湊索引的使用情況,命令及輸出如下所示:

mysql> show engine innodb status\G
……
Hash table size 34673, node heap has 0 buffer(s)
0.00 hash searches/s, 0.00 non-hash searches/s

ChangeBuffer

在修改資料庫資料時,如果對應的資料頁剛剛好在快取區,可以之間修改快取區的資料頁,並把資料頁標記為髒頁。

如果修改資料資料時,對應的資料頁如果不在快取區,就需要把資料頁從磁碟載入到快取區,然後進行修改。對於寫多讀少的場景,會產生大量的磁碟IO,影響資料庫的效能。

Change Buffer對資料更新過程有加速作用。 如果資料頁沒有在記憶體中,會將更新操作快取到Change Buffer 中,這樣就不需要從磁碟讀入這個資料頁,減少了IO操作,提高了效能。 先將更新操作,記錄在Change Buffer 中,之後再進行 merge,真正進行資料更新。InnoDB Change Buffer比較複雜,我會在後續單獨章節中進行介紹。

InnoDB Change Buffer

行鎖資訊管理

InnoDB支援行鎖,可以對資料庫中的資料進行加鎖操作,這些鎖資訊也存放在BufferPool中,具體儲存格式此處不做詳細解釋。

既然鎖資訊都存放在BufferPool中,那麼鎖的數目肯定受快取區大小的影響,如果InnoDB中鎖佔據的空間超過了BufferPool總大小的70%,在新新增鎖時會報以下錯誤:

[FATAL] InnoDB: Over 95 percent of the buffer pool is occupied by lock heaps or the adaptive hash index! Check that your transactions do not set too many row locks. Your buffer pool size is 8 MB. Maybe you should make the buffer pool bigger? We intentionally generate a seg fault to print a stack trace on Linux!For more information, see Help and Support Center at http://www.mysql.com.

我是御狐神,歡迎大家關注我的微信公眾號:wzm2zsd

qrcode_for_gh_83670e17bbd7_344-2021-09-04-10-55-16

參考文件

  1. MySQL 8.0 Reference Manual/The InnoDB Storage Engine/InnoDB Architecture
  2. Chunk Change: InnoDB Buffer Pool Resizing
  3. 玩轉MySQL之十InnoDB Buffer Pool詳解
  4. InnoDB的Buffer Pool簡介
  5. Mysql的Innodb儲存引擎緩衝池個人理解
  6. InnoDB關鍵特性之自適應hash索引
  7. InnoDB頁壓縮技術

本文最先發布至微信公眾號,版權所有,禁止轉載!

相關文章