以下內容來自掘金小冊 MySQL 是怎樣運行的:從根兒上理解 MySQL
版權歸原作者所有!
InnoDB數據頁結構
-
InnoDB為了不同的目的而設計了不同類型的頁,我們把用於存放記錄的頁叫做
數據頁。 -
一個數據頁可以被大致划分為7個部分,分別是
File Header,表示頁的一些通用信息,占固定的38字節。Page Header,表示數據頁專有的一些信息,占固定的56個字節。Infimum + Supremum,兩個虛擬的偽記錄,分別表示頁中的最小和最大記錄,占固定的26個字節。User Records:真實存儲我們插入的記錄的部分,大小不固定。Free Space:頁中尚未使用的部分,大小不確定。Page Directory:頁中的某些記錄相對位置,也就是各個槽在頁面中的地址偏移量,大小不固定,插入的記錄越多,這個部分占用的空間越多。File Trailer:用於檢驗頁是否完整的部分,占用固定的8個字節。
-
每個記錄的頭信息中都有一個
next_record屬性,從而使頁中的所有記錄串聯成一個單鏈表。 -
InnoDB會為把頁中的記錄划分為若干個組,每個組的最后一個記錄的地址偏移量作為一個槽,存放在Page Directory中,所以在一個頁中根據主鍵查找記錄是非常快的,分為兩步:
- 通過二分法確定該記錄所在的槽。
- 通過記錄的next_record屬性遍歷該槽所在的組中的各個記錄。
-
每個數據頁的
File Header部分都有上一個和下一個頁的編號,所以所有的數據頁會組成一個雙鏈表。 -
為保證從內存中同步到磁盤的頁的完整性,在頁的首部和尾部都會存儲頁中數據的校驗和和頁面最后修改時對應的
LSN值,如果首部和尾部的校驗和和LSN值校驗不成功的話,就說明同步過程出現了問題。
InnoDB記錄結構
-
頁是
MySQL中磁盤和內存交互的基本單位,也是MySQL是管理存儲空間的基本單位。 -
指定和修改行格式的語法如下:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名稱 ALTER TABLE 表名 ROW_FORMAT=行格式名稱 -
InnoDB目前定義了4種行格式
COMPACT行格式
具體組成如圖:

Redundant行格式
具體組成如圖:

Dynamic和Compressed行格式
這兩種行格式類似於COMPACT行格式,只不過在處理行溢出數據時有點兒分歧,它們不會在記錄的真實數據處存儲字符串的前768個字節,而是把所有的字節都存儲到其他頁面中,只在記錄的真實數據處存儲其他頁面的地址。
另外,Compressed行格式會采用壓縮算法對頁面進行壓縮。
一個頁一般是16KB,當記錄中的數據太多,當前頁放不下的時候,會把多余的數據存儲到其他頁中,這種現象稱為行溢出。
B+樹索引
- 每個索引都對應一棵
B+樹,B+樹分為好多層,最下邊一層是葉子節點,其余的是內節點。所有用戶記錄都存儲在B+樹的葉子節點,所有目錄項記錄都存儲在內節點。
InnoDB存儲引擎會自動為主鍵(如果沒有它會自動幫我們添加)建立聚簇索引,聚簇索引的葉子節點包含完整的用戶記錄。- 我們可以為自己感興趣的列建立
二級索引,二級索引的葉子節點包含的用戶記錄由索引列 + 主鍵組成,所以如果想通過二級索引來查找完整的用戶記錄的話,需要通過回表操作,也就是在通過二級索引找到主鍵值之后再到聚簇索引中查找完整的用戶記錄。 B+樹中每層節點都是按照索引列值從小到大的順序排序而組成了雙向鏈表,而且每個頁內的記錄(不論是用戶記錄還是目錄項記錄)都是按照索引列的值從小到大的順序而形成了一個單鏈表。如果是聯合索引的話,則頁面和記錄先按照聯合索引前邊的列排序,如果該列值相同,再按照聯合索引后邊的列排序。- 通過索引查找記錄是從
B+樹的根節點開始,一層一層向下搜索。由於每個頁面都按照索引列的值建立了Page Directory(頁目錄),所以在這些頁面中的查找非常
首先我們需要知道在innoDB中一條記錄的格式:
將記錄格式的其他信息去掉並把它豎起來的效果就是這樣

