MySQL 不完全入門指南

detectiveHLH發表於2021-08-18

由於 MySQL 的整個體系太過於龐大,文章的篇幅有限,不能夠完全的覆蓋所有的方面。所以我會盡可能的從更加貼進我們日常使用的方式來進行解釋。

小白眼中的 MySQL

首先,對於我們來說,MySQL 是個啥?我們從一個最簡單的例子來回顧一下。

這可能就是最開始大家認知中的 MySQL。那 MySQL 中是怎麼處理這個查詢語句的呢?換句話說,它是如何感知到這串字串是一個查詢語句的?它是如何感知到該去哪張表中取資料?它是如何感知到該如何取資料?

到目前為止,都不知道。接下來我們一步一補來進行解析。

連線池

首先,要去 MySQL 執行命令,肯定是需要連線上 MySQL 伺服器的,就像我們通過「使用者名稱」和「密碼」登陸網站一樣。所以,我們首先要認識的就是連線池

這種池化技術的作用很明顯,複用連線,避免頻繁的銷燬、建立執行緒所帶來的開銷。除此之外,在這一層還可以根據你的賬號密碼對使用者的合法性、使用者的許可權進行校驗。

每一個連線都對應一個執行緒,「伺服器」 和 「MySQL」 都一樣,伺服器的一個執行緒從伺服器的連線池中取出一個連線,發起查詢語句。MySQL 伺服器的執行緒從連線池中取出一個執行緒,繼續後續的流程。

那麼後面的流程是啥呢?當然是分析 SQL 語句了。

分析器

很明顯,MySQL 肯定得知道這個 SQL 是不是個合法的 SQL 語句,以及 SQL 語句到底要幹啥?

就好像有個哥們瘋狂的敲你家門,門開啟了,下一步是幹嘛?肯定得問他是誰?來幹嘛?

所以,下一步就是要將 SQL 進行解析。解析完成之後,我們就知道當前的 SQL 是否符合語法,它到底要幹嘛,是要查詢資料?還是要更新資料?還是要刪除資料?

很簡單,我們肉眼能能明顯看出來一條 SQL 語句是要幹嘛。但電腦不是人腦,我們得讓電腦也能看懂這條 SQL 語句,才能幫我們去做後面的事。

知道了這個 SQL 語言要幹嘛之後,是不是就可以開始執行操作了呢?

並不是

優化器

MySQL 除了要知道這條 SQL 要幹嘛,在執行之前,還得決定怎麼幹,怎麼幹是最優解。

還是剛剛那個例子,隔壁的哥們敲開了你家的門,說哥們兒,我家裡這停水了,想找你施(白)舍(漂) 24 瓶礦泉水。我們暫且不去討論,他為什麼需要 24 瓶礦泉水。

我們要討論的是,我們要怎麼把這 24 瓶礦泉水拿給他。因為你剛剛想起來,礦泉水在之前被你一頓操作全扔櫃子了。

你是要每次拿個 4 瓶,跑 6 趟呢?還是找個箱子,把 24 瓶裝滿再搬出去給他。

這差不多就是優化器要做的事情,優化器會分析你的 SQL,找出最優解。

再舉個正經的例子,假設 SELECTnameFROMstudentwhereid= 1 語句執行時,資料庫裡有 1W 條資料,此時有兩種方案:

  1. 查出所有列的 name 值,然後再遍歷對比,找到 id=1 的 name 值
  2. 直接找到 id=1 的資料,然後再取 name 的值

用屁股想想都知道應該選方案2.

找到了怎麼做之後,接下來就需要落實到行動上了。所以,接下來執行器閃亮登場。

執行器

執行器會掉用底層儲存引擎的介面,來真正的執行 SQL 語句,在這裡的例子就是查詢操作。

至此,MySQL 這個黑盒子已經被我們一步一步的揭開了面紗。但是在揭開最後一片面紗的時候,我們又發現了新的黑盒子。那就是儲存引擎

我們到目前為止,就只知道它的名字,至於其如何儲存資料,如何查詢資料,一概不知。

儲存引擎

