也許很多人都背過 MySQL 調優的口訣,但是從來不理解爲什麼這樣子寫出的 sql 語句,可以有更高的性能。
而要理解其中的原由,就必須對 MySQL 底層的做一定的瞭解。
同時,爲了進大廠,你也必須學會,才能去和麪試官噴。。
下面我給出幾道題目,你可以做一個自我檢測:
- 什麼叫 4K 對齊
- 如何存儲空值數據
- 如何存儲可變長數據
- 大 value 如何存儲
- 什麼是聚簇索引
- InnoDB 沒有定義主鍵會怎樣
- 爲什麼推薦自增 id
- 什麼是頁目錄
- 查詢起始頁是否會改變
- 什麼是覆蓋索引
- 爲什麼要符合最左前綴原則
熟悉 MySQL 的都知道 MySQL 有各種各樣的存儲引擎(InnoDB、MyISAM 等等)。
不過由於 InnoDB 支持事務,支持行鎖,這樣的特性十分適合我們生產環境中去使用,因而我們大部分情況下使用的都是 InnoDB 存儲引擎。
我下面也是對 InnoDB 做詳細地描述。
數據頁
我們都知道,我們一般要用到 MySQL 數據庫,一般都會將數據持久化到磁盤,而不是存儲在內存中。
因爲內存斷電易失,此外,就是容量有限。
比如我們要在計算機磁盤上存儲一段數據,那就會把它存在文件裏,比如叫 “我很帥.txt”;
那我我們要查找數據的時候,要怎麼去找,比如說找到裏面的 “風流倜儻、玉樹臨風……”;
Linux 當中有 grep,awk 等等等等的命令,你也可以用 java 語言寫一個程序,去查找。
不過這就涉及到一個概念,叫尋址,我們的磁盤尋址,一般是毫秒級別的;
然後,又涉及到帶寬,一般幾百兆,或者能達到一兩個G。
然後做個簡單的對比,對於內存,尋址則是納秒級別的。
我們都知道 納秒 <微秒 < 毫秒,1 毫秒 = 10^6 納秒。
而且內存的帶寬也要高於硬盤。
所以到目前爲止,我們計算機的 I/O 都是瓶頸。
所以對於我們的硬盤來說,一次讀取數據,絕對不會是一個字節一個字節去讀的,因爲磁盤的 I/O 是一個極慢的過程。
所以有這麼一個概念,叫 4K 對齊,我們格式化磁盤的時候,也會看到有個東西叫 4K 對齊,那什麼叫 4K 對齊?
就是我們讀磁盤當中的數據的時候,假設我們就讀一小點數據,幾個字節;
但是真正從磁盤當中讀出的數據,至少要有 4K,不管你用不用得到,都會存儲到內存空間中(所以讀出的數據永遠是 4K 的倍數)。
假設你要讀邊上的數據了,那麼就不用再去磁盤中去找,就可以節省時間。
這就是因爲 I/O 這一瓶頸而不得不這麼做。
而在我們的 MySQL 數據庫中,對於我們的 InnoDB 存儲引擎,存儲的基本單位就是數據頁;
存儲的時候,默認的頁要有 16KB 的大小。
所以我們在讀取數據的時候,每次不是指讀出你想要的那幾條,一次至少讀出的數據,就是一頁,16KB。
而存儲的時候,一次也最少要寫入一頁的數據。
那麼我們接下來就要考慮數據頁中是如何存放我們的數據的了。
(我不會把頁裏面所有的內容都列舉出來,我們只需要分析有關的內容,便於理解)
首先,對於我們大量數據的存儲,既然要分頁,那麼必須給每一個頁標上一個唯一的號碼,那麼我們才能確認哪一頁是哪一頁;
我們可以將數據存儲比作一本書,書有很多很多的頁碼,假設,一本書的頁碼全是一個數字,那你按頁去分辨內容的時候一定會非常頭疼。
行格式
不要急,我們先來看最主要的內容,既然是數據庫,那麼最重要的,就是要存放我們的數據吧。
廢話不多說,先建表:
爲了方便使用數據庫,我使用了 Navicat。
我們可以看到,新建的表,默認使用了 InnoDB 存儲引擎(我的是 MySQL 5.7 版本)。
同時,字符集爲 latin1
我們知道,我們的關係型數據庫,數據是按行來存儲的。
我們在創建表的時候,就會先定義表的行格式,也就是每一列分別存儲什麼類型的數據,數據佔用空間是多少。
我們可以看一下,目前默認的行格式是什麼:
我先不說一行裏面到底存儲了多少內容,我們一點一點來分析,最後就能明白。
首先,我們看一下我們的表結構:
我們的一行肯定要存儲數據,所以至少要包含:每個字段所佔用的大小。
id:int
首先 int 是一個固定長度的數字,佔 32 位,也就是 4 個字節
並且是非空的,也就是無論哪一行,有多少行,這裏肯定是要花費 4 個字節的空間來存儲這個 int
a:int,不同的是,可以爲空。
- 現在我們就要思考一下了,首先,假如有數據,那麼佔了 4 個字節沒錯;
可是要是沒有數據呢,爲空的情況怎麼辦??? - 我們可以想到,用一種特殊的字符來佔據這個位置,來表示,這個位置是空的。
這麼做有點就是,十分簡單,而且行的大小不會動態改變;
但是,缺點顯而易見,這裏明明不存儲數據,但是,就是要佔用空間,假設空的字段很多,那豈不是要浪費很多很多的空間??? - 於是,我們還可以繼續思考,爲了節省空間,我們可以選擇,不往這裏存儲數據,但是,這樣就會產生問題:
這裏沒有數據了,那麼空出來的位置,就要去存儲下一列的數據,才能不浪費空間,那麼數據的位置就不固定,就會出現錯亂,我們怎麼讀到正確的數據呢?
所以,爲了解決這個問題,我們來看 COMPACT 行格式(與 DYNAMIC 類似):
在 COMPACT 行格式中,有一個字段叫 NULL標誌位,用來記錄,這一行中,哪些字段爲空;
現在我們已經瞭解了 COMPACT 行格式中的一個額外的字段,
也就是說,我們的一行數據,真正的大小,是要大於一行中數據所佔的大小的,
因爲會有額外字段,去佔用存儲的空間。
(不過,要是我們設計表的時候,所有的字段都不爲空,那麼我們就可以節省這一個用來記錄空字段的空間了)
我們繼續看:
- c:varchar(20):意味着 c 這個字段,只能存儲最多 20 個字符。
(在老版本中,這個長度表示的單位則是字節,不過現在已經表示字符了;
因爲如果要用字節來表示,很多字符佔用的字節數不是固定的,因此很難把控這個字節數到底有多大) - 我們先想,假如是定長字符串,那麼存儲空間就很容易把控,因爲長度固定,根本不會變
- 那麼,像這樣,如果是可變長度的字符串呢?
我們就得去額外的開闢字節,去存儲字符的長度;
這樣,我們在讀取的時候,才知道應該要讀到哪裏。
所以,現在我們又知道了,在 COMPACT 行格式中,還有一個額外的字段,是用來存儲可變字符長度的;
這樣,又再一次說明了,實際上一行記錄的內容,要大於實際存儲數據的內容。
所以,我們現在做一個測試:
我們建一個表,給出一個 varchar 65535 長度的字段;
不過驚喜地發現報錯了:
上面說,最大的行大小爲 65535,不過我現在給出的字段就是 65535,按道理應該正好對上最大值對吧。
不過我們上面已經分析過,因爲一個行之中,還要額外存儲其他的字段,來保證一些特殊情況的處理,如可變長度字符串,空字段;
所以這會額外佔用存儲空間,就使得行大小會大於 65535,就會存不下了。
這時候,我把它改爲 65532,就能成功了(65533 也不行的)。
不過,你們還記不記得,一頁的大小是多少??
16K 對吧,也就是 16 * 1024 只有 16384 個字節的大小;
也就是還存不下我們的這一行數據。
那麼,這樣的數據該怎麼辦?
我們想一想,一頁存不下,那就兩頁,三頁……
把它分開,在各個頁中分別存儲每一小段數據。
想法很完美,那麼,要實現這樣一個目標,就要需要按順序找出所有的頁,
我們知道,數據結構有一種叫鏈表,
我們要實現這樣一種存儲方式,就可以在一個頁中,存儲一部分數據的值,然後再存儲下一個頁的地址,這樣,便可以實現分頁存儲大段的數據了。
還有一種方式,就是將數據和指針分離,一個頁專門存放數據的地址,其他頁專門存放數據:
瞭解了行格式,我們繼續往下探究。
頁目錄
我們現在往數據庫裏插入 8 條記錄:
我們嘗試一下把它們全部查找出來,
一看,確實都有了;
不過,有個小細節,就是查出來的數據,默認是按照 id 排好序的。
也就是說,我們的 InnoDB,已經幫我們做好了排序的工作。
之前我們建表的時候,明確指定了,id 就是這張表的主鍵,然後,存儲的數據,會默認按照主鍵進行排序。
要是我們沒有指定主鍵會怎麼樣???
如果沒有的話,那麼 InnoDB 會默認挑選一個唯一非空的字段來作爲表的主鍵。
如果連一個唯一非空的字段都沒有,那麼就會自動生成一個 row_id,但是這個 row_id 對於我們用戶來說是不可見的,也不可以被查找。
現在假設,我採用了 MyISAM 存儲引擎,設置 id 主鍵,然後我們往其中插入數據
我們查詢一下:
可以發現,我們的查詢結果並不是按照 id 主鍵排好序的。
爲什麼偏偏 InnoDB 會產生這樣的結果??
那麼,我們就要從它的存儲結構談起了。
首先,通過我們的觀察,我們會發現,對於 InnoDB 存儲引擎,我們插入的數據,會被自動排序。
我們知道,對於線性表來說,隨機插入操作的時間複雜度爲 O(n),加入我們的 InnoDB 存儲引擎採用順序線性表來存儲我們的數據,那麼就很容易造成數據插入效率低下的爲問題。
所以,實際上,行與行之間,採用鏈表的方式來進行相連。
不過我們仔細一想,能這麼簡單嗎??
我們知道,鏈表雖然純插入的操作複雜度低,但是,光要找出指定的數據,就需要從頭至尾遍歷,查找的時間複雜度爲 O(n),所以效率還是不夠高的。
只要一個頁個數據量開始變多,那麼查詢所花費的時間就可能會很長。
爲了解決這個問題,在頁中還有一部分存儲空間,叫頁目錄(Page Director)。
我們存儲在頁中的數據,會被分組,其中,用一個頁目錄來專門存放每一組的起始 id。
(語言表述的話會難以理解,我放一張圖)
通過頁目錄,我們將大量的數據分爲少量的組,然後先通過頁目錄,去查找屬於哪一個組;
然後,從那一組的開頭,去依次往後查詢遍歷。
比如,我們要找 id=2 的行記錄,我們便可以先通過頁目錄,找到 1,然後是 3;
因爲 2 比 1 大,然後又比 3 小,
這樣,我們就可以確定,2 在第一組;
所以,我們從頁裏面的 1 的行,開始往後尋找,就可以找到 2。
這樣,在數據量上升之後,查詢的效率就會明顯得到提高。
不過,在頁目錄中,並不需要用遍歷的方式去查詢,
首先,在頁的目錄中,我們只需要存儲 id 主鍵,不用存儲其它的字段數據;
而主鍵通常很小,所以頁目錄也很小;
所以我們完全可以,將其作爲一個數組,然後採取二分查找的方式,去進行查找,
這樣就可以進一步提升查詢效率。
聚集索引
到這裏,我們已經探討了,在一個頁的情況下,我們要怎麼存儲數據。
由於一個頁,大小隻有 16KB,那麼存儲的數據總是有限的,所以,我們真正的生產數據,肯定會存到很多很多的頁中去。
那麼,當數據分頁之後,又是什麼樣子的呢。
如果你還不知道,先別急着看答案,
按照道理,你想一想,數據應該用什麼方式去存儲。
首先,既然分頁了,那麼我們必須有一種結構,能把所有的頁都找到。
仿照着之前行的存儲方式,我們也可以將其存儲成鏈表的樣子。
在第一頁上,記錄下第二頁的地址,我們就可以一頁一頁去查找。
但是,如果只是這樣,那可不行。
就如同之前所描述的,一頁中的多行記錄一樣,頁中的行都要有頁目錄,更何況可以產生更多的頁的數目呢。
所以,類似於頁目錄,我們還需要額外的一個頁,去存儲我們的頁信息。
就像這樣,假如這時候我們需要去查找 id=8 的數據,
我們就會先通過目錄頁,找到記錄所在的那一個頁,就是 5 的所指向的那一頁;
然後在那一個頁中,我們通過頁中的目錄,去查找到數據行所在的那一組,就會找到 7;
最後,從 id=7 的那一行,往後遍歷,就會找到 8 這一行記錄。
假設數據量變大,那麼查找的效率就會比從頭開始查找高處許多:
先從起始頁查找,很快就能定位到下一個頁,然後再定位到下一個頁;
這樣最多 3、4 次,就能查找到指定的頁;
然後在數據頁中,只要按照頁目錄,迅速檢索出行所在的組,然後就能找到。
假設從頭往後遍歷掃描,那麼就會遍歷成千上萬個頁,可見這樣的存儲結構確實十分高效。
然後看到這裏,不知道你有沒什麼發現,這樣一個存儲結構像什麼?
假設我把數據變多:
這看起來不就是一棵樹嘛。
實際上,我們的 InnoDB 底層採用的是 b+ 樹。
這裏推薦一個演示 b+ 樹的網站:
https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html
可以自己快速的體驗和理解 b+ 樹,
就像這樣子:
而我們之前描述的,迅速查找的頁,就叫做索引。
我們上面也已經探討過了,在 InnoDB 中,必須是有主鍵的,而有了主鍵,就一定會有主鍵索引,
我們剛纔演示確實是記錄按照 id 排好順序的。
而主鍵索引又叫聚簇索引、聚集索引。
意思就是,所有的數據都存儲在葉子節點中,
就像這樣:
不過雖然採用了 b+ 樹的形式確實是大大提高了查詢速率。
不過這裏又有一個小細節,就是當一個頁存不下之後,新增一個頁,創建出一個頁表,那麼查詢的起始頁就會改變位置。
如果我們的表,它的起始頁一直在變化,那麼肯定是不好的。
InnoDB 它是怎麼做的呢?
首先,它會將起始頁複製出一個;
然後,將原先的起始頁,修改成爲新的頁目錄。
理清了存儲的數據結構,我們現在再來一個問題,
就是爲什麼我們存儲的主鍵字段,要儘可能的小,並且是遞增的?
也就是爲什麼推薦我們用自增 int 主鍵?
通過上面的學習,想來你們應該能夠明白。
因爲我們的頁目錄,包括目錄頁,裏面都不存儲行裏面所有的記錄,只包含一個 id 索引,和目標地址的指針。
那麼,現在,假設我們用 8 字節的 id,加上 6 字節的指針。
那麼,一個頁,16KB,就可以存放大約 1170 條記錄,這樣我們的索引目錄的數量就會比較少,我們的樹的層數、高度就會比較低,就可以減少磁盤 I/O 帶來的開銷。
這樣,假設樹的高度爲 3,我們的 3 層樹,就可以承載 1170 * 1170 * 16KB 的數據量(大約 21G);
要是樹再高一層,那就再乘上 1170,這樣,數據量就可以有二十多 TB。
而我們的 I/O 開銷,就只有樹的高度,這幾次。
也就是,假設樹有 3 層,我們找到數據行,只需要找 3 頁,就能找到,所以,索引是十分高效的。
而且,如果是遞增的,那麼當數據頁不夠時,增加新的數據頁即可。
否則,如果插在中間,那就得把當前數據頁的排在後面的數據,擠到下一頁去,產生頁分裂,這時非常低效的。
非主鍵索引
之前我都是再說主鍵索引,不過 InnoDB 也可以建立非主鍵索引。
它們唯一的區別就是,聚集索引存儲了每一行的所有數據,但是其它的索引,就不會存儲行數據,而只是在存儲了主鍵的值。
這樣的話,當我們通過某個字段,查找到了主鍵 id,但是我們不能直接獲得其它列的數據,我們還必須通過這個 id,回到主鍵索引,去查取整個行的記錄。
不過我們知道,對於表字段建立索引,可以指定多個字段,共同建立起一個索引,叫聯合索引。
我們現在明白,索引是一種 b+ 樹的結構,也就是會對字段的值進行排好序,方便我們的查找。
看到這裏,你也就應該要能思考出,爲什麼覆蓋索引會有查詢優勢,
因爲,查詢的字段,就已經是非主鍵索引的排序字段了,我們在這棵索引樹上可以直接獲取到字段的值,
而不用去回表掃描,增大 I/O 的開銷。
不過對於聯合索引,多個字段的情況,那麼它是如何排序的?
現在我先對字段 a、b、c 建立了一個聯合索引:
然後我們查詢一下索引中的字段,
我們可以發現,確實是排序過的:
- 首先按照 a 排序,
- 其次如果 a 相等,就按照 b 排序
- 再然後,如果 a、b 都相等了,就按照 c 排序。
然後就可以很容易解釋,爲什麼對於聯合索引的查詢,不按索引順序來寫條件,就會導致索引失效了。
因爲假設,沒有 a 字段的明確指定,那麼 b 的排序就不是完全按照次序的,a 每發生變化,b 的排序就會錯亂;
只有 a 確定了,那麼查詢到的 b 就是排好序的,那麼就可以進行檢索。
就比如:
select * from test where a=1 and b=1 and c=1;
- 這樣查找的時候,因爲 a 的索引是有序的,就會先找到 a;
- 找到 a 之後,b 就是有序的,就可以通過索引迅速找到 b;
- 找到 b 之後,那麼 c 就是有序的,就可以通過索引快速找到 c。
那麼假設 sql 是這樣:
select * from test where a=1 and c=1;
- 這樣查找的時候,因爲 a 的索引是有序的,就會先找到 a;
- 但是由於 b 沒有確定,所以 c 無法保證有序,就無法通過索引再進行快速分治查找。
- 因而 c 索引失效
那麼再假設 sql 是這樣:
select * from test where and a=1 and b>1 and c=1;
- 這樣查找的時候,因爲 a 的索引是有序的,就會先找到 a;
- 找到 a 之後,b 就是有序的,就可以迅速查找到 b=1 的位置,然後,依次往後遍歷出所有的 b 都是大於 1 的;
- 但是,由於 b 有很多很多個符合要求,所以 b 也不是固定的,那麼就導致 c 無法保證有序,因而 c 就無法通過索引分治快速查找
- 所以,c 索引再次失效。
作者的話
學到這裏,那你應該能大致理解,對於 InnoDB,是如何存儲數據的,索引又是一種什麼東西;
那麼,在對於數據庫 sql 語句的編寫,你也能大致明白,底層的邏輯是什麼樣子的,也就能因此避免掉一些性能低效的 sql 語句;
這樣,你也就不用去刻意背誦 sql 優化口訣,而是能真正明白,怎樣的 sql 是高效的。
不過,我這裏也只是分析了底層的存儲結構,關於 InnoDB 還有很多知識需要學習,所以要做到真正精通,還需要大家不斷努力。