精通mysql索引機制,你就不用再背sql優化口訣了!!(萬字長文)

也許很多人都背過 MySQL 調優的口訣,但是從來不理解爲什麼這樣子寫出的 sql 語句,可以有更高的性能。
而要理解其中的原由,就必須對 MySQL 底層的做一定的瞭解。

同時,爲了進大廠,你也必須學會,才能去和麪試官噴。。

下面我給出幾道題目,你可以做一個自我檢測:

  1. 什麼叫 4K 對齊
  2. 如何存儲空值數據
  3. 如何存儲可變長數據
  4. 大 value 如何存儲
  5. 什麼是聚簇索引
  6. InnoDB 沒有定義主鍵會怎樣
  7. 爲什麼推薦自增 id
  8. 什麼是頁目錄
  9. 查詢起始頁是否會改變
  10. 什麼是覆蓋索引
  11. 爲什麼要符合最左前綴原則

熟悉 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
存儲引擎

我們知道,我們的關係型數據庫,數據是按行來存儲的。
我們在創建表的時候,就會先定義表的行格式,也就是每一列分別存儲什麼類型的數據,數據佔用空間是多少。
我們可以看一下,目前默認的行格式是什麼:
Dynamic
我先不說一行裏面到底存儲了多少內容,我們一點一點來分析,最後就能明白。

首先,我們看一下我們的表結構:
表結構
我們的一行肯定要存儲數據,所以至少要包含:每個字段所佔用的大小。

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,按道理應該正好對上最大值對吧。
test
不過我們上面已經分析過,因爲一個行之中,還要額外存儲其他的字段,來保證一些特殊情況的處理,如可變長度字符串,空字段;
所以這會額外佔用存儲空間,就使得行大小會大於 65535,就會存不下了。
這時候,我把它改爲 65532,就能成功了(65533 也不行的)。
test
不過,你們還記不記得,一頁的大小是多少??
16K 對吧,也就是 16 * 1024 只有 16384 個字節的大小;
也就是還存不下我們的這一行數據。

那麼,這樣的數據該怎麼辦?
我們想一想,一頁存不下,那就兩頁,三頁……
把它分開,在各個頁中分別存儲每一小段數據。

想法很完美,那麼,要實現這樣一個目標,就要需要按順序找出所有的頁,
我們知道,數據結構有一種叫鏈表,
我們要實現這樣一種存儲方式,就可以在一個頁中,存儲一部分數據的值,然後再存儲下一個頁的地址,這樣,便可以實現分頁存儲大段的數據了。
在這裏插入圖片描述
還有一種方式,就是將數據和指針分離,一個頁專門存放數據的地址,其他頁專門存放數據:
在這裏插入圖片描述
瞭解了行格式,我們繼續往下探究。

頁目錄

我們現在往數據庫裏插入 8 條記錄:
插入記錄
我們嘗試一下把它們全部查找出來,
一看,確實都有了;
不過,有個小細節,就是查出來的數據,默認是按照 id 排好序的。
也就是說,我們的 InnoDB,已經幫我們做好了排序的工作。
查找
之前我們建表的時候,明確指定了,id 就是這張表的主鍵,然後,存儲的數據,會默認按照主鍵進行排序。
要是我們沒有指定主鍵會怎麼樣???

如果沒有的話,那麼 InnoDB 會默認挑選一個唯一非空的字段來作爲表的主鍵。
如果連一個唯一非空的字段都沒有,那麼就會自動生成一個 row_id,但是這個 row_id 對於我們用戶來說是不可見的,也不可以被查找。

現在假設,我採用了 MyISAM 存儲引擎,設置 id 主鍵,然後我們往其中插入數據
MyISAM
我們查詢一下:
可以發現,我們的查詢結果並不是按照 id 主鍵排好序的。
test
爲什麼偏偏 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+ 樹,
就像這樣子:
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 還有很多知識需要學習,所以要做到真正精通,還需要大家不斷努力。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章