MySQL 的儲存引擎有很多的種類,分別適用於不同的場景。大家可以這麼理解,儲存引擎就是一個執行資料操作的介面(Interface),而底層的具體實現有很多類。

InnoDB、MyISAM、Memory、CSV、Archive、Blackhole、Merge、Federated、Example

用的最廣泛的,就是 InnoDB 了。打從 MySQL 5.5 之後,InnoDB 就是 MySQL 預設的儲存引擎了。

所以,儲存引擎其實並不是什麼高大上的東西,跟什麼大學拿去交作業的圖書館管理系統區別,就差了點而已。

或者,我再舉個例子。我們往我們電腦上的檔案中寫入資料,此時由於 OS 的優化,資料並不會直接寫入磁碟,因為 I/O 操作相當的昂貴。資料會先進入到 OS 的 Cache 中,由 OS 之後刷入磁碟。而 MySQL 在整個的邏輯結構上,跟計算機寫檔案差不多。

從上面的例子看出,儲存引擎可以分為兩部分:

  • 記憶體

  • 磁碟

所以,從巨集觀上來說,MySQL 就是把資料在快取在記憶體中,鼓搗鼓搗,然後在某些時候刷入磁碟中去,就這麼回事。

接下來,就讓我們深入儲存引擎 InnoDB 的底層原理中相當重要的一部分——記憶體架構

簡單來說,InnoDB 的記憶體由以下兩部分組成:

  • Buffer Pool

  • Log Buffer

從上面畫的圖就能夠看出,Buffer Pool 是 InnoDB 中非常重要的一部分,MySQL 之所以這麼快其中一個重要的原因就是其資料都存在記憶體中,而這個記憶體就是 Buffer Pool。

Buffer Pool

一般來說,宿主機的 80% 的記憶體都會分配給 Buffer Pool。這個很好理解,記憶體越大,就能夠存下更多的資料。這樣一來更多的資料將可以直接在記憶體中讀取,可以大大的提升操作效率。

那 Buffer Pool 中到底是如何儲存資料的呢?如果其底層的資料儲存不進行特殊的設計、優化,那麼 InnoDB 在取資料的時候除了整個遍歷之外,沒有其他的捷徑。而如果那樣做,MySQL 也不會獲得今天這樣的地位。

如果我們能想象一下,InnoDB 會如何組織記憶體的資料。想象一下,圖書館的書是直接一本一本的攤在地上好找,還是按照類目、名稱進行分類、放到對應的書架上、再進行編號好找?結論自然不言而喻。Buffer Pool 也採用了同樣的資料整合措施。

InnoDB 將 Buffer Pool 分成了一張一張的頁(Pages),同時還有個 Change Buffer(後面會詳細講,這裡先知道就行)。分成一頁一頁的資料就能夠提升查詢效率嗎?那這個頁裡面到底是個啥呢?

可以從上圖看到,頁和頁之間,實際上是有關聯的,他們通過雙向連結串列進行連線,可以方便的從某一頁跳到下一頁。

那資料在頁中具體是如何儲存的呢?

User Records

當然,光跳來跳去的並不能說明任何問題,我們還是揭開頁(Pages)這個黑盒的面紗吧。

!

可以看到,主要就分為

  • 上一頁指標

  • 下一頁指標

  • User Records

  • 其餘欄位

為了方便理解,其餘欄位我後續會補充

上一頁指標、下一頁指標就不多贅述,就是一個指標。重點我們需要了解 User Records。

User Records 就是一行一行的資料**(Rows)最終儲存的地方,其中,行與行之間形成了單向連結串列**。

看了這個單向連結串列不知道你有沒有一個疑問。

我們知道,在聚簇索引中,Key 實際上會按照 Primary Key 的順序來進行排列。那在 User Records 中也會這樣嗎?我們插入一條新的資料到 User Records 中時,是否也會按照 Primary Key 的順序來對已有的資料重排序?

如果每次插入資料都需要對 User Records 中的資料進行重排序,那麼 MySQL 的江湖地位將再次不保。

雖然在圖中看起來是按照「主鍵」的順序儲存的,但實際上是按照資料的插入順序來儲存的,先到的資料會在前面,後到的資料會在後面,只是每個 User Records 資料的指標指向的不是物理上的下一個,而是邏輯上的下一個