把一些記錄放到頁里邊的示意圖就是:

注意record_type的取值不同,代表着該條記錄有不同的含義:
一個頁面里面里的記錄通過next_record指針串成一個鏈表,為了查找方便,為這個鏈表設置了兩個虛擬頭節點: 最小記錄和最大記錄:
record_type = 1表示是最小記錄,record_type = 3表示是最大記錄。
record_type = 0表示是普通用戶記錄。
record_type = 2表示是索引項目記錄。這個后面再說。
頁分裂
假設我們的每個數據頁最多能存放3條記錄(實際上一個數據頁非常大,可以存放下好多記錄),innoDB在插入數據項的時候,會按照主鍵值的大小順序串聯成一個單向鏈表:

上圖中的三條記錄按照主鍵(橙色)由小到大的順序串成一個鏈表
此時我們再插入一條記錄,因為頁10最多只能放3條記錄,所以我們不得不再分配一個新頁:

頁10中用戶記錄最大的主鍵值是5,而頁28中有一條記錄的主鍵值是4,因為5 > 4,所以這就不符合下一個數據頁中用戶記錄的主鍵值必須大於上一個頁中用戶記錄的主鍵值的要求,所以在插入主鍵值為4的記錄的時候需要伴隨着一次記錄移動,也就是把主鍵值為5的記錄移動到頁28中,然后再把主鍵值為4的記錄插入到頁10中。我們必須通過一些諸如記錄移動的操作來始終保證這個狀態一直成立:下一個數據頁中用戶記錄的主鍵值必須大於上一個頁中用戶記錄的主鍵值。這個過程我們也可以稱為頁分裂。
B+樹索引在空間和時間上都有代價,所以沒事兒別瞎建索引。B+樹索引適用於下邊這些情況:
- 全值匹配
- 匹配左邊的列
- 匹配范圍值
- 精確匹配某一列並范圍匹配另外一列
- 用於排序
- 用於分組
在使用索引時需要注意下邊這些事項:
- 只為用於搜索、排序或分組的列創建索引
- 為列的基數大的列創建索引
- 索引列的類型盡量小
- 可以只對字符串值的前綴建立索引
- 只有索引列在比較表達式中單獨出現才可以適用索引
- 為了盡可能少的讓
聚簇索引發生頁面分裂和記錄移位的情況,建議讓主鍵擁有AUTO_INCREMENT屬性。 - 定位並刪除表中的重復和冗余索引
- 盡量使用
覆蓋索引進行查詢,避免回表帶來的性能損耗。
其他的部分見掘金小冊!
表空間

