聚簇索引并不是一種單獨(dú)的索引類型,而是一種數(shù)據(jù)存儲方式。比如,InnoDB的聚簇索引使用B+Tree的數(shù)據(jù)結(jié)構(gòu)存儲索引和數(shù)據(jù)。聚簇索引與非聚簇對比如下圖。
當(dāng)表有聚簇索引時(shí),它的數(shù)據(jù)行實(shí)際上存放在索引的葉子頁(leaf page)中。因?yàn)闊o法同時(shí)把數(shù)據(jù)行存放在兩個(gè)不同的地方,所以一個(gè)表只能有一個(gè)聚簇索引(不過,覆蓋索引可以模擬多個(gè)聚簇索引的情況)。
- 術(shù)語“聚簇”表示數(shù)據(jù)行和相鄰的鍵值緊湊地存儲在一起。
- 聚簇索引的二級索引:葉子節(jié)點(diǎn)不會保存引用的行的物理位置,而是保存行的主鍵值。
對于聚簇索引的存儲引擎,數(shù)據(jù)的物理存放順序與索引順序是一致的,即:只要索引是相鄰的,那么對應(yīng)的數(shù)據(jù)一定也是相鄰地存放在磁盤上的,如果主鍵不是自增id,可以想象,它會干些什么,不斷地調(diào)整數(shù)據(jù)的物理地址、分頁,當(dāng)然也有其他一些措施來減少這些操作,但卻無法徹底避免。但,如果是自增的,那就簡單了,它只需要一頁一頁地寫,索引結(jié)構(gòu)相對緊湊,磁盤碎片少,效率也高。
對于非聚簇索引的存儲引擎,表數(shù)據(jù)存儲順序與索引順序無關(guān),葉結(jié)點(diǎn)包含索引字段值及指向數(shù)據(jù)頁數(shù)據(jù)行的邏輯指針,其行數(shù)量與數(shù)據(jù)表行數(shù)據(jù)量一致。
下圖1展示了聚簇索引的記錄是如何存放的。注意到,節(jié)點(diǎn)頁只包含了索引列,葉子頁包含行的全部數(shù)據(jù),這是B+Tree的數(shù)據(jù)結(jié)構(gòu)。在這個(gè)案例中,索引列包含的是整數(shù)值。
InnoDB將通過主鍵聚集數(shù)據(jù),圖1中的“被索引的列”就是主鍵列。如果沒有定義主鍵,InnoDB會選擇一個(gè)唯一的非空索引代替。如果沒有這樣的索引,InnoDB會隱式定義一個(gè)主鍵來作為聚簇索引。InnoDB只聚集在同一個(gè)頁面中的記錄,包含相鄰鍵值的頁面可能會相距甚遠(yuǎn)。
聚簇主鍵可能對性能有幫助,但也可能導(dǎo)致嚴(yán)重的性能問題。所以需要仔細(xì)地考慮聚簇索引,尤其是將表的存儲引擎從InnoDB改成其他引擎的時(shí)候(反過來也一樣)。
聚簇的數(shù)據(jù)有一些重要的優(yōu)點(diǎn):
- 可以把相關(guān)數(shù)據(jù)保存在一起。例如實(shí)現(xiàn)電子郵箱時(shí),可以根據(jù)用戶ID來聚集數(shù)據(jù),這樣只需要從磁盤讀取少數(shù)的數(shù)據(jù)頁就能獲取某個(gè)用戶的全部郵件。如果沒有聚簇索引,則每封郵件都可能多一次磁盤IO。
- 數(shù)據(jù)訪問更快。聚簇索引將索引和數(shù)據(jù)保存在同一個(gè)B+Tree中,因此從聚簇索引中獲取數(shù)據(jù)通常比在非聚簇索引中查找要快。
- 使用覆蓋索引掃描的查詢可以直接使用頁節(jié)點(diǎn)中的主鍵值。
如果設(shè)計(jì)表和查詢時(shí)能充分利用上面的優(yōu)點(diǎn),就能極大地提升性能。但是,聚簇索引也有一些缺點(diǎn):
- 聚簇?cái)?shù)據(jù)最大限度地提高了IO密集型應(yīng)用的性能,但如果數(shù)據(jù)全部放在內(nèi)存中,則訪問的順序就沒那么重要了,聚簇索引也就沒什么優(yōu)勢了。
- 插入速度嚴(yán)重依賴于插入順序。按照主要的順序插入是加載數(shù)據(jù)到InnoDB表中速度最快的方式。但如果不是按照主鍵順序加載數(shù)據(jù),那么在加載完成后最好使用optimize table命令重新組織一下表。
- 更新聚簇索引列的代價(jià)很高,因?yàn)闀?qiáng)制InnoDB將每個(gè)被更新的行移動到新的位置。
- 基于聚簇索引的表插入新行,或者主鍵被更新導(dǎo)致需要移動行的時(shí)候,可能面臨”頁分裂(page split)“的問題。當(dāng)行的主鍵值要求必須將這一行插入到某個(gè)已滿的頁中時(shí),存儲引擎會將該頁分裂成兩個(gè)頁面來容納該行,這就是一次分裂操作。頁分裂會導(dǎo)致表占用更多的磁盤空間。
- 聚簇索引可能導(dǎo)致全表掃描變慢,尤其是行比較稀疏,或者由于頁分裂導(dǎo)致數(shù)據(jù)存儲不連續(xù)的時(shí)候。
- 二級索引(非聚簇索引)可能比想象的要更大,因?yàn)樵诙o索引的葉子節(jié)點(diǎn)包含了引用行的主鍵列。
- 二級索引訪問需要兩次索引查找,而不是一次。
最后一點(diǎn)可能讓人有些疑惑,為什么二級索引需要兩次索引查找?答案在于二級索引中保存的”行指針“的實(shí)質(zhì)。要記住,二級索引葉子節(jié)點(diǎn)保存的不是指向行的物理位置的指針,而是行的主鍵值。
這意味著通過二級索引查找行,存儲引擎需要找到二級索引的葉子節(jié)點(diǎn)獲得對應(yīng)的主鍵值,然后根據(jù)這個(gè)值去聚簇索引中查找到對應(yīng)的行。這里做了重復(fù)的工作:兩次B-Tree查找而不是一次。對于 InnoDB,自適應(yīng)哈希索引能夠減少這樣的重復(fù)工作。
InnoDB和MyISAM的數(shù)據(jù)分布對比
聚簇索引和非聚簇索引的數(shù)據(jù)分布有區(qū)別,以及對應(yīng)的主要索引和二級索引的數(shù)據(jù)分布也有區(qū)別,通常會讓人感到困擾和意外。來看看InnoDB和MyISAM是如何存儲下面這個(gè)表的:
create table layout_test(
col1 int not null,
col2 int not null,
primary key(col1),
key(col2)
);
假設(shè)該表的主鍵取值為1~10000,按照隨機(jī)順序播放并使用optimize table命令做了優(yōu)化。換句話說,數(shù)據(jù)在磁盤上的存儲方式已經(jīng)最優(yōu),但行的順序是隨機(jī)的。列col2的值是從1~100之間隨機(jī)賦值,所以有很多重復(fù)的值。
MyISAM的數(shù)據(jù)布局
MyISAM的B+Tree的葉子節(jié)點(diǎn)上的data,并不是數(shù)據(jù)本身,而是數(shù)據(jù)存放的地址。MyISAM按照數(shù)據(jù)插入的順序存儲在磁盤上,如下圖2所示,左邊為行號(row number),從0開始。因?yàn)樵M的大小固定,所以MyISAM很容易的從表的開始位置找到某一字節(jié)的位置。
MyISAM建立的primary key的索引結(jié)構(gòu)大致如圖3和圖4所示。MyISAM不支持聚簇索引,索引中每一個(gè)葉子節(jié)點(diǎn)僅僅包含行號(row number),且葉子節(jié)點(diǎn)按照col1的順序存儲。MyISAM是按列值與行號來組織索引的。
在圖4中,表一共有三列,假設(shè)以Col1為主鍵,可以看出,MyISAM的葉子節(jié)點(diǎn)中保存的實(shí)際上是指向存放數(shù)據(jù)的物理塊的指針。從MYISAM存儲的物理文件看出,MyISAM引擎的索引文件(.MYI)和數(shù)據(jù)文件(.MYD)是相互獨(dú)立的,索引文件僅僅保存數(shù)據(jù)記錄的地址。
下圖5顯示col2 的索引結(jié)構(gòu),與圖3的primary key對比,索引中每一個(gè)葉子節(jié)點(diǎn)僅僅包含行號(row number),且葉子節(jié)點(diǎn)按照col2的順序存儲。在圖6中,在Col2建立一個(gè)輔助索引,與圖4對比,MyISAM的葉子節(jié)點(diǎn)也是保存指向存放數(shù)據(jù)的物理塊的指針。
所以,結(jié)論是MyISAM的primary key和輔助索引沒有任何區(qū)別。只是Primary key要求key唯一非空,而輔助索引的key可以重復(fù)。
因此,MyISAM中索引檢索的算法為首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,則取出其data域的值,然后以data域的值為地址,讀取相應(yīng)數(shù)據(jù)記錄。
InnoDB的數(shù)據(jù)布局
MyISAM索引文件和數(shù)據(jù)文件是分離的,索引文件僅保存數(shù)據(jù)記錄的地址。而在InnoDB中,表數(shù)據(jù)文件本身就是按B+Tree組織的一個(gè)索引結(jié)構(gòu),這棵樹的葉節(jié)點(diǎn)data域保存了完整的數(shù)據(jù)記錄。這個(gè)索引的key是數(shù)據(jù)表的主鍵,因此InnoDB表數(shù)據(jù)文件本身就是主索引。
圖7和與圖3 MyISAM對比看出,InnoDB索引的每一個(gè)葉子節(jié)點(diǎn)都包含了主鍵值、事務(wù)ID、用于事務(wù)和MVCC的回流指針以及所有的剩余列(在這個(gè)例子中是col2)。如果主鍵是一個(gè)列前綴索引,InnoDB也會包含完整的主鍵列和剩下的其他列。這種索引叫做聚簇索引。
圖8可以看到葉節(jié)點(diǎn)包含了完整的數(shù)據(jù)記錄。
因?yàn)镮nnoDB的數(shù)據(jù)文件本身要按主鍵聚集,所以InnoDB要求表必須有主鍵(MyISAM可以沒有),如果沒有顯式指定,則MySQL系統(tǒng)會自動選擇一個(gè)可以唯一標(biāo)識數(shù)據(jù)記錄的列作為主鍵,如果不存在這種列,則MySQL自動為InnoDB表生成一個(gè)隱含字段作為主鍵,這個(gè)字段長度為6個(gè)字節(jié),類型為長整形。
還有一點(diǎn)和MyISAM的不同是,InnoDB的二級索引和聚簇索引很不相同。InnoDB二級索引的葉子節(jié)點(diǎn)中存儲的不是”行指針“,而是主鍵值,并以此作為指向行的“指針”。這樣的策略減少了當(dāng)出現(xiàn)行移動或者數(shù)據(jù)頁分裂時(shí)二級索引的維護(hù)工作。使用主鍵值當(dāng)作指針會讓二級索引占用更多的空間,換來的好處是,InnoDB在移動行時(shí)無須更新二級索引中的這個(gè)“指針”。
下圖9展示了示例表的二級索引col2索引。每一個(gè)葉子節(jié)點(diǎn)都包含了索引列(這里是col2),緊接著是主鍵值(col1)。圖10展示了InnoDB的所有輔助索引都引用主鍵作為data域。
InnoDB 表是基于聚簇索引建立的。因此InnoDB 的索引能提供一種非常快速的主鍵查找性能。不過,它的輔助索引(Secondary Index, 也就是非主鍵索引)也會包含主鍵列,所以,如果主鍵定義的比較大,其他索引也將很大。如果想在表上定義 、很多索引,則爭取盡量把主鍵定義得小一些。InnoDB 不會壓縮索引。
InnoDB與MyIASM索引和數(shù)據(jù)布局對比
圖7描述InnoDB和MyISAM如何存放表的抽象圖。對比InnoDB和MyISAM的主鍵索引與二級索引。
InnoDB的的二級索引的葉子節(jié)點(diǎn)存放的是KEY字段加主鍵值。因此,通過二級索引查詢首先查到是主鍵值,然后InnoDB再根據(jù)查到的主鍵值通過主鍵索引找到相應(yīng)的數(shù)據(jù)塊。而MyISAM的二級索引葉子節(jié)點(diǎn)存放的還是列值與行號的組合,葉子節(jié)點(diǎn)中保存的是數(shù)據(jù)的物理地址。所以可以看出MYISAM的主鍵索引和二級索引沒有任何區(qū)別,主鍵索引僅僅只是一個(gè)叫做PRIMARY的唯一、非空的索引,且MYISAM引擎中可以不設(shè)主鍵。
為了更形象說明這兩種索引的區(qū)別,我們假想一個(gè)表如下圖8存儲了4行數(shù)據(jù)。其中id作為主索引,name作為輔助索引。圖示清晰的顯示了聚簇索引和非聚簇索引的差異。
對于聚簇索引存儲來說,行數(shù)據(jù)和主鍵B+樹存儲在一起,輔助鍵B+樹只存儲輔助鍵和主鍵,主鍵和非主鍵B+樹幾乎是兩種類型的樹。對于非聚簇索引存儲來說,主鍵B+樹在葉子節(jié)點(diǎn)存儲指向真正數(shù)據(jù)行的指針,而非主鍵。
InnoDB使用的是聚簇索引,將主鍵組織到一棵B+樹中,而行數(shù)據(jù)就儲存在葉子節(jié)點(diǎn)上,若使用"where id = 14"這樣的條件查找主鍵,則按照B+樹的檢索算法即可查找到對應(yīng)的葉節(jié)點(diǎn),之后獲得行數(shù)據(jù)。若對Name列進(jìn)行條件搜索,則需要兩個(gè)步驟:第一步在輔助索引B+樹中檢索Name,到達(dá)其葉子節(jié)點(diǎn)獲取對應(yīng)的主鍵。第二步使用主鍵在主索引B+樹種再執(zhí)行一次B+樹檢索操作,最終到達(dá)葉子節(jié)點(diǎn)即可獲取整行數(shù)據(jù)。
MyISM使用的是非聚簇索引,非聚簇索引的兩棵B+樹看上去沒什么不同,節(jié)點(diǎn)的結(jié)構(gòu)完全一致只是存儲的內(nèi)容不同而已,主鍵索引B+樹的節(jié)點(diǎn)存儲了主鍵,輔助鍵索引B+樹存儲了輔助鍵。表數(shù)據(jù)存儲在獨(dú)立的地方,這兩顆B+樹的葉子節(jié)點(diǎn)都使用一個(gè)地址指向真正的表數(shù)據(jù),對于表數(shù)據(jù)來說,這兩個(gè)鍵沒有任何差別。由于索引樹是獨(dú)立的,通過輔助鍵檢索無需訪問主鍵的索引樹。
我們重點(diǎn)關(guān)注聚簇索引,看上去聚簇索引的效率明顯要低于非聚簇索引,因?yàn)槊看问褂幂o助索引檢索都要經(jīng)過兩次B+樹查找,這不是多此一舉嗎?聚簇索引的優(yōu)勢在哪?
1 由于行數(shù)據(jù)和葉子節(jié)點(diǎn)存儲在一起,這樣主鍵和行數(shù)據(jù)是一起被載入內(nèi)存的,找到葉子節(jié)點(diǎn)就可以立刻將行數(shù)據(jù)返回了,如果按照主鍵Id來組織數(shù)據(jù),獲得數(shù)據(jù)更快。
2 輔助索引使用主鍵作為"指針" 而不是使用地址值作為指針的好處是,減少了當(dāng)出現(xiàn)行移動或者數(shù)據(jù)頁分裂時(shí)輔助索引的維護(hù)工作,使用主鍵值當(dāng)作指針會讓輔助索引占用更多的空間,換來的好處是InnoDB在移動行時(shí)無須更新輔助索引中的這個(gè)"指針"。也就是說行的位置(實(shí)現(xiàn)中通過16K的Page來定位,后面會涉及)會隨著數(shù)據(jù)庫里數(shù)據(jù)的修改而發(fā)生變化(前面的B+樹節(jié)點(diǎn)分裂以及Page的分裂),使用聚簇索引就可以保證不管這個(gè)主鍵B+樹的節(jié)點(diǎn)如何變化,輔助索引樹都不受影響。
在InnoDB表中按主鍵順序插入行
如果正在使用InnoDB表并且沒有什么數(shù)據(jù)需要聚集,那么可以定義一個(gè)代理鍵作為主鍵,這種主鍵的數(shù)據(jù)應(yīng)該和應(yīng)用無關(guān),最簡單的方法是使用auto_increment自增列。這樣可以保證數(shù)據(jù)行是按照順序?qū)懭耄瑢τ诟鶕?jù)主鍵做關(guān)聯(lián)操作的性能也會更好。
最好避免隨機(jī)的聚簇索引,特別對于I/O密集型的應(yīng)用。例如,從性能的角度考慮,使用UUID作為聚簇索引會很糟糕:它使得聚簇索引的插入變得完全隨機(jī),這是最壞的情況,使得數(shù)據(jù)沒有任何聚集特性。
為了演示這一點(diǎn),我們做如下兩個(gè)基準(zhǔn)測試。第一個(gè)使用整數(shù)ID插入shopinfo表,整數(shù)ID自增且為主鍵:
CREATE TABLE `shopinfo` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '記錄ID',
`shop_id` int(11) NOT NULL COMMENT '商店ID',
`goods_id` int(11) NOT NULL COMMENT '物品ID',
`pay_type` int(11) NOT NULL COMMENT '支付方式',
`price` decimal(10,2) NOT NULL COMMENT '物品價(jià)格',
`comment` varchar(4000) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `shop_id` (`shop_id`,`goods_id`),
KEY `price` (`price`),
KEY `pay_type` (`pay_type`),
KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商店物品表';
第二個(gè)例子是shopinfo_uuid表,除了主鍵改為UUID,其余和前面的shopinfo表完全相同。
CREATE TABLE `shopinfo_uuid` (
`uuid` varchar(36) NOT NULL,
`shop_id` int(11) NOT NULL COMMENT '商店ID',
`goods_id` int(11) NOT NULL COMMENT '物品ID',
`pay_type` int(11) NOT NULL COMMENT '支付方式',
`price` decimal(10,2) NOT NULL COMMENT '物品價(jià)格',
`comment` varchar(4000) DEFAULT NULL,
PRIMARY KEY (`uuid`),
UNIQUE KEY `shop_id` (`shop_id`,`goods_id`),
KEY `price` (`price`),
KEY `pay_type` (`pay_type`),
KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商店物品表';
我們先向這兩個(gè)表各插入1萬條記錄。然后再向這兩個(gè)表繼續(xù)插入9萬條記錄,觀察這兩個(gè)表的插入耗時(shí)和表索引大小,下表對測試結(jié)果進(jìn)行比較。其中,查看指定庫的指定表shopinfo的索引大小SQL語句:
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024), 2), ' MB') AS 'Total Index Size' FROM TABLES WHERE table_schema = 'study' and table_name = 'shopinfo';
表名 | 行數(shù) | 時(shí)間 | 索引大小(MB) |
---|---|---|---|
shopinfo | 10000 | 0.755s | 4.08 |
shopinfo_uuid | 10000 | 1.699s | 8.16 |
shopinfo | 90000 | 8.014s | 29.47 |
shopinfo_uuid | 90000 | 46.111s | 60.58 |
通過測試,插入同樣的行數(shù)和內(nèi)容(除主鍵內(nèi)容),向UUID主鍵插入行不僅花費(fèi)的時(shí)間更長,而且索引占用的空間也更大。這一方面是由于主鍵字段更長,另一方面毫無疑問是由于頁分裂和碎片導(dǎo)致的。
如圖9所示,由于主鍵的值是順序的,InnoDB把每一條記錄都存儲在上一條記錄的后面。當(dāng)達(dá)到頁的最大填充因子時(shí)(InnoDB默認(rèn)的最大填充因子是頁大小的15/16,留出的部分空間用于以后修改),下一條記錄就會寫入新的頁中。一旦數(shù)據(jù)按照這樣順序的方式加載,主鍵頁就會近似于被順序的記錄填滿,這也是所期望的結(jié)果。
而當(dāng)采用UUID的聚簇索引的表往插入數(shù)據(jù),如圖10所示,因?yàn)樾滦械闹麈I值不一定比之前的插入值大,所以InnoDB無法簡單的總是把新行插入到索引的最后,而是需要為新的行尋找合適的位置----通常是已有數(shù)據(jù)的中間位置----并且分配空間。這會增加很多額外的工作,并導(dǎo)致數(shù)據(jù)分布不夠優(yōu)化。
下面總結(jié)使用UUID作為主鍵的一些缺點(diǎn):
- 寫入目標(biāo)頁可能已經(jīng)刷到磁盤上并從緩存中移除,或者是還沒有被加載到緩存中,InnoDB在插入之前不得不先找到并從磁盤讀取目標(biāo)頁到內(nèi)存中,這將導(dǎo)致大量的隨機(jī)I/O;
- 因?yàn)閷懭胧莵y序的,InnoDB不得不頻繁的做頁分裂操作,以便為新的行分配空間。頁分裂會導(dǎo)致移動大量數(shù)據(jù),一次插入最少需要修改三個(gè)頁而不是一個(gè),包含兩個(gè)葉子節(jié)點(diǎn)和一個(gè)父節(jié)點(diǎn)。
- 由于頻繁的頁分裂,頁會變得稀疏并被不規(guī)則的填充,所以最終數(shù)據(jù)會有碎片。
把這些隨機(jī)值載入到聚簇索引以后,需要做一次optimize table來重建表并優(yōu)化頁的填充。
注意,順序主鍵也有缺點(diǎn):對于高并發(fā)工作負(fù)載,在InnoDB中按主鍵順序插入可能會造成明顯的爭用。主鍵的上界會成為“熱點(diǎn)”。因?yàn)樗械牟迦攵及l(fā)生在這里,所以并發(fā)插入可能導(dǎo)致間隙鎖競爭。另一個(gè)熱點(diǎn)可能是auto_increment鎖機(jī)制;如果遇到這個(gè)問題,則可能需要考慮重新設(shè)計(jì)表或者應(yīng)用,比如應(yīng)用層面生成單調(diào)遞增的主鍵ID,插表不使用auto_increment機(jī)制,或者更改innodb_autonc_lock_mode配置。