用圖來表示,大概如下:

可以理解為陣列和連結串列的區別。

看到這,那麼問題來了,說好的不遍歷呢?這不是打臉嗎?因為從上圖可以看出,我要找查詢某個資料是否存在於當前的頁(Pages)中,只能從頭開始遍歷這個單向連結串列。

就這?還敢號稱高效能?當然,InnoDB 肯定不是這麼搞的。這下就需要從「其餘欄位」中取出一部分欄位了來解釋了。

Infimum 和 Supremum

分別代表當前頁(Pages)中的最大最小的記錄。

可以看到,有了 InfimumSupremum,我們不需要再去遍歷 User Records 就能夠知道,要找的資料是否在當前的頁中,大大的提升了效率。

但其實還是有問題,比如我需要查詢的資料不在當前頁中還好,那如果在呢?那是不是還是逃不了 O(N) 的連結串列遍歷呢?算不算治標不治本?

這個時候,我們又需要從「其餘欄位」中抽一個概念出來了。

Page Directory

顧名思義,這玩意兒是個「目錄」,可以看下圖。

!

可以看到,每隔一段記錄就會在 Page Directory 中有個記錄,這個記錄是一個指向 User Records 中記錄的一個指標。

不知道這個設計有沒有讓你想起跳錶(Skip List)。那這個 Page Directory 中的目錄拿來幹嘛呢?

有了 Page Directory,就可以對一頁中的資料進行類似於跳錶的中的查詢。在 Page Directory 中找到對應的「位置」之後,再根據指標跳到對應的 User Records 上的單連結串列,進行查詢。如此一來就避免了遍歷全部的資料。

上面提到的「位置」,其實有個專業的名詞叫「槽位(Slots)」。每一個槽位的資料都是一個指向了 User Records 某條記錄的指標。

當我們新增每條資料的時候,就會同步的對 Page Directory 中的槽位進行維護。InnoDB 規定每隔 6 條記錄就會建立一個 Slot。

瞭解到這裡之後,關於如何高效地在 MySQL 查詢資料就已經瞭解的差不多了。

想了解「其餘欄位」還有哪些、以及「頁」的完整面貌的,可以去看看我之前寫的頁的文章 MySQL 頁完全指南——淺入深出頁的原理,再次就不再贅述。

索引

瞭解完頁之後,索引是什麼就一目瞭然了。InnoDB 底層的儲存使用的資料結構為 B+樹,B樹的變種。MySQL 中有兩種索引,分別是聚簇索引和非聚簇索引,聽著很高大上。

其實瞭解完「頁」的底層原理,要區分它們就變成的很簡單了。

  • 聚簇索引的葉子結點上,儲存的是「頁」

  • **非聚簇索引(二級索引)**的葉子結點上,儲存的是「主鍵ID」。很多時候,我們都需要通過非聚簇索引拿到主鍵,再根據這個主鍵去「聚簇索引」中拿完整的資料,這個過程還有一個很有意思的名字叫「回表」。

至於為什麼底層資料結構要用 B+樹 和 B樹,大概是因為以下三點:

  • B+樹能夠減少 I/O 的次數

  • 查詢效率更加的穩定

  • 能夠更好的支援範圍查詢

詳細的原因可以參考之前寫的 淺入淺出 MySQL 索引

更新資料

為什麼下一步就是要看如何更新資料呢?因為上述的「頁」的原理主要都是基於「查詢」的前提在講,看完了之後對查詢的過程應該瞭然於胸了。接下來我們就來看看更新的時候會發生什麼。

首先,如果我們插入了某條 id=100 的資料,然後再去更新的話,這條資料是一定的在 Buffer Pool 的。這句話看似是廢話(我都寫到資料庫了那肯定存在啊)

那我換個說法,更新的時候,id=100 這條資料可能不在 Buffer Pool 中。為什麼之前寫入了 Buffer Pool,之後再來更新 Buffer Pool 中又沒有呢?

