MySQL 基礎(一)資料儲存
儲存在磁碟上的資料需要通過 IO 來讀取,這是一個比較耗時的操作,為了能夠提高訪問速度,MySQL
引入了 Page
的結構作為客戶端與資料互動的基本單元。
Page 結構
Page
的大小預設為 16 kb,由於這個大小可能無法與某些作業系統的頁大小相匹配,這種情況下可能會使得對於 Page
的寫入無法保證原子性(即 Page
沒有完全寫入,這種情況非常危險),為了解決這個問題,可以設定 Page
的大小,具體在 /etc/my.inf
檔案中配置(windows
上為 my.ini
檔案):
# 將 Page 的大小設定為 4kb,只有在 MySQL 5.6 之後使用 InnoDB 儲存引擎才可以修改 innodb_page_size=4K
出於不同的目的,Page
也有許多型別,比如,用於儲存索引的 Page
被稱為索引頁,儲存資料的 Page
被稱為資料頁,儲存日誌的 Page
被稱為日誌資訊頁等
Page
的一般結構如下圖所示:
具體介紹如下:
File Header
:38 位元組,用於儲存頁的通用資訊Page Header
:56 位元組,用於儲存頁的專用資訊,即頁的狀態資訊Infimum + Supermum
:26 位元組,用於儲存當前頁的最小記錄和最大記錄User Records
:用於儲存在當前頁中實際儲存的資料Free Space
:表示當前頁可用的儲存空間Page Directory
:頁目錄,儲存使用者記錄的相對位置File Tailer
:8 位元組,這個屬性的主要目的是用於檢測當前的Page
是否是完整的
Row 的結構
當讀取資料時,將會從 Table
中載入資料到 Page
,而在表中資料是以 row
為基本單位讀取到 Page
中,即儲存在 User Record
中的資料是以 row
為單位的
MySQL
中 row
的結構如下圖所示(以 COMPACT
行格式為例):
關鍵的部分在於記錄頭資訊的組成部分,具體解釋如下:
-
“預留位 1” 和 “預留位 2”:這兩個位置是保留位使得之後的版本可以進行擴充套件
-
deleted_flag
:當前對應的row
是否被刪除,這裡只是邏輯上的刪除。將這個位置為 1 表示已經被刪除,通過這個標記位,可以將所有被刪除的row
row
的空間可以被複用 -
min_rec_flag
:B+ 樹每層非葉子節點中標識最小的目錄項記錄 -
n_owner
:把一個頁劃分成若干個組,提高整體的效能。改組中主鍵最大的row
將會儲存該值,表示該組中存在的記錄的數量 -
heap_no
:堆號,每條插入進來的記錄都會被分配一個堆號,插入的堆號從 2 開始,這是因為Infimum
的堆號設定為 0,Supremum
的堆號設定為 1,因此新插入的記錄的堆號需要從 2 開始計數 -
record_type
:插入的記錄的型別,0 表示普通資料記錄,1 表示 B+ 樹的非葉子節點目錄項記錄,2 表示Infimum
的記錄,3 表示Supremum
的記錄 -
next_record
:下一條記錄,從當前記錄的真實資料到下一條的真實資料的距離(忽略 “記錄的額外資訊部分”)
底層 row
的儲存將會按照主鍵的順序從下到大依次排序,因此即使資料的插入順序不是按照順序來的,那麼最後的儲存依舊會是有序的
底層的 row
記錄的特點:
- 記錄會按照主鍵順序從小到大排列
row
的儲存是通過鏈式結構來連線
Page Directory
由於底層的 row
的儲存是以鏈式的結構來連線的,如果所有的資料都通過這種方式來連線,這樣會使得資料的查詢變得相當緩慢。為了提高查詢的速度,在 MySQL
中將會通過 Page Directory
來將記錄進行分組,每個組對應一個 solt
,查詢時首先通過定位到對應的 solt
,然後再去按照連結串列的順序進行遍歷查詢,從而提升了查詢的速度
具體的結構如下圖所示:
分組的規則:
- 對於
Infimum
記錄,組內只能有一條記錄,即Infimum
記錄本身 - 對於
Supremum
記錄,組內只能有 1 ~ 8 條記錄 - 對於其它的記錄,組內只能有 4 ~ 8 條記錄
分組的步驟:
- 首先,最初的時候,沒有記錄,此時資料頁中只有兩個組:
Infimum
和Supremum
,此時每個組中都只有一條記錄 - 之後再插入資料,會將資料插入到
Supremum
對應的組中 - 當插入的資料使得
Supremum
對應的組達到上限,此時就會使得組分裂,申請新的solt
,指向插入組中的主鍵最大的記錄
記錄的刪除
為了提高系統的效能,在刪除記錄時不會直接刪除資料,而是在 Page
中執行以下步驟:
- 找到要刪除的記錄,將當前記錄的
deleted_flag
標記位置為 1,表示該記錄已經被刪除 - 修改上一條記錄的
next_record
指標,使得它指向當前資料的下一條記錄 (由於Infimum
和Supremum
記錄的儲存,任意一條記錄的前後記錄都不會為空,保證了穩定性) - 之後,需要修改對應的組中持有
n_owner
值的記錄,將這個組中的n_owner
的值 -1
由於刪除時並沒有立刻直接將記錄所在的記憶體給釋放,因此如果之後再有新的資料插入時,對於滿足條件的記錄將會複用當前刪除的記錄所在的記憶體空間,再修改相關聯的屬性,這樣就避免了由於頻繁地申請和釋放空間而帶來的時間消耗
記錄的插入
正常情況下,如果當前的 Page
能夠容納插入的資料,那麼就會直接將資料放入的當前的 Page
中,然後修改相關聯的指標、n_owner
、分組等資料
如果插入的資料在當前頁沒有足夠的空間能夠容納它,那麼將會首先申請一個新的 Page
,然後將當前頁中主鍵最大的記錄提取到新申請的頁中,再將新的資料插入到當前處理的 Page
中,最終需要保證資料在這兩個頁中依舊是按照主鍵的順序從小到大排序的。
結合這一點,在設計表時建議使用自增的欄位作為主鍵,這樣就能夠避免由於在兩個頁之間移動記錄所帶來的開銷
索引
在單個 Page
中查詢資料較為簡單,也比較快,但是當查詢的資料量很大時,將會在多個 Page
中查詢資料,此時如果再按照順序查詢的方式進行查詢,那麼效率將會十分低下。為了提高查詢的效率,引入索引來加快查詢的速度。
一般來講,資料庫管理系統中一般存在兩種索引:Hash
索引和 B
樹索引,由於 Hash
索引在 MySQL
的高版本的引擎中已經不再使用了,因此在這裡不做介紹。當下最為流行的是使用 B+
樹作為索引結構
B+ 樹索引
B-
樹和 B+
樹是資料結構相關的內容,在此不做過多的介紹,感興趣的可以參考:https://www.cnblogs.com/nullzx/p/8729425.html
一個使用 B+
樹索引的表的儲存結構如下:
在圖中,藍色的部分表示記錄所屬的型別,回憶一下上文提到的關於 row
的 record_type
屬性,0 表示普通資料,1 表示索引節點,2 表示 Infimum
記錄,3 表示 Superemum
記錄。圖中 Page 37
、Page 30
、Page 35
都是索引型別的 Page
,這些 Page
與 B+
樹中的節點相對應,通過 B+
樹的索引節點,能夠顯著地加快查詢的速度。
二級索引(非主鍵)
所謂二級索引,就是給非主鍵的列加上對應的索引,為了能夠提高可用性,會在 B+
樹的葉子節點中加上記錄對應的主鍵,以 “索引列 + 主鍵” 的形式存在,這樣就能夠通過主鍵執行 “回表” 查詢。
依舊以上文圖中的 index_demo
表中的資料為例,現在單獨對 c2
列建立索引,此時的儲存情況如下所示:
聯合索引(多列)
預設情況下,在為單獨的一列(非主鍵)加上索引時,只會為該列對應的葉子節點新增附屬的主鍵的資料,這是為了節約記憶體而作出的選擇。但是有時候可能為了加快搜索速度,並不希望再通過主鍵進行 “回表” 查詢去獲取另一列的資料,在這種情況下,可以考慮將額外的一列作為附屬列新增到要新增索引的列中,這種索引也被成為 “聯合索引”
“聯合索引” 只是單純地加上附屬列的資料,附屬列本身不具備索引,但是在底層排序時也會將附屬列作為參考條件進行排序(優先順序低於索引列),這也就是為什麼聯合索引走的是 “最左原則” 的原因
依舊以上文中的 index_demo
為例,現在同時對 c2
和 c3
加上聯合索引,此時的索引結構如下圖所示:
從圖中可以看到,只有對 c2
列的資料進行查詢時,才能通過索引來查詢資料,單獨通過 c3
是無法通過 “聯合索引” 來查詢的
目錄項記錄的唯一性
當建立的二級索引含有大量的相同的資料時,此時對於索引頁來講無法單獨通過該索引列來繼續進行向下的查詢的。為了解決這個問題,MySQL
針對這種情況,將會在索引頁中加上對應的主鍵屬性,這樣就能夠結合主鍵來得到 “唯一性” 的保證
Buffer Pool
由於磁碟的 IO 處理速度要遠小於計算機的處理速度,因此如果直接同磁碟進行 IO 的操作將會成為效能的瓶頸,為了提高處理的速度,MySQL
通過引入 Buffer Pool
作為和磁碟之間互動的緩衝區,大部分的操作將直接在緩衝區中完成,在一定的時間間隔後開啟一個執行緒將資料沖刷到磁碟上,以此來提高資料庫的整體效能
Buffer Pool
的基本結構如下圖所示:
具體介紹:
- 整個記憶體區間是一塊連續的記憶體區間,每個控制塊對應著管理的緩衝頁。
innodb_buffer_pool_size
不包含控制塊區域的大小,因此一般情況下申請的空間會大於innodb_buffer_pool_size
指定的大小innodb_buffer_poll_size
預設大小為 128 MB,可以通過在/etc/my.cnf
檔案中進行配置- 通過 Hash 表來判斷資料庫中的頁是否載入到緩衝區,其中,Hash 表的 key 為 “表空間 + 頁號”,value 為緩衝區中的控制塊
free 連結串列
free 連結串列用於維護控制塊,具體的結構如下圖所示:
黃色區域表示的是及基節點,用於維護連結串列的頭節點和尾節點,綠色部分表示連結串列中實際的控制塊。
flush 連結串列
用於將在記憶體中的 Page
寫回到磁碟中,具體的結構如下圖所示:
該連結串列的作用是維護髒頁對應的控制塊,在某個時刻再將它們寫回到磁碟中,具體的寫入方式如下:
- 從 flush 連結串列中重新整理一部分頁面到磁碟,這是通過後臺執行緒來實現的,
- 根據系統的繁忙程度來確定重新整理的速度(BUFFER_FLUSH_LIST)。
- 系統很繁忙的情況下,會使得重新整理髒頁到磁碟的速度會很慢,可能會導致這麼一種情況:當讀取不在緩衝區中的資料頁時,由於當前緩衝區的所有頁都是髒頁,將會導致無法將磁碟中的頁讀到緩衝區中。在這種情況下,將會去檢視
LRU
連結串列的尾部,檢測是否存在可以直接釋放的未修改的緩衝頁;如果沒有,將不得不將LRU
連結串列尾部的一個髒頁同步重新整理到磁碟中。(BUFFER_FLUSH_SINGLE_PAGE)
- 從
LRU
連結串列的了冷資料彙總重新整理一部分頁到磁碟(BUFFER_FLUSH_LRU)
LRU 連結串列
這是一種在計算機系統中常用的處理連結串列,在 MySQL
中具體的結構如下:
young 區表示最近頻繁被訪問的資料,也被稱為熱資料,由於經常被訪問,因此它們存在的實現要比較長;而 old 區則表示很少被訪問的頁,也被稱為冷資料,當需要釋放記憶體時,將會首先釋放 old 區的資料。
在 MySQL
中的 InnoDB
中,使用 LRU 連結串列會存在以下的一些問題:
- 預讀問題
InnoDB
認為對於當前頁的讀取,對於當前讀取頁之後的頁,認為在查詢該頁周圍的頁,也是你將要讀取的頁。因此它會把當前讀取頁周圍的一些頁一併載入到緩衝池中,導致一部分緩衝池內的頁失效。- 預讀分為兩種:線性預讀和隨機預讀
- 線性預讀:表空間—> 區(64個頁) —> 頁,當線性訪問超過一定的閾值時,就會執行線性預讀
- 隨機預讀:預設不開啟,只要連續讀取超過一定閾值,就會讀取整個區的頁。
- 解決方案
- 當頁從磁碟中載入時,將載入的頁放入冷資料區域。通過適當增大冷資料區的大小可以有效解決該問題。
- 設定冷資料區比例的引數:
innodb_old_blocks_pct
(預設 37)
- 全表查詢導致緩衝頁失效
- 由於未加上查詢條件,或者未命中索引,將會導致全表掃描,直接將整個表的頁全部讀取到緩衝池中,使得整個緩衝池中的頁失效
- 解決方案
- 在從磁碟載入頁到冷資料區之後,通過設定一個時間閾值,只有在該頁上訪問超過這個時間閾值時才能進入熱資料區。
- 設定時間閾值的引數:
innodb_old_blocks_time
(預設為 1000 ms)
Chunk
由於多個執行緒的訪問,可能會由於加鎖的原因導致效能的下降,因此可以將 Buffer Pool 設定為多個例項
Chunk 的作用是為了細化 Buffer Pool,優化 Buffer Pool(因為較小連續的記憶體空間要比較大的連續記憶體空間更加容易分配)
引入 Chunk 後,最終記憶體中的資料組成結構如下所示: