mysql 辅助索引_MySQL InnoDB B+tree索引
假設(shè)我們知道 InnoDB 數(shù)據(jù)頁的結(jié)構(gòu),知道了各個數(shù)據(jù)頁可以組成一個雙向鏈表,而每個數(shù)據(jù)頁中的記錄會按照主鍵值從小到大的順序組成一個單向鏈表,每個數(shù)據(jù)頁都會為存儲在它里邊兒的記錄生成一個頁目錄,在通過主鍵查找某條記錄的時候可以在頁目錄中使用二分法快速定位到對應(yīng)的槽,然后再遍歷該槽對應(yīng)分組中的記錄即可快速找到指定的記錄。頁和記錄的關(guān)系示意圖如下:
其中 頁a、頁b、頁c … 頁n 這些頁可以不在物理結(jié)構(gòu)上相連,只要通過雙向鏈表在邏輯上相關(guān)聯(lián)即可。
沒有索引的查找
在正式介紹索引之前,我們需要了解一下沒有索引的時候是怎么查找記錄的。為了方便大家理解,我們下邊先只嘮叨搜索條件為對某個列精確匹配的情況,所謂精確匹配,就是搜索條件中用等于=連接起的表達(dá)式,比如這樣:
SELECT * FROM table WHERE field = xxx;
1
SELECT*FROMtableWHEREfield=xxx;
在一個頁中的查找
假設(shè)目前表中的記錄比較少,所有的記錄都可以被存放到一個頁中,在查找記錄的時候可以根據(jù)搜索條件的不同分為兩種情況:
以主鍵為搜索條件這個查找過程我們已經(jīng)很熟悉了,可以在頁目錄中使用二分法快速定位到對應(yīng)的槽,然后再遍歷該槽對應(yīng)分組中的記錄即可快速找到指定的記錄。
以其他列作為搜索條件對非主鍵列的查找的過程可就不這么幸運(yùn)了,因為在數(shù)據(jù)頁中并沒有對非主鍵列建立所謂的頁目錄,所以我們無法通過二分法快速定位相應(yīng)的槽。這種情況下只能從最小記錄開始依次遍歷單鏈表中的每條記錄,然后對比每條記錄是不是符合搜索條件。很顯然,這種查找的效率是非常低的。
在很多頁中查找
大部分情況下我們表中存放的記錄都是非常多的,需要好多的數(shù)據(jù)頁來存儲這些記錄。在很多頁中查找記錄的話可以分為兩個步驟:
定位到記錄所在的頁。
從所在的頁內(nèi)中查找相應(yīng)的記錄。
在沒有索引的情況下,不論是根據(jù)主鍵列或者其他列的值進(jìn)行查找,由于我們并不能快速的定位到記錄所在的頁,所以只能從第一個頁沿著雙向鏈表一直往下找,在每一個頁中根據(jù)我們剛剛嘮叨過的查找方式去查找指定的記錄。因為要遍歷所有的數(shù)據(jù)頁,所以這種方式顯然是超級耗時的,如果一個表有一億條記錄,使用這種方式去查找記錄那要等到猴年馬月才能等到查找結(jié)果,必須有一種能高效完成搜索的方法,那就是索引了。
索引
為了故事的順利發(fā)展,我們先建一個表:
mysql> CREATE TABLE index_demo(
-> c1 INT,
-> c2 INT,
-> c3 CHAR(1),
-> PRIMARY KEY(c1)
-> ) ROW_FORMAT = Compact;
Query OK, 0 rows affected (0.03 sec)
1
2
3
4
5
6
7
mysql>CREATETABLEindex_demo(
->c1INT,
->c2INT,
->c3CHAR(1),
->PRIMARYKEY(c1)
->)ROW_FORMAT=Compact;
QueryOK,0rowsaffected(0.03sec)
這個新建的index_demo表中有2個INT類型的列,1個CHAR(1)類型的列,而且我們規(guī)定了c1列為主鍵,這個表使用Compact行格式來實際存儲記錄的。為了我們理解上的方便,我們簡化了一下index_demo表的行格式示意圖:
我們只在示意圖里展示記錄的這幾個部分:
record_type:記錄頭信息的一項屬性,表示記錄的類型,0表示普通記錄、2表示最小記錄、3表示最大記錄、1我們還沒用過,等會再說~
next_record:記錄頭信息的一項屬性,表示下一條地址相對于本條記錄的地址偏移量,為了方便大家理解,我們都會用箭頭來表明下一條記錄是誰。
各個列的值:這里只記錄在index_demo表中的三個列,分別是c1、c2和c3。
其他信息:除了上述3種信息以外的所有信息,包括其他隱藏列的值以及記錄的額外信息。
為了節(jié)省篇幅,我們之后的示意圖中會把記錄的其他信息這個部分省略掉,因為它占地方并且不會有什么觀賞效果。另外,為了方便理解,我們覺得把記錄豎著放看起來感覺更好,所以將記錄格式示意圖的其他信息去掉并把它豎起來的效果就是這樣:
把一些記錄放到頁里邊的示意圖就是:
一個簡單的索引方案
回到正題,我們在根據(jù)某個搜索條件查找一些記錄時為什么要遍歷所有的數(shù)據(jù)頁呢?因為各個頁中的記錄并沒有規(guī)律,我們并不知道我們的搜索條件匹配哪些頁中的記錄,所以?不得不?依次遍歷所有的數(shù)據(jù)頁。所以如果我們想快速的定位到需要查找的記錄在哪些數(shù)據(jù)頁中該咋辦?還記得我們?yōu)楦鶕?jù)主鍵值快速定位一條記錄在頁中的位置而設(shè)立的頁目錄么?我們也可以想辦法為快速定位記錄所在的數(shù)據(jù)頁而建立一個別的目錄,建這個目錄必須完成下邊這些事兒:
1. 下一個數(shù)據(jù)頁中用戶記錄的主鍵值必須大于上一個頁中用戶記錄的主鍵值
為了故事的順利發(fā)展,我們這里需要做一個假設(shè):假設(shè)我們的每個數(shù)據(jù)頁最多能存放3條記錄(實際上一個數(shù)據(jù)頁非常大,可以存放下好多記錄)。有了這個假設(shè)之后我們向index_demo表插入3條記錄:
INSERT INTO index_demo VALUES(1, 4, 'u'), (3, 9, 'd'), (5, 3, 'y');
1
INSERTINTOindex_demoVALUES(1,4,'u'),(3,9,'d'),(5,3,'y');
那么這些記錄已經(jīng)按照主鍵值的大小串聯(lián)成一個單向鏈表了,如圖所示:
從圖中可以看出來,index_demo表中的3條記錄都被插入到了編號為10的數(shù)據(jù)頁中了。此時我們再來插入一條記錄:
INSERT INTO index_demo VALUES(4, 4, 'a');
1
INSERTINTOindex_demoVALUES(4,4,'a');
因為頁10最多只能放3條記錄,所以我們不得不再分配一個新頁:
怎么分配的頁號是28呀,不應(yīng)該是11么?再次強(qiáng)調(diào)一遍,新分配的數(shù)據(jù)頁編號可能并不是連續(xù)的,也就是說我們使用的這些頁在存儲空間里可能并不挨著。它們只是通過維護(hù)著上一個頁和下一個頁的編號而建立了鏈表關(guān)系。另外,頁10中用戶記錄最大的主鍵值是5,而頁28中有一條記錄的主鍵值是4,因為5 > 4,所以這就不符合下一個數(shù)據(jù)頁中用戶記錄的主鍵值必須大于上一個頁中用戶記錄的主鍵值的要求,所以在插入主鍵值為4的記錄的時候需要伴隨著一次記錄移動,也就是把主鍵值為5的記錄移動到頁28中,然后再把主鍵值為4的記錄插入到頁10中,這個過程的示意圖如下:
這個過程表明了在對頁中的記錄進(jìn)行增刪改操作的過程中,我們必須通過一些諸如記錄移動的操作來始終保證這個狀態(tài)一直成立:下一個數(shù)據(jù)頁中用戶記錄的主鍵值必須大于上一個頁中用戶記錄的主鍵值。這個過程我們也可以稱為頁分裂。
2. 給所有的頁建立一個目錄項
由于數(shù)據(jù)頁的編號可能并不是連續(xù)的,所以在向index_demo表中插入許多條記錄后,可能是這樣的效果:
因為這些16KB的頁在物理存儲上可能并不挨著,所以如果想從這么多頁中根據(jù)主鍵值快速定位某些記錄所在的頁,我們需要給它們做個目錄,每個頁對應(yīng)一個目錄項,每個目錄項包括下邊兩個部分:
頁的用戶記錄中最小的主鍵值,我們用key來表示。
頁號,我們用page_no表示。
所以我們?yōu)樯线厧讉€頁做好的目錄就像這樣子:
以頁28為例,它對應(yīng)目錄項2,這個目錄項中包含著該頁的頁號28以及該頁中用戶記錄的最小主鍵值5。我們只需要把幾個目錄項在物理存儲器上連續(xù)存儲,比如把他們放到一個數(shù)組里,就可以實現(xiàn)根據(jù)主鍵值快速查找某條記錄的功能了。比方說我們想找主鍵值為20的記錄,具體查找過程分兩步:
先從目錄項中根據(jù)二分法快速確定出主鍵值為20的記錄在目錄項3中(因為?12 < 20 < 209),它對應(yīng)的頁是頁9。
再根據(jù)前邊說的在頁中查找記錄的方式去頁9中定位具體的記錄。
至此,針對數(shù)據(jù)頁做的簡易目錄就搞定了。這個目錄就是我們說的索引。
InnoDB 中的索引方案
上邊之所以稱為一個簡易的索引方案,是因為我們?yōu)榱嗽诟鶕?jù)主鍵值進(jìn)行查找時使用二分法快速定位具體的目錄項而假設(shè)所有目錄項都可以在物理存儲器上連續(xù)存儲,但是這樣做有幾個問題:
InnoDB 是使用頁來作為管理存儲空間的基本單位,也就是最多能保證16KB的連續(xù)存儲空間,而隨著表中記錄數(shù)量的增多,需要非常大的連續(xù)的存儲空間才能把所有的目錄項都放下,這對記錄數(shù)量非常多的表是不現(xiàn)實的。
我們時常會對記錄進(jìn)行增刪,假設(shè)我們把頁28中的記錄都刪除了,頁28也就沒有存在的必要了,那意味著目錄項2也就沒有存在的必要了,這就需要把目錄項2后的目錄項都向前移動一下,這種牽一發(fā)而動全身的設(shè)計不是什么好主意~
所以,設(shè)計 InnoDB 的大叔們需要一種可以靈活管理所有目錄項的方式。他們靈光乍現(xiàn),忽然發(fā)現(xiàn)這些目錄項其實長得跟我們的用戶記錄差不多,只不過目錄項中的兩個列是主鍵和頁號而已,所以他們復(fù)用了之前存儲用戶記錄的數(shù)據(jù)頁來存儲目錄項,為了和用戶記錄做一下區(qū)分,我們把這些用來表示目錄項的記錄稱為目錄項記錄。那InnoDB怎么區(qū)分一條記錄是普通的用戶記錄還是目錄項記錄呢?別忘了記錄頭信息里的record_type屬性,它的各個取值代表的意思如下:
0:普通的用戶記錄
1:目錄項記錄
2:最小記錄
3:最大記錄
哈哈,原來這個值為1的record_type是這個意思呀,我們把前邊使用到的目錄項放到數(shù)據(jù)頁中的樣子就是這樣:
從圖中可以看出來,我們新分配了一個編號為30的頁來專門存儲目錄項記錄。這里再次強(qiáng)調(diào)一遍目錄項記錄和普通的用戶記錄的不同點:
目錄項記錄的record_type值是1,而普通用戶記錄的record_type值是0。
目錄項記錄只有主鍵值和頁的編號兩個列,而普通的用戶記錄的列是用戶自己定義的,可能包含很多列,另外還有 InnoDB 自己添加的隱藏列。
在記錄頭信息里面有一個叫min_rec_mask的屬性,只有在存儲目錄項記錄的頁中的主鍵值最小的目錄項記錄的min_rec_mask值為1,其他別的記錄的min_rec_mask值都是0。
除了上述幾點外,這兩者就沒啥差別了,它們用的是一樣的數(shù)據(jù)頁(頁面類型都是0x45BF,這個屬性在File Header中,忘了的話可以翻到前邊的文章看),頁的組成結(jié)構(gòu)也是一樣一樣的,都會為主鍵值生成Page Directory(頁目錄),從而在按照主鍵值進(jìn)行查找時可以使用二分法來加快查詢速度。現(xiàn)在以查找主鍵為20的記錄為例,根據(jù)某個主鍵值去查找記錄的步驟就可以大致拆分成下邊兩步:
先到存儲目錄項記錄的頁,也就是頁30中通過二分法快速定位到對應(yīng)目錄項,因為12 < 20 < 209,所以定位到對應(yīng)的記錄所在的頁就是頁9。
再到存儲用戶記錄的頁9中根據(jù)二分法快速定位到主鍵值為20的用戶記錄。
雖然說目錄項記錄中只存儲主鍵值和對應(yīng)的頁號,比用戶記錄需要的存儲空間小多了,但是不論怎么說一個頁只有16KB大小,能存放的目錄項記錄也是有限的,那如果表中的數(shù)據(jù)太多,以至于一個數(shù)據(jù)頁不足以存放所有的目錄項記錄,該咋辦呢?
當(dāng)然是再多整一個存儲目錄項記錄的頁嘍~ 為了大家更好的理解新分配一個目錄項記錄頁的過程,我們假設(shè)一個存儲目錄項記錄的頁最多只能存放4條目錄項記錄(請注意是假設(shè)哦,真實情況下可以存放好多條的),所以如果此時我們再向上圖中插入一條主鍵值為320的用戶記錄的話,那就需要分配一個新的存儲目錄項記錄的頁嘍:
從圖中可以看出,我們插入了一條主鍵值為320的用戶記錄之后需要兩個新的數(shù)據(jù)頁:
為存儲該用戶記錄而新生成了頁31。
因為原先存儲目錄項記錄的頁30的容量已滿(我們前邊假設(shè)只能存儲4條目錄項記錄),所以不得不需要一個新的頁32來存放頁31對應(yīng)的目錄項。
現(xiàn)在因為存儲目錄項記錄的頁不止一個,所以如果我們想根據(jù)主鍵值查找一條用戶記錄大致需要3個步驟,以查找主鍵值為20的記錄為例:
確定目錄項記錄頁我們現(xiàn)在的存儲目錄項記錄的頁有兩個,即頁30和頁32,又因為頁30表示的目錄項的主鍵值的范圍是[1, 320),頁32表示的目錄項的主鍵值不小于320,所以主鍵值為20的記錄對應(yīng)的目錄項記錄在頁30中。
通過目錄項記錄頁確定用戶記錄真實所在的頁。在一個存儲目錄項記錄的頁中通過主鍵值定位一條目錄項記錄的方式說過了,不贅述了~
在真實存儲用戶記錄的頁中定位到具體的記錄。在一個存儲用戶記錄的頁中通過主鍵值定位一條用戶記錄的方式也說過了,不贅述了~
那么問題來了,在這個查詢步驟的第 1 步中我們需要定位存儲目錄項記錄的頁,但是這些頁在存儲空間中也可能不挨著,如果我們表中的數(shù)據(jù)非常多則會產(chǎn)生很多存儲目錄項記錄的頁,那我們怎么根據(jù)主鍵值快速定位一個存儲目錄項記錄的頁呢?其實也簡單,為這些存儲目錄項記錄的頁再生成一個更高級的目錄,就像是一個多級目錄一樣,大目錄里嵌套小目錄,小目錄里才是實際的數(shù)據(jù),所以現(xiàn)在各個頁的示意圖就是這樣子:
如圖,我們生成了一個存儲更高級目錄項的頁33,這個頁中的兩條記錄分別代表頁30和頁32,如果用戶記錄的主鍵值在[1, 320)之間,則到頁30中查找更詳細(xì)的目錄項記錄,如果主鍵值不小于320的話,就到頁32中查找更詳細(xì)的目錄項記錄。隨著表中記錄的增加,這個目錄的層級會繼續(xù)增加,如果簡化一下,那么我們可以用下邊這個圖來描述它:
其實這是一種組織數(shù)據(jù)的形式,或者說是一種數(shù)據(jù)結(jié)構(gòu),它的名稱是B+tree。
Note
我們都知道 CPU 是很快的,磁盤是很慢的,要想提高數(shù)據(jù)庫的訪問效率,可以說非常大的一個優(yōu)化點就是減少磁盤 IO 訪問。每次查找數(shù)據(jù)時把磁盤 IO 次數(shù)控制在一個很小的數(shù)量級,最好是常數(shù)數(shù)量級。那么我們就想到如果一個高度可控的多路搜索樹是否能滿足需求呢?就這樣,B+tree 應(yīng)運(yùn)而生。B+tree 索引的本質(zhì)就是 B+tree 數(shù)據(jù)結(jié)構(gòu)在數(shù)據(jù)庫中的實現(xiàn),但是 B+tree 索引在數(shù)據(jù)庫中有一個特點是高扇出性,因此在數(shù)據(jù)庫中,B+tree 的高度一般都在 2-4 層,這也就是說查找某一鍵值得行記錄最多只需要 2-4 次 IO。這倒不錯,因為當(dāng)前一般的機(jī)械磁盤每秒至少可以做 100 次 IO,2-4 次的 IO 意味著查詢時間只需要 0.02-0.04 秒。
不論是存放用戶記錄的數(shù)據(jù)頁,還是存放目錄項記錄的數(shù)據(jù)頁,我們都把它們存放到B+tree這個數(shù)據(jù)結(jié)構(gòu)中了,所以我們也稱這些數(shù)據(jù)頁為節(jié)點。從圖中可以看出來,我們的實際用戶記錄其實都存放在B+tree的最底層的節(jié)點上,這些節(jié)點也被稱為葉子節(jié)點或葉節(jié)點,其余用來存放目錄項的節(jié)點稱為非葉子節(jié)點或者內(nèi)節(jié)點,其中B+tree最上邊的那個節(jié)點也稱為根節(jié)點。
從圖中可以看出來,一個B+tree的節(jié)點其實可以分成好多層,設(shè)計 InnoDB 的大叔們?yōu)榱擞懻摲奖?#xff0c;規(guī)定最下邊的那層,也就是存放我們用戶記錄的那層為第0層,之后依次往上加。之前的討論我們做了一個非常極端的假設(shè):存放用戶記錄的頁最多存放 3 條記錄,存放目錄項記錄的頁最多存放 4 條記錄。其實真實環(huán)境中一個頁存放的記錄數(shù)量是非常大的,假設(shè),假設(shè),假設(shè)所有存放用戶記錄的葉子節(jié)點代表的數(shù)據(jù)頁可以存放 100 條用戶記錄,所有存放目錄項記錄的內(nèi)節(jié)點代表的數(shù)據(jù)頁可以存放 1000 條目錄項記錄,那么:
如果B+tree只有1層,也就是只有1個用于存放用戶記錄的節(jié)點,最多能存放100條記錄。
如果B+tree有2層,最多能存放1000×100=100000條記錄。
如果B+tree有3層,最多能存放1000×1000×100=100000000條記錄。
如果B+tree有4層,最多能存放1000×1000×1000×100=100000000000條記錄。
你的表里能存放100000000000條記錄么?所以一般情況下,我們用到的B+tree都不會超過 4 層,那我們通過主鍵值去查找某條記錄最多只需要做 4 個頁面內(nèi)的查找(查找 3 個目錄項頁和一個用戶記錄頁),又因為在每個頁面內(nèi)有所謂的Page Directory(頁目錄),所以在頁面內(nèi)也可以通過二分法實現(xiàn)快速定位記錄,這不是很牛么,哈哈!
可以說數(shù)據(jù)庫必須有索引,沒有索引則檢索過程變成了順序查找,O(n) 的時間復(fù)雜度幾乎是不能忍受的。我們非常容易想象出一個只有單關(guān)鍵字組成的表如何使用 B+tree 進(jìn)行索引,只要將關(guān)鍵字存儲到樹的節(jié)點即可。當(dāng)數(shù)據(jù)庫一條記錄里包含多個字段時,一棵 B+tree 就只能存儲主鍵,如果檢索的是非主鍵字段,則主鍵索引失去作用,又變成順序查找了。這時應(yīng)該在第二個要檢索的列上建立第二套索引。這個索引由獨立的 B+tree 來組織。有兩種常見的方法可以解決多個 B+tree 訪問同一套表數(shù)據(jù)的問題,一種叫做聚簇索引(clustered index ),一種叫做非聚簇索引(secondary index)。這兩個名字雖然都叫做索引,但這并不是一種單獨的索引類型,而是一種數(shù)據(jù)存儲方式。對于聚簇索引存儲來說,行數(shù)據(jù)和主鍵 B+tree 存儲在一起,輔助鍵 B+tree 只存儲輔助鍵和主鍵,主鍵和非主鍵 B+tree 幾乎是兩種類型的樹。對于非聚簇索引存儲來說,主鍵 B+tree 在葉子節(jié)點存儲指向真正數(shù)據(jù)行的指針,而非主鍵。
聚簇索引
我們上邊介紹的B+tree本身就是一個目錄,或者說本身就是一個索引。它有兩個特點:
使用記錄主鍵值的大小進(jìn)行記錄和頁的排序,這包括三個方面的含義:
頁內(nèi)的記錄是按照主鍵的大小順序排成一個單向鏈表。
各個存放用戶記錄的頁也是根據(jù)頁中用戶記錄的主鍵大小順序排成一個雙向鏈表。
存放目錄項記錄的頁分為不同的層次,在同一層次中的頁也是根據(jù)頁中目錄項記錄的主鍵大小順序排成一個雙向鏈表。
B+tree的葉子節(jié)點存儲的是完整的用戶記錄。所謂完整的用戶記錄,就是指這個記錄中存儲了所有列的值(包括隱藏列)。
我們把具有這兩種特性的B+tree樹稱為聚簇索引,所有完整的用戶記錄都存放在這個聚簇索引的葉子節(jié)點處。這種聚簇索引并不需要我們在MySQL語句中顯式的使用INDEX語句去創(chuàng)建,InnoDB存儲引擎會自動的為我們創(chuàng)建聚簇索引,由于實際的數(shù)據(jù)頁只能按照一棵 B+tree 進(jìn)行排序,因此每張表只能擁有一個聚集索引。另外有趣的一點是,在InnoDB存儲引擎中,聚簇索引就是數(shù)據(jù)的存儲方式(所有的用戶記錄都存儲在了葉子節(jié)點),也就是所謂的索引即數(shù)據(jù),數(shù)據(jù)即索引,所以 InnoDB 存儲引擎表也稱之為索引組織表。
在多數(shù)情況下,查詢優(yōu)化器傾向于采用聚集索引。因為聚集索引能夠在 B+tree 索引的葉子節(jié)點上直接找到數(shù)據(jù)。此外,由于定義了數(shù)據(jù)的邏輯順序,聚集索引能夠特別快地訪問針對范圍值的查詢。查詢優(yōu)化器能夠快速發(fā)現(xiàn)某一段范圍的數(shù)據(jù)頁需要掃描。
輔助索引
上邊介紹的聚簇索引只能在搜索條件是主鍵值時才能發(fā)揮作用,因為B+tree中的數(shù)據(jù)都是按照主鍵進(jìn)行排序的。那如果我們想以別的列作為搜索條件該咋辦呢?難道只能從頭到尾沿著鏈表依次遍歷記錄么?
不,我們可以多建幾棵B+tree,不同的B+tree中的數(shù)據(jù)采用不同的排序規(guī)則。比方說我們用c2列的大小作為數(shù)據(jù)頁、頁中記錄的排序規(guī)則,再建一棵B+tree,效果如下圖所示:
這個B+tree與上邊介紹的聚簇索引有幾處不同:
使用記錄c2列的大小進(jìn)行記錄和頁的排序,這包括三個方面的含義:
頁內(nèi)的記錄是按照c2列的大小順序排成一個單向鏈表。
各個存放用戶記錄的頁也是根據(jù)頁中記錄的c2列大小順序排成一個雙向鏈表。
存放目錄項記錄的頁分為不同的層次,在同一層次中的頁也是根據(jù)頁中目錄項記錄的c2列大小順序排成一個雙向鏈表。
B+tree的葉子節(jié)點存儲的并不是完整的用戶記錄,而只是c2列+主鍵這兩個列的值。
目錄項記錄中不再是主鍵+頁號的搭配,而變成了c2列+頁號的搭配。
所以如果我們現(xiàn)在想通過c2列的值查找某些記錄的話就可以使用我們剛剛建好的這個B+tree了。以查找c2列的值為4的記錄為例,查找過程如下:
確定目錄項記錄頁根據(jù)根頁面,也就是頁44,可以快速定位到目錄項記錄所在的頁為頁42(因為2 < 4 < 9)。
通過目錄項記錄頁確定用戶記錄真實所在的頁。在頁42中可以快速定位到實際存儲用戶記錄的頁,但是由于c2列并沒有唯一性約束,所以c2列值為4的記錄可能分布在多個數(shù)據(jù)頁中,又因為2 < 4 ≤ 4,所以確定實際存儲用戶記錄的頁在頁34和頁35中。
在真實存儲用戶記錄的頁中定位到具體的記錄。到頁34和頁35中定位到具體的記錄。
但是這個B+tree的葉子節(jié)點中的記錄只存儲了c2和c1(也就是主鍵)兩個列,所以我們必須再根據(jù)主鍵值去聚簇索引中再查找一遍完整的用戶記錄。
各位各位,看到步驟4的操作了么?我們根據(jù)這個以c2列大小排序的B+tree只能確定我們要查找記錄的主鍵值,所以如果我們想根據(jù)c2列的值查找到完整的用戶記錄的話,仍然需要到聚簇索引中再查一遍,這個過程也被稱為回表。也就是根據(jù)c2列的值查詢一條完整的用戶記錄需要使用到2棵B+tree!!!
為什么我們還需要一次回表操作呢?直接把完整的用戶記錄放到葉子節(jié)點不就好了么?你說的對,如果把完整的用戶記錄放到葉子節(jié)點是可以不用回表,但是太占地方了呀~相當(dāng)于每建立一棵B+tree都需要把所有的用戶記錄再都拷貝一遍,這就有點太浪費(fèi)存儲空間了。因為這種按照非主鍵列建立的B+tree需要一次回表操作才可以定位到完整的用戶記錄,所以這種B+tree也被稱為二級索引(英文名secondary index),或者輔助索引。由于我們使用的是c2列的大小作為B+tree的排序規(guī)則,所以我們也稱這個B+tree為為c2列建立的索引。
所以相對而言,輔助索引的占用空間都會比聚簇索引小很多,特別是在一個表的列數(shù)很多或是這些列中包含大字段的情況下,因為我們一般都不會在大字段上直接建立索引。因為每個二級索引與聚簇索引的總行數(shù)是一樣的,并且一對一。那這樣比較下來,在我們統(tǒng)計一個表總的精確行數(shù)時(COUNT *),一些優(yōu)化器就會選擇表中最小的索引來作為統(tǒng)計的目標(biāo)索引,因為它占用空間最小,IO 也會最小,性能相應(yīng)的更快一些。
回表操作在數(shù)據(jù)量大的情況下開銷還是很大。比如,范圍查詢走的是二級索引,那么開銷是多大呢?如下 SQL 語句,date 是二級索引,然后我們根據(jù)二級索引查詢 1000 條記錄。
select * from table where date >= 1990 limit 1000
1
select*fromtablewheredate>=1990limit1000
這里假設(shè)二級索引和主鍵樹的高度都是 3,假設(shè)每個頁子節(jié)點能存儲 200 條鍵值對,這里的開銷就變成了3 + 4 + 3000次 IO 開銷了,其中 3 是定位到開始值的數(shù)據(jù)頁,4 是掃描二級索引剩下 800 條數(shù)據(jù)需要掃描的頁的 4 個數(shù)據(jù)頁,3000 = 3*1000 表示回表到主鍵 1000 次產(chǎn)生的 IO。
由于索引 date 對應(yīng)的 B+tree 中的記錄會按照 date 列的值進(jìn)行排序,所以值>=1990之間的記錄在磁盤中的存儲是相連的,集中分布在一個或幾個數(shù)據(jù)頁中,我們可以很快的把這些連著的記錄從磁盤中讀出來,這種讀取方式我們也可以稱為順序 I/O。根據(jù)前面獲取到的記錄的主鍵字段的值可能并不相連,而在聚簇索引中記錄是根據(jù)主鍵的順序排列的,所以根據(jù)這些并不連續(xù)的主鍵值到聚簇索引中訪問完整的用戶記錄可能分布在不同的數(shù)據(jù)頁中,這樣讀取完整的用戶記錄可能要訪問更多的數(shù)據(jù)頁,這種讀取方式我們也可以稱為隨機(jī) I/O。一般情況下,順序 I/O 比隨機(jī) I/O 的性能高很多。所以在這種情況下,回表產(chǎn)生的 IO 開銷就太大了,InnoDB 很多時候都選擇了直接掃描主鍵,也就是全表掃描,其代價可能比回表開銷更小,比如在表數(shù)據(jù)頁小于回表所產(chǎn)生的 IO 次數(shù)時,因為全表掃描每個頁也就一次 IO 而已。這在 MySQL 5.6 之前都是這么工作的,但在 MySQL 5.6 有了 MRR 技術(shù)后,這種情況就改善了很多,大概原理就是在回表之前通過對主鍵進(jìn)行排序,把隨機(jī) I/O 轉(zhuǎn)換為順序 I/O,然后順序掃描主鍵。
如何避免回表呢?如果你所查詢的列本身就在二級索引中,是不是直接可以展示給你,這種形式被稱之為覆蓋索引,不需要回表。所以可以試著將出現(xiàn)頻率非常高的語句中所有使用到的列以合適的順序建一個聯(lián)合二級索引,這樣所有需要的列都被這個二級索引覆蓋了,就不需要回表了,從而一定程度上提高了性能。這雖然是一個好的做法,但需要去權(quán)衡,因為需要考慮語句中涉及到的列數(shù),這個語句出現(xiàn)的頻率及最終這個索引的大小。最壞的情況是建一個和聚簇索引差不多大的二級索引,這樣一方面是占用空間比較大,另一方面是維護(hù)這個二級索引對這個表的整體修改性能也是有影響的,所以各方面都需要去權(quán)衡,然后再決定是不是要這樣做。
聯(lián)合索引
我們也可以同時以多個列的大小作為排序規(guī)則,也就是同時為多個列建立索引,比方說我們想讓B+樹按照c2和c3列的大小進(jìn)行排序,這個包含兩層含義:
先把各個記錄和頁按照c2列進(jìn)行排序。
在記錄的c2列相同的情況下,采用c3列進(jìn)行排序
為c2和c3列建立的索引的示意圖如下:
如圖所示,我們需要注意一下幾點:
每條目錄項記錄都由c2、c3、頁號這三個部分組成,各條記錄先按照c2列的值進(jìn)行排序,如果記錄的c2列相同,則按照c3列的值進(jìn)行排序。
B+樹葉子節(jié)點處的用戶記錄由c2、c3和主鍵c1列組成。
千萬要注意一點,以c2和c3列的大小為排序規(guī)則建立的B+tree稱為聯(lián)合索引,本質(zhì)上也是一個二級索引。它的意思與分別為c2和c3列分別建立索引的表述是不同的,不同點如下:
建立聯(lián)合索引只會建立如上圖一樣的1棵B+tree。
為c2和c3列分別建立索引會分別以c2和c3列的大小為排序規(guī)則建立2棵B+tree。
輔助索引的指針
現(xiàn)在已經(jīng)知道,聚簇索引存儲了所有數(shù)據(jù),二級索引只存儲了部分?jǐn)?shù)據(jù),但二級索引是為了提高性能的,所以經(jīng)常會被使用到,那如果二級索引中的數(shù)據(jù)不能滿足需求怎么辦?這就用到了我們上面提到的“回表”,也就是二級索引中每行記錄中指針的作用。
關(guān)于聚簇索引及二級索引列之間的邏輯關(guān)系,我們分類如下:
自定義主鍵的聚簇索引
索引結(jié)構(gòu):[主鍵列][TRXID][ROLLPTR][其它建表創(chuàng)建的非主鍵列]
參與記錄比較的列:主鍵列
內(nèi)結(jié)點KEY列:[主鍵列]+PageNo指針
未定義主鍵的聚簇索引
索引結(jié)構(gòu):[ROWID][TRXID][ROLLPTR][其它建表創(chuàng)建的非主鍵列]
參與記錄比較的列:只ROWID一列而已
內(nèi)結(jié)點KEY列:[ROWID]+PageNo指針
自定義主鍵的二級唯一索引
索引結(jié)構(gòu):[唯一索引列][主鍵列]
參與記錄比較的列:[唯一索引列][主鍵列]
內(nèi)結(jié)點KEY列:[唯一索引列]+PageNo指針
自定義主鍵的二級非唯一索引
索引結(jié)構(gòu):[非唯一索引列][主鍵列]
參與記錄比較的列:[非唯一索引列][主鍵列]
內(nèi)結(jié)點KEY列:[非唯一索引列][主鍵列]+PageNo指針
未定義主鍵的二級唯一索引
索引結(jié)構(gòu):[唯一索引列][ROWID]
參與記錄比較的列:[唯一索引列][ROWID]
內(nèi)結(jié)點KEY列:[唯一索引列]+PageNo指針
未定義主鍵的二級非唯一索引
索引結(jié)構(gòu):[非唯一索引列][ROWID]
參與記錄比較的列:[非唯一索引列][ROWID]
內(nèi)結(jié)點KEY列:[非唯一索引列][ROWID]+PageNo指針
通過這六種情況,講清楚了聚簇索引記錄包含的列,二級索引記錄包括的列,以及在非葉子節(jié)點中分別包含的列,因為索引是用來檢索數(shù)據(jù)的,所以還講述了用來檢查記錄時,在二級索引及聚簇索引中,參與比較記錄大小的列分別是什么,唯一索引與非唯一索引的區(qū)別等。
需要注意的一點是,上面講述的索引列的順序關(guān)系,與實際索引中記錄的物理存儲不是一回事,記錄的存儲格式是記錄的格式,而這個是索引在內(nèi)存中是元組的組織關(guān)系,這個元組的順序體現(xiàn)的就是每個索引自己的邏輯順序,以什么列建的索引,什么列就會在最前面起到優(yōu)先排序的作用。
我們這里特別關(guān)注一下二級唯一索引的元組邏輯順序,二級唯一索引中,作為索引本身的索引列,就是我們上面所說的“鍵”,當(dāng)這個元組需要回表時,在元組中存儲的聚簇索引列信息,就是我們所說的“值”,這樣就形成了鍵值對。而對于二級非唯一索引而言,因為只有索引列本身再加上主鍵列才能保證索引記錄是唯一的,所以這二者合起來才能構(gòu)成我們所說的“鍵”,而“值”就為空了,也就是說,二級非唯一索引中,在記錄構(gòu)成方面,非葉結(jié)節(jié)點只是比葉子節(jié)點多了一個 PageNo 指針信息。
從上面可以看到,二級索引元組中,首先存儲的就是每個索引定義的索引列,接著就是這條記錄對應(yīng)的聚簇索引的主鍵列的值,而主鍵列是唯一的,所以二級索引回表時對應(yīng)的記錄也是唯一的,這樣就形成了一種指針的效果。
不過有一點需要注意一下,二級索引回表時對應(yīng)的聚簇索引,如果是用戶自定義的,有可能是自增列,也有可能是有邏輯意義的單列或者組合列的聚簇索引,如果用戶沒有自定義,則 InnoDB 會自動給聚簇索引分配一個主鍵列,不過是隱藏的列,即我們所熟知的 Rowid 列。基于此,如果是用戶自定義的聚簇索引,則二級索引指針指向的就是聚簇索引所包含的列,如果沒有自定義主鍵,那該指針就指向Rowid列了。另外我們還可以看到聚簇索引(不管是自定義還是默認(rèn)的 ROWID)都會包含 TRXID 和 ROLLPTR 兩個默認(rèn)隱藏列,一個是事務(wù) ID,一個是回滾指針,這也就是 MVCC 和事務(wù)實現(xiàn)的關(guān)鍵點。
InnoDB 的 B+tree 索引注意事項
根頁面
我們前邊介紹B+tree索引的時候,為了大家理解上的方便,先把存儲用戶記錄的葉子節(jié)點都畫出來,然后接著畫存儲目錄項記錄的內(nèi)節(jié)點,實際上B+tree的形成過程是這樣的:
每當(dāng)為某個表創(chuàng)建一個B+tree索引(聚簇索引不是人為創(chuàng)建的,默認(rèn)就有)的時候,都會為這個索引創(chuàng)建一個根節(jié)點頁面。最開始表中沒有數(shù)據(jù)的時候,每個B+tree索引對應(yīng)的根節(jié)點中既沒有用戶記錄,也沒有目錄項記錄。
隨后向表中插入用戶記錄時,先把用戶記錄存儲到這個根節(jié)點中。
當(dāng)根節(jié)點中的可用空間用完時繼續(xù)插入記錄,此時會將根節(jié)點中的所有記錄復(fù)制到一個新分配的頁,比如頁a中,然后對這個新頁進(jìn)行頁分裂的操作,得到另一個新頁,比如頁b。這時新插入的記錄根據(jù)鍵值(也就是聚簇索引中的主鍵值,二級索引中對應(yīng)的索引列的值)的大小就會被分配到頁a或者頁b中,而根節(jié)點便升級為存儲目錄項記錄的頁。
這個過程需要大家特別注意的是:一個 B+tree 索引的根節(jié)點自誕生之日起,便不會再移動。這樣只要我們對某個表建立一個索引,那么它的根節(jié)點的頁號便會被記錄到某個地方,然后凡是InnoDB存儲引擎需要用到這個索引的時候,都會從那個固定的地方取出根節(jié)點的頁號,從而來訪問這個索引。
Note
這個存儲某個索引的根節(jié)點在哪個頁面中的信息就是傳說中的數(shù)據(jù)字典中的一項信息,關(guān)于更多數(shù)據(jù)字典的內(nèi)容,可以看相關(guān)信息。
內(nèi)節(jié)點中目錄項記錄的唯一性
我們知道B+tree索引的內(nèi)節(jié)點中目錄項記錄的內(nèi)容是索引列 + 頁號的搭配,但是這個搭配對于二級索引來說有點兒不嚴(yán)謹(jǐn)。還拿index_demo表為例,假設(shè)這個表中的數(shù)據(jù)是這樣的:
c1
c2
c3
1
1
‘u’
3
1
‘d’
5
1
‘y’
7
1
‘a(chǎn)’
如果二級索引中目錄項記錄的內(nèi)容只是索引列 + 頁號的搭配的話,那么為c2列建立索引后的B+tree應(yīng)該長這樣:
如果我們想新插入一行記錄,其中c1、c2、c3的值分別是:9、1、'c',那么在修改這個為c2列建立的二級索引對應(yīng)的B+tree時便碰到了個大問題:由于頁3中存儲的目錄項記錄是由c2列 + 頁號的值構(gòu)成的,頁3中的兩條目錄項記錄對應(yīng)的c2列的值都是1,而我們新插入的這條記錄的c2列的值也是1,那我們這條新插入的記錄到底應(yīng)該放到頁4中,還是應(yīng)該放到頁5中啊?答案是:對不起,懵逼了。
為了讓新插入記錄能找到自己在那個頁里,我們需要保證在 B+tree 的同一層內(nèi)節(jié)點的目錄項記錄除頁號這個字段以外是唯一的。所以對于二級索引的內(nèi)節(jié)點的目錄項記錄的內(nèi)容實際上是由三個部分構(gòu)成的:
索引列的值
主鍵值
頁號
也就是我們把主鍵值也添加到二級索引內(nèi)節(jié)點中的目錄項記錄了,這樣就能保證B+tree每一層節(jié)點中各條目錄項記錄除頁號這個字段外是唯一的,所以我們?yōu)閏2列建立二級索引后的示意圖實際上應(yīng)該是這樣子的:
這樣我們再插入記錄(9, 1, 'c')時,由于頁3中存儲的目錄項記錄是由c2列 + 主鍵 + 頁號的值構(gòu)成的,可以先把新記錄的c2列的值和頁3中各目錄項記錄的c2列的值作比較,如果c2列的值相同的話,可以接著比較主鍵值,因為B+tree同一層中不同目錄項記錄的c2列 + 主鍵的值肯定是不一樣的,所以最后肯定能定位唯一的一條目錄項記錄,在本例中最后確定新記錄應(yīng)該被插入到頁5中。
一個頁面最少存儲 2 條記錄
我們前邊說過一個 B+tree 只需要很少的層級就可以輕松存儲數(shù)億條記錄,查詢速度杠杠的!這是因為 B+tree 本質(zhì)上就是一個大的多層級目錄,每經(jīng)過一個目錄時都會過濾掉許多無效的子目錄,直到最后訪問到存儲真實數(shù)據(jù)的目錄。那如果一個大的目錄中只存放一個子目錄是個啥效果呢?那就是目錄層級非常非常非常多,而且最后的那個存放真實數(shù)據(jù)的目錄中只能存放一條記錄。費(fèi)了半天勁只能存放一條真實的用戶記錄?逗我呢?所以InnoDB的一個數(shù)據(jù)頁至少要存放兩條記錄,這也是我們之前嘮叨記錄行格式的時候說過一個結(jié)論(我們當(dāng)時依據(jù)這個結(jié)論推導(dǎo)了表中只有一個列時該列在不發(fā)生行溢出的情況下最多能存儲多少字節(jié),忘了的話回去看看吧)。
MySQL運(yùn)維內(nèi)參
如果您覺得本站對你有幫助,那么可以支付寶掃碼捐助以幫助本站更好地發(fā)展,在此謝過。
總結(jié)
以上是生活随笔為你收集整理的mysql 辅助索引_MySQL InnoDB B+tree索引的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: html一条横线在文本旁边_lt;del
- 下一篇: linux cmake编译源码,linu