答案是記憶體是有限的,我們不可能無限的向 Buffer Pool 中插入資料。熟悉 Redis 的知道,Redis 在執行時會有「過期策略」,有以下三種:

  1. 定時過期

  2. 惰性過期

  3. 定期過期

而 Buffer Pool 同樣也是基於記憶體,同樣也需要一個「過期策略」來清理掉一些不常被訪問的資料,來為新的資料、熱點資料騰出空間。

當然,這裡的清理掉,並不是刪除,而是將它們刷入磁碟

更新資料時,如果發現對應的資料不存在,就會將那個資料所在的頁載入到 Buffer Pool 中來。注意,這裡並不是只載入 id=100 這一行,而是其所在的一整「頁」資料。

載入到 Buffer Pool 中之後,再對 Buffer Pool 中的資料進行更新。當然,這個情況對我們開發人員來說,是針對聚簇索引的。

還有另一種情況是針對「 非聚簇索引」 的。

Change Buffer

很簡單,當我們更新了某些欄位之後,假設這些欄位是組成非聚簇索引的欄位,就會涉及到非聚簇索引的更新,但不巧的是該非聚簇索引所在的頁不在 Buffer Pool 中。按照之前的說法,需要將對應的頁(Pages)載入到 Buffer Pool 中來。

但是這裡有一個很大的問題,這個二級索引可能之後**根本不會被用到,**那這樣一來,剛剛昂貴的 I/O 操作就被浪費掉了。積少成多,如果每次涉及到更新二級索引發現在 Buffer Pool 中不存在,都去做 I/O 操作,那也是一個相當大的開銷。

所以,InnoDB 才設計了 Change Buffer。Change Buffer 就是專門用來儲存當「非聚簇索引」所在的頁不在 Buffer Pool 時的更改的。

換句話說,當對應的非聚簇索引被修改並且對應的頁(Pages)不在 Buffer Pool 中時,會將其改動暫存在 Change Buffer,等到其對應的頁被其他的請求載入進 Buffer Pool 時,就會將 Change Buffer 中暫存的資料 和 Buffer Pool 中的資料進行合併

當然,Change Buffer 這個設計也不是沒有缺點。當 Change Buffer 中有很多的資料時,全部合併到Buffer Pool可能會花上幾個小時的時間,並且在合併的期間,磁碟的 I/O 操作會比較頻繁,從而導致部分的CPU資源被佔用,對 MySQL 整體的效能是有影響的。

那你可能會問,難道只有被快取的頁載入到了 Buffer Pool 才會觸發合併操作嗎?那要是它一直沒有被載入進來,Change Buffer 不就被撐爆了?很顯然,InnoDB 在設計的時候考慮到了這個點。除了對應的頁載入,提交事務、服務停機、服務重啟都會觸發合併。

Adaptive Hash

自適應雜湊索引(Adaptive Hash Index)是配合 Buffer Pool 工作的一個功能。自適應雜湊索引使得MySQL的效能更加接近於記憶體伺服器。

如果要啟用自適應雜湊索引,可以通過更改配置innodb_adaptive_hash_index來開啟。如果不想啟用,也可以在啟動的時候,通過命令列引數--skip-innodb-adaptive-hash-index來關閉。

自適應雜湊索引是根據索引 Key 的字首來構建的,InnoDB 有自己的監控索引的機制,當其檢測到為當前某個索引頁建立雜湊索引能夠提升效率時,就會建立對應的雜湊索引。如果某張表資料量很少,其資料全部都在 Buffer Pool 中,那麼此時自適應雜湊索引就會變成我們所熟悉的指標這樣一個角色。

當然,建立、維護自適應雜湊索引是會帶來一定的開銷的,但是比起其帶來的效能上的提升,這點開銷可以直接忽略不計。但是,是否要開啟自適應雜湊索引還是需要看具體的業務情況的,例如當我們的業務特徵是有大量的併發 Join 查詢,此時訪問自適應雜湊索引就會產生競爭

並且如果業務還使用了 LIKE 或者 % 等萬用字元,根本就不會用到雜湊索引,那麼此時自適應雜湊索引反而變成了系統的負擔。

