數(shù)據(jù)庫中的存儲結(jié)構(gòu)是怎樣的
記錄是按照行來存儲的,但是數(shù)據(jù)庫的讀取并不以行為單位,否則一次讀取(也就是一次 I/O 操作)只能處理一行數(shù)據(jù),效率會非常低。因此在數(shù)據(jù)庫中,不論讀一行,還是讀多行,都是將這些行所在的頁進(jìn)行加載。也就是說,數(shù)據(jù)庫管理存儲空間的基本單位是頁(Page)。
一個頁中可以存儲多個行記錄(Row),同時在數(shù)據(jù)庫中,還存在著區(qū)(Extent)、段(Segment)和表空間(Tablespace)。行、頁、區(qū)、段、表空間的關(guān)系如下圖所示:
從圖中你能看到一個表空間包括了一個或多個段,一個段包括了一個或多個區(qū),一個區(qū)包括了多個頁,而一個頁中可以有多行記錄,這些概念我簡單給你講解下。
區(qū)(Extent)是比頁大一級的存儲結(jié)構(gòu),在 InnoDB 存儲引擎中,一個區(qū)會分配 64 個連續(xù)的頁。因為 InnoDB 中的頁大小默認(rèn)是 16KB,所以一個區(qū)的大小是 64*16KB=1MB。
段(Segment)由一個或多個區(qū)組成,區(qū)在文件系統(tǒng)是一個連續(xù)分配的空間(在 InnoDB 中是連續(xù)的 64 個頁),不過在段中不要求區(qū)與區(qū)之間是相鄰的。段是數(shù)據(jù)庫中的分配單位,不同類型的數(shù)據(jù)庫對象以不同的段形式存在。當(dāng)我們創(chuàng)建數(shù)據(jù)表、索引的時候,就會相應(yīng)創(chuàng)建對應(yīng)的段,比如創(chuàng)建一張表時會創(chuàng)建一個表段,創(chuàng)建一個索引時會創(chuàng)建一個索引段。
表空間(Tablespace)是一個邏輯容器,表空間存儲的對象是段,在一個表空間中可以有一個或多個段,但是一個段只能屬于一個表空間。數(shù)據(jù)庫由一個或多個表空間組成,表空間從管理上可以劃分為系統(tǒng)表空間、用戶表空間、撤銷表空間、臨時表空間等。
在 InnoDB 中存在兩種表空間的類型:共享表空間和獨(dú)立表空間。如果是共享表空間就意味著多張表共用一個表空間。如果是獨(dú)立表空間,就意味著每張表有一個獨(dú)立的表空間,也就是數(shù)據(jù)和索引信息都會保存在自己的表空間中。獨(dú)立的表空間可以在不同的數(shù)據(jù)庫之間進(jìn)行遷移。
你可以通過下面的命令來查看 InnoDB 的表空間類型:
mysql > show variables like 'innodb_file_per_table';
你能看到 innodb_file_per_table=ON,這就意味著每張表都會單獨(dú)保存為一個.ibd 文件。
數(shù)據(jù)頁內(nèi)的結(jié)構(gòu)是怎樣的
頁(Page)如果按類型劃分的話,常見的有數(shù)據(jù)頁(保存 B+ 樹節(jié)點(diǎn))、系統(tǒng)頁、Undo 頁和事務(wù)數(shù)據(jù)頁等。數(shù)據(jù)頁是我們最常使用的頁。
表頁的大小限定了表行的最大長度,不同 DBMS 的表頁大小不同。比如在 MySQL 的 InnoDB 存儲引擎中,默認(rèn)頁的大小是 16KB,我們可以通過下面的命令來進(jìn)行查看:
mysql> show variables like '%innodb_page_size%';
在 SQL Server 的頁大小為 8KB,而在 Oracle 中我們用術(shù)語“塊”(Block)來代表“頁”,Oralce 支持的塊大小為 2KB,4KB,8KB,16KB,32KB 和 64KB。
數(shù)據(jù)庫 I/O 操作的最小單位是頁,與數(shù)據(jù)庫相關(guān)的內(nèi)容都會存儲在頁結(jié)構(gòu)里。數(shù)據(jù)頁包括七個部分,分別是文件頭(File Header)、頁頭(Page Header)、最大最小記錄(Infimum+supremum)、用戶記錄(User Records)、空閑空間(Free Space)、頁目錄(Page Directory)和文件尾(File Tailer)。
頁結(jié)構(gòu)的示意圖如下所示:
這 7 個部分到底有什么作用呢?我簡單梳理下:
實際上,我們可以把這 7 個數(shù)據(jù)頁分成 3 個部分。
首先是文件通用部分,也就是文件頭和文件尾。它們類似集裝箱,將頁的內(nèi)容進(jìn)行封裝,通過文件頭和文件尾校驗的方式來確保頁的傳輸是完整的。
在文件頭中有兩個字段,分別是 FIL_PAGE_PREV 和 FIL_PAGE_NEXT,它們的作用相當(dāng)于指針,分別指向上一個數(shù)據(jù)頁和下一個數(shù)據(jù)頁。連接起來的頁相當(dāng)于一個雙向的鏈表,如下圖所示:
需要說明的是采用鏈表的結(jié)構(gòu)讓數(shù)據(jù)頁之間不需要是物理上的連續(xù),而是邏輯上的連續(xù)。
我們之前講到過 Hash 算法,這里文件尾的校驗方式就是采用 Hash 算法進(jìn)行校驗。舉個例子,當(dāng)我們進(jìn)行頁傳輸?shù)臅r候,如果突然斷電了,造成了該頁傳輸?shù)牟煌暾@時通過文件尾的校驗和(checksum 值)與文件頭的校驗和做比對,如果兩個值不相等則證明頁的傳輸有問題,需要重新進(jìn)行傳輸,否則認(rèn)為頁的傳輸已經(jīng)完成。
第二個部分是記錄部分,頁的主要作用是存儲記錄,所以“最小和最大記錄”和“用戶記錄”部分占了頁結(jié)構(gòu)的主要空間。另外空閑空間是個靈活的部分,當(dāng)有新的記錄插入時,會從空閑空間中進(jìn)行分配用于存儲新記錄,如下圖所示:
第三部分是索引部分,這部分重點(diǎn)指的是頁目錄,它起到了記錄的索引作用,因為在頁中,記錄是以單向鏈表的形式進(jìn)行存儲的。單向鏈表的特點(diǎn)就是插入、刪除非常方便,但是檢索效率不高,最差的情況下需要遍歷鏈表上的所有節(jié)點(diǎn)才能完成檢索,因此在頁目錄中提供了二分查找的方式,用來提高記錄的檢索效率。這個過程就好比是給記錄創(chuàng)建了一個目錄:
- 將所有的記錄分成幾個組,這些記錄包括最小記錄和最大記錄,但不包括標(biāo)記為“已刪除”的記錄。
- 第 1 組,也就是最小記錄所在的分組只有 1 個記錄;最后一組,就是最大記錄所在的分組,會有 1-8 條記錄;其余的組記錄數(shù)量在 4-8 條之間。這樣做的好處是,除了第 1 組(最小記錄所在組)以外,其余組的記錄數(shù)會盡量平分。
- 在每個組中最后一條記錄的頭信息中會存儲該組一共有多少條記錄,作為 n_owned 字段。
- 頁目錄用來存儲每組最后一條記錄的地址偏移量,這些地址偏移量會按照先后順序存儲起來,每組的地址偏移量也被稱之為槽(slot),每個槽相當(dāng)于指針指向了不同組的最后一個記錄。如下圖所示:
頁目錄存儲的是槽,槽相當(dāng)于分組記錄的索引。我們通過槽查找記錄,實際上就是在做二分查找。這里我以上面的圖示進(jìn)行舉例,5 個槽的編號分別為 0,1,2,3,4,我想查找主鍵為 9 的用戶記錄,我們初始化查找的槽的下限編號,設(shè)置為 low=0,然后設(shè)置查找的槽的上限編號 high=4,然后采用二分查找法進(jìn)行查找。
首先找到槽的中間位置 p=(low+high)/2=(0+4)/2=2,這時我們?nèi)【幪枮?2 的槽對應(yīng)的分組記錄中最大的記錄,取出關(guān)鍵字為 8。因為 9 大于 8,所以應(yīng)該會在槽編號為 (p,high] 的范圍進(jìn)行查找
接著重新計算中間位置 p’=(p+high)/2=(2+4)/2=3,我們查找編號為 3 的槽對應(yīng)的分組記錄中最大的記錄,取出關(guān)鍵字為 12。因為 9 小于 12,所以應(yīng)該在槽 3 中進(jìn)行查找。
遍歷槽 3 中的所有記錄,找到關(guān)鍵字為 9 的記錄,取出該條記錄的信息即為我們想要查找的內(nèi)容。
從數(shù)據(jù)頁的角度看 B+ 樹是如何進(jìn)行查詢的
MySQL 的 InnoDB 存儲引擎采用 B+ 樹作為索引,而索引又可以分成聚集索引和非聚集索引(二級索引),這些索引都相當(dāng)于一棵 B+ 樹,如圖所示。一棵 B+ 樹按照節(jié)點(diǎn)類型可以分成兩部分:
- 葉子節(jié)點(diǎn),B+ 樹最底層的節(jié)點(diǎn),節(jié)點(diǎn)的高度為 0,存儲行記錄。
- 非葉子節(jié)點(diǎn),節(jié)點(diǎn)的高度大于 0,存儲索引鍵和頁面指針,并不存儲行記錄本身。
我們剛才學(xué)習(xí)了頁結(jié)構(gòu)的內(nèi)容,你可以用頁結(jié)構(gòu)對比,看下 B+ 樹的結(jié)構(gòu)。
在一棵 B+ 樹中,每個節(jié)點(diǎn)都是一個頁,每次新建節(jié)點(diǎn)的時候,就會申請一個頁空間。同一層上的節(jié)點(diǎn)之間,通過頁的結(jié)構(gòu)構(gòu)成一個雙向的鏈表(頁文件頭中的兩個指針字段)。非葉子節(jié)點(diǎn),包括了多個索引行,每個索引行里存儲索引鍵和指向下一層頁面的頁面指針。最后是葉子節(jié)點(diǎn),它存儲了關(guān)鍵字和行記錄,在節(jié)點(diǎn)內(nèi)部(也就是頁結(jié)構(gòu)的內(nèi)部)記錄之間是一個單向的鏈表,但是對記錄進(jìn)行查找,則可以通過頁目錄采用二分查找的方式來進(jìn)行。
當(dāng)我們從頁結(jié)構(gòu)來理解 B+ 樹的結(jié)構(gòu)的時候,可以幫我們理解一些通過索引進(jìn)行檢索的原理:
1.B+ 樹是如何進(jìn)行記錄檢索的?
如果通過 B+ 樹的索引查詢行記錄,首先是從 B+ 樹的根開始,逐層檢索,直到找到葉子節(jié)點(diǎn),也就是找到對應(yīng)的數(shù)據(jù)頁為止,將數(shù)據(jù)頁加載到內(nèi)存中,頁目錄中的槽(slot)采用二分查找的方式先找到一個粗略的記錄分組,然后再在分組中通過鏈表遍歷的方式查找記錄。
2. 普通索引和唯一索引在查詢效率上有什么不同?
我們創(chuàng)建索引的時候可以是普通索引,也可以是唯一索引,那么這兩個索引在查詢效率上有什么不同呢?
唯一索引就是在普通索引上增加了約束性,也就是關(guān)鍵字唯一,找到了關(guān)鍵字就停止檢索。而普通索引,可能會存在用戶記錄中的關(guān)鍵字相同的情況,根據(jù)頁結(jié)構(gòu)的原理,當(dāng)我們讀取一條記錄的時候,不是單獨(dú)將這條記錄從磁盤中讀出去,而是將這個記錄所在的頁加載到內(nèi)存中進(jìn)行讀取。InnoDB 存儲引擎的頁大小為 16KB,在一個頁中可能存儲著上千個記錄,因此在普通索引的字段上進(jìn)行查找也就是在內(nèi)存中多幾次“判斷下一條記錄”的操作,對于 CPU 來說,這些操作所消耗的時間是可以忽略不計的。所以對一個索引字段進(jìn)行檢索,采用普通索引還是唯一索引在檢索效率上基本上沒有差別。