單表訪問方法
訪問方法(access method)
兩大類
- 使用全表掃描進行查詢
- 使用索引進行查詢
六小類:
const、ref、ref_or_null、range、index、all
const
通過主鍵或者唯一二級索引列(即該索引列是像主鍵一樣是不含重復值的)與常數的等值比較來定位一條記錄是像坐火箭一樣快的,所以他們把這種通過主鍵或者唯一二級索引列來定位一條記錄的訪問方法定義為:
const,意思是常數級別的,代價是可以忽略不計的。不過這種const訪問方法只能在主鍵列或者唯一二級索引列和一個常數進行等值比較時才有效,如果主鍵或者唯一二級索引是由多個列構成的話,索引中的每一個列都需要與常數進行等值比較,這個const訪問方法才有效(這是因為只有該索引中全部列都采用等值比較才可以定位唯一的一條記錄)。對於唯一二級索引來說,查詢該列為
NULL值的情況比較特殊,比如這樣:SELECT * FROM single_table WHERE key2 IS NULL;因為唯一二級索引列並不限制 NULL 值的數量,所以上述語句可能訪問到多條記錄,也就是說 上邊這個語句不可以使用
const訪問方法來執行。
ref
先使用二級索引找到對應記錄的
id值,然后再回表到聚簇索引中查找完整的用戶記錄,采用二級索引來執行查詢的訪問方法稱為:ref對於普通的二級索引來說,通過索引列進行等值比較后可能匹配到多條連續的記錄,而不是像主鍵或者唯一二級索引那樣最多只能匹配1條記錄,所以這種
ref訪問方法比const差了那么一丟丟,但是在二級索引等值比較時匹配的記錄數較少時的效率還是很高的(如果匹配的二級索引記錄太多那么回表的成本就太大了)注意下邊兩種情況:
二級索引列值為
NULL的情況不論是普通的二級索引,還是唯一二級索引,它們的索引列對包含
NULL值的數量並不限制,所以我們采用key IS NULL這種形式的搜索條件最多只能使用ref的訪問方法,而不是const的訪問方法。
- 不論是普通的二級索引,還是唯一二級索引,它們的索引列對包含
NULL值的數量並不限制,所以我們采用key IS NULL這種形式的搜索條件最多只能使用ref的訪問方法,而不是const的訪問方法。
ref_or_null
有時候我們不僅想找出某個二級索引列的值等於某個常數的記錄,還想把該列的值為
NULL的記錄也找出來,就像下邊這個查詢:SELECT * FROM single_demo WHERE key1 = 'abc' OR key1 IS NULL;當使用二級索引而不是全表掃描的方式執行該查詢時,這種類型的查詢使用的訪問方法就稱為
ref_or_null
range
搜索條件就不只是要求索引列與常數的等值匹配了,而是索引列需要匹配某個或某些范圍的值,
如下邊這個查詢:
SELECT * FROM single_table WHERE key2 IN (1438, 6328) OR (key2 >= 38 AND key2 <= 79);
使用二級索引 + 回表的方式執行,利用索引進行范圍匹配的訪問方法稱之為:range
Index
看下邊這個查詢:
SELECT key_part1, key_part2, key_part3 FROM single_table WHERE key_part2 = 'abc';由於
key_part2並不是聯合索引idx_key_part最左索引列,所以我們無法使用ref或者range訪問方法來執行這個語句。但是這個查詢符合下邊這兩個條件:
它的查詢列表只有3個列:
key_part1,key_part2,key_part3,而索引idx_key_part又包含這三個列。搜索條件中只有
key_part2列。這個列也包含在索引idx_key_part中。也就是說我們可以直接通過遍歷
idx_key_part索引的葉子節點的記錄來比較key_part2 = 'abc'這個條件是否成立,把匹配成功的二級索引記錄的key_part1,key_part2,key_part3列的值直接加到結果集中就行了。由於二級索引記錄比聚簇索記錄小的多(聚簇索引記錄要存儲所有用戶定義的列以及所謂的隱藏列,而二級索引記錄只需要存放索引列和主鍵),而且這個過程也不用進行回表操作,所以直接遍歷二級索引比直接遍歷聚簇索引的成本要小很多,設計MySQL的大叔就把這種采用遍歷二級索引記錄的執行方式稱之為:index。
all
最直接的查詢執行方式就是我們已經提了無數遍的全表掃描
連接的原理
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
在這個查詢中我們指明了這三個過濾條件:
t1.m1 > 1
t1.m1 = t2.m2
t2.n2 < 'd'那么這個連接查詢的大致執行過程如下:
- 首先確定第一個需要查詢的表,這個表稱之為
驅動表。只需要選取代價最小的那種訪問方法去執行單表查詢語句(就是說從const、ref、ref_or_null、range、index、all這些執行方法中選取代價最小的去執行查詢)- 針對上一步驟中從驅動表產生的結果集中的每一條記錄,分別需要到
t2表中查找匹配的記錄,所謂匹配的記錄,指的是符合過濾條件的記錄。因為是根據t1表中的記錄去找t2表中的記錄,所以t2表也可以被稱之為被驅動表。整個連接查詢的執行過程就如下圖所示:
這個兩表連接查詢共需要查詢1次
t1表,2次t2表。當然這是在特定的過濾條件下的結果,如果我們把t1.m1 > 1這個條件去掉,那么從t1表中查出的記錄就有3條,就需要查詢3次t2表了。也就是說在兩表連接查詢中,驅動表只需要訪問一次,被驅動表可能被訪問多次。
內連接和外連接
內連接:對於
內連接的兩個表,驅動表中的記錄在被驅動表中找不到匹配的記錄,該記錄不會加入到最后的結果集。外連接:對於
外連接的兩個表,驅動表中的記錄即使在被驅動表中沒有匹配的記錄,也仍然需要加入到結果集。在
MySQL中,根據選取驅動表的不同,外連接仍然可以細分為2種:
左外連接
選取左側的表為驅動表。
右外連接
選取右側的表為驅動表。
WHERE子句
WHERE子句中的過濾條件就是我們平時見的那種,不論是內連接還是外連接,凡是不符合WHERE子句中的過濾條件的記錄都不會被加入最后的結果集。
ON子句中的過濾條件把
ON子句放到外連接中,如果無法在被驅動表中找到匹配ON子句中的過濾條件的記錄,那么該記錄仍然會被加入到結果集中,對應的被驅動表記錄的各個字段使用NULL值填充;把ON子句放到內連接中,MySQL會把它和WHERE子句一樣對待,內連接中的WHERE子句和ON子句是等價的。一般情況下,我們都把只涉及單表的過濾條件放到
WHERE子句中,把涉及兩表的過濾條件都放到ON子句中,我們也一般把放到ON子句中的過濾條件也稱之為連接條件。對於左(外)連接和右(外)連接來說,必須使用ON子句來指出連接條件。內連接和外連接的根本區別就是在驅動表中的記錄不符合
ON子句中的連接條件時會不會把該記錄加入到最后的結果集連接的本質就是把各個連接表中的記錄都取出來依次匹配的組合加入結果集並返回給用戶。不論哪個表作為驅動表,兩表連接產生的笛卡爾積肯定是一樣的。而對於內連接來說,由於凡是不符合
ON子句或WHERE子句中的條件的記錄都會被過濾掉,其實也就相當於從兩表連接的笛卡爾積中把不符合過濾條件的記錄給踢出去,所以對於內連接來說,驅動表和被驅動表是可以互換的,並不會影響最后的查詢結果。但是對於外連接來說,由於驅動表中的記錄即使在被驅動表中找不到符合ON子句連接條件的記錄,所以此時驅動表和被驅動表的關系就很重要了,也就是說左外連接和右外連接的驅動表和被驅動表不能輕易互換。連接的原理
對於兩表連接來說,驅動表只會被訪問一遍,但被驅動表卻要被訪問到好多遍,具體訪問幾遍取決於對驅動表執行單表查詢后的結果集中的記錄條數。
通用的兩表連接過程如下圖所示:
如果有3個表進行連接的話,那么步驟2中得到的結果集就像是新的驅動表,然后第三個表就成為了被驅動表,重復上邊過程,也就是步驟2中得到的結果集中的每一條記錄都需要到t3表中找一找有沒有匹配的記錄
用偽代碼表示一下這個過程就是這樣:
for each row in t1 { #此處表示遍歷滿足對t1單表查詢結果集中的每一條記錄 for each row in t2 { #此處表示對於某條t1表的記錄來說,遍歷滿足對t2單表查詢結果集中的每一條記錄 for each row in t3 { #此處表示對於某條t1和t2表的記錄組合來說,對t3表進行單表查詢 if row satisfies join conditions, send to client } } }
這個過程就像是一個嵌套的循環,所以這種驅動表只訪問一次,但被驅動表卻可能被多次訪問,訪問次數取決於對驅動表執行單表查詢后的結果集中的記錄條數的連接執行方式稱之為嵌套循環連接(Nested-Loop Join),這是最簡單,也是最笨拙的一種連接查詢算法。
使用索引加快連接速度
我們知道在嵌套循環連接的步驟2中可能需要訪問多次被驅動表,如果訪問被驅動表的方式都是全表掃描的話,要掃描很多次。 但是別忘了,查詢t2表其實就相當於一次單表掃描,我們可以利用索引來加快查詢速度。
以
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
為例
原來的t1.m1 = t2.m2這個涉及兩個表的過濾條件在針對t2表做查詢時關於t1表的條件就已經確定了,所以我們只需要單單優化對t2表的查詢了,上述兩個對t2表的查詢語句中利用到的列是m2和n2列,
-
在
m2列上建立索引,因為對m2列的條件是等值查找,比如t2.m2 = 2、t2.m2 = 3等,所以可能使用到ref的訪問方法,假設使用ref的訪問方法去執行對t2表的查詢的話,需要回表之后再判斷t2.n2 < d這個條件是否成立。這里有一個比較特殊的情況,就是假設
m2列是t2表的主鍵或者唯一二級索引列,那么使用t2.m2 = 常數值這樣的條件從t2表中查找記錄的過程的代價就是常數級別的。我們知道在單表中使用主鍵值或者唯一二級索引列的值進行等值查找的方式稱之為const,而設計MySQL的大叔把在連接查詢中對被驅動表使用主鍵值或者唯一二級索引列的值進行等值查找的查詢執行方式稱之為:eq_ref。 -
在
n2列上建立索引,涉及到的條件是t2.n2 < 'd',可能用到range的訪問方法,假設使用range的訪問方法對t2表的查詢的話,需要回表之后再判斷在m2列上的條件是否成立。
假設m2和n2列上都存在索引的話,那么就需要從這兩個里邊兒挑一個代價更低的去執行對t2表的查詢。當然,建立了索引不一定使用索引,只有在二級索引 + 回表的代價比全表掃描的代價更低時才會使用索引。
另外,有時候連接查詢的查詢列表和過濾條件中可能只涉及被驅動表的部分列,而這些列都是某個索引的一部分,這種情況下即使不能使用eq_ref、ref、ref_or_null或者range這些訪問方法執行對被驅動表的查詢的話,也可以使用索引掃描,也就是index的訪問方法來查詢被驅動表。所以我們建議在真實工作中最好不要使用*作為查詢列表,最好把真實用到的列作為查詢列表。
基於塊的嵌套循環連接(Block Nested-Loop Join)
掃描一個表的過程其實是先把這個表從磁盤上加載到內存中,然后從內存中比較匹配條件是否滿足。
如果表太大,內存里可能並不能完全存放的下表中所有的記錄,所以在掃描表前邊記錄的時候后邊的記錄可能還在磁盤上,等掃描到后邊記錄的時候可能內存不足,所以需要把前邊的記錄從內存中釋放掉。我們前邊又說過,采用嵌套循環連接算法的兩表連接過程中,被驅動表要被訪問多次,如果這個被驅動表中的數據特別多而且不能使用索引進行訪問,那就相當於要從磁盤上讀好幾次這個表,這個I/O代價就非常大了,所以我們得想辦法:盡量減少訪問被驅動表的次數。
我們可以在把被驅動表的記錄加載到內存的時候,一次性和多條驅動表中的記錄做匹配,這樣就可以大大減少重復從磁盤上加載被驅動表的代價了。所以提出一個join buffer的概念,join buffer就是執行連接查詢前申請的一塊固定大小的內存,先把若干條驅動表結果集中的記錄裝在這個join buffer中,然后開始掃描被驅動表,每一條被驅動表的記錄一次性和join buffer中的多條驅動表記錄做匹配,因為匹配的過程都是在內存中完成的,所以這樣可以顯著減少被驅動表的I/O代價。使用join buffer的過程如下圖所示:

最好的情況是join buffer足夠大,能容納驅動表結果集中的所有記錄,這樣只需要訪問一次被驅動表就可以完成連接操作了。這種加入了join buffer的嵌套循環連接算法稱之為基於塊的嵌套連接(Block Nested-Loop Join)算法。
join buffer的大小是可以通過啟動參數或者系統變量join_buffer_size進行配置,默認大小為262144字節(也就是256KB),最小可以設置為128字節。當然,對於優化被驅動表的查詢來說,最好是為被驅動表加上效率高的索引,如果實在不能使用索引,並且自己的機器的內存也比較大可以嘗試調大join_buffer_size的值來對連接查詢進行優化。
另外需要注意的是,驅動表的記錄並不是所有列都會被放到join buffer中,只有查詢列表中的列和過濾條件中的列才會被放到join buffer中,所以再次提醒我們,最好不要把*作為查詢列表,只需要把我們關心的列放到查詢列表就好了,這樣可以在join buffer中放置更多的記錄。