所以,為了儘可能的減少併發情況下帶來的競爭,InnoDB 對自適應雜湊索引進行了分割槽,每個索引都被繫結到了一個特定的分割槽,而每個分割槽都由單獨的鎖進行保護。

其實通俗點理解,就是降低了鎖的粒度。分割槽的數量我們可以通過配置innodb_adaptive_hash_index_parts來改變,其可配置的區間範圍為[8, 512]。

過期策略

上面提到,Buffer Pool 也會有自己的過期策略,定時的將不需要的資料刷回磁碟,為後續的請求騰出空間。那麼,InnoDB 是怎麼知道哪些資料是不需要的呢?

答案是 LRU 演算法

LRU是**(L**east Recently Used)的簡稱,表示最近最少使用,Redis 的記憶體淘汰策略中也有用到 LRU。

但是 InnoDB 所採用的 LRU 演算法和傳統的 LRU 演算法還不太一樣,InnoDB 使用的是改良版的 LRU。那為啥要改良?這就需要了解原生 LRU 在 MySQL 有啥問題了。

在實際的業務場景下,很有可能會出現全表掃描的情況,如果資料量較大,那麼很有可能會將之前 Buffer Pool 中快取的熱點資料全部換出。這樣一來,熱點資料被再次訪問時,就需要執行 I/O 操作,而這樣就會導致該段時間 MySQL 效能斷崖式下跌。而這種情況還有個專門的名詞,叫——緩衝池汙染

這也是為什麼 InnoDB 要對 LRU 演算法做優化。

優化之後的連結串列被分成了兩個部分,分別是 New SublistOld Sublist,其分別佔用了 Buffer Pool 的 3/4 和 1/4。

連結串列的前 3/4,也就是 New Sublist 存放的是訪問較為頻繁的頁。而後 1/4 也就是 Old Sublist 則是反問的不那麼頻繁的頁。Old Sublist中的資料,會在後續 Buffer Pool 剩餘空間不足、或者有新的頁加入時被移除掉。

瞭解了連結串列的整體構造和組成之後,我們就以新頁被加入到連結串列為起點,把整體流程走一遍。首先,一個新頁被放入到Buffer Pool之後,會被插入到連結串列中 New Sublist 和 Old Sublist 相交的位置,該位置叫MidPoint

該連結串列儲存的資料來源有兩部分,分別是:

  • MySQL 的預讀執行緒預先載入的資料

  • 使用者的操作,例如 Query 查詢

預設情況下,由使用者操作影響而進入到 Buffer Pool 中的資料,會被立即放到連結串列的最前端,也就是 New Sublist 的 Head 部分。但如果是 MySQL 啟動時預載入的資料,則會放入MidPoint中,如果這部分資料再次被使用者訪問過之後,才會放到連結串列的最前端。

這樣一來,雖然這些頁資料在連結串列中了,但是由於沒有被訪問過,就會被移動到後 1/4 的 Old Sublist中去,直到被清理掉。

Log Buffer

Log Buffer 用來儲存那些即將被刷入到磁碟檔案中的日誌,例如 Redo Log,該區域也是 InnoDB記憶體的重要組成部分。Log Buffer 的預設值為16M,如果我們需要進行調整的話,可以通過配置引數innodb_log_buffer_size來進行調整。

當 Log Buffer 如果較大,就可以儲存更多的 Redo Log,這樣一來在事務提交之前我們就不需要將 Redo Log 刷入磁碟,只需要丟到 Log Buffer 中去即可。因此較大的 Log Buffer 就可以更好的支援較大的事務執行;同理,如果有事務會大量的更新、插入或者刪除行,那麼適當的增大 Log Buffer 的大小,也可以有效的減少部分磁碟 I/O 操作。

至於 Log Buffer 中的資料刷入到磁碟的頻率,則可以通過引數innodb_flush_log_at_trx_commit來決定。

本篇文章已放到我的 Github github.com/sh-blog 中,歡迎 Star。微信搜尋關注【SH的全棧筆記】,回覆【佇列】獲取MQ學習資料,包含基礎概念解析和RocketMQ詳細的原始碼解析,持續更新中。

如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言