京東資深架構師深度解析《 MySQL 實戰 》

基礎架構:一條 SQL 查詢語句是如何執行的?

MySQL 的基本架構示意圖:mysql

clipboard.png

MySQL 能夠分爲 Server 層和存儲引擎層兩部分。算法

Server 層包括鏈接器、查詢緩存、分析器、優化器、執行器等,涵蓋 MySQL 的大多數核心服務功能,以及全部的內置函數(如日期、時間、數學和加密函數等),全部跨存儲引擎的功能都在這一層實現,好比存儲過程、觸發器、視圖等。sql

存儲引擎層負責數據的存儲和提取。其架構模式是插件式的,支持 InnoDB、MyISAM、Memory 等多個存儲引擎。如今最經常使用的存儲引擎是 InnoDB,它從 MySQL 5.5.5 版本開始成爲了默認存儲引擎。數據庫

不一樣存儲引擎的表數據存取方式不一樣,支持的功能也不一樣。不一樣的存儲引擎共用一個Server 層,也就是從鏈接器到執行器的部分。數組

鏈接器

鏈接器負責跟客戶端創建鏈接、獲取權限、維持和管理鏈接。緩存

查詢緩存

MySQL 拿到一個查詢請求後,會先到查詢緩存看看,以前是否是執行過這條語句。以前執行過的語句及其結果可能會以 key-value 對的形式,被直接緩存在內存中。key 是查詢的語句,value 是查詢的結果。若是你的查詢可以直接在這個緩存中找到 key,那麼這個 value 就會被直接返回給客戶端。安全

可是大多數狀況下我會建議你不要使用查詢緩存,爲何呢?由於查詢緩存每每弊大於利。性能優化

查詢緩存的失效很是頻繁,只要有對一個表的更新,這個表上全部的查詢緩存都會被清空。所以極可能你費勁地把結果存起來,還沒使用呢,就被一個更新全清空了。對於更新壓力大的數據庫來講,查詢緩存的命中率會很是低。除非你的業務就是有一張靜態表,很長時間纔會更新一次。好比,一個系統配置表,那這張表上的查詢才適合使用查詢緩存。session

MySQL 8.0 版本直接將查詢緩存的整塊功能刪掉了,也就是說 8.0 開始完全沒有這個功能了。數據結構

分析器

若是沒有命中查詢緩存,就要開始真正執行語句了。

分析器先會作「詞法分析」。作完了這些識別之後,就要作「語法分析」。

優化器

優化器是在表裏面有多個索引的時候,決定使用哪一個索引;或者在一個語句有多表關聯(join)的時候,決定各個表的鏈接順序。

優化器階段完成後,這個語句的執行方案就肯定下來了,而後進入執行器階段。

執行器

打開表的時候,執行器就會根據表的引擎定義,去使用這個引擎提供的接口。

你會在數據庫的慢查詢日誌中看到一個 rows_examined 的字段,表示這個語句執行過程當中掃描了多少行。這個值就是在執行器每次調用引擎獲取數據行的時候累加的。

在有些場景下,執行器調用一次,在引擎內部則掃描了多行,所以引擎掃描行數跟 rows_examined 並非徹底相同的。

我給你留一個問題吧,若是表 T 中沒有字段 k,而你執行了這個語句 select * from T where k=1, 那確定是會報「不存在這個列」的錯誤: 「Unknown column ‘k’ in ‘where clause’」。你以爲這個錯誤是在咱們上面提到的哪一個階段報出來的呢?

答案是分析器。


日誌系統:一條 SQL 更新語句是如何執行的?

與查詢流程不同的是,更新流程還涉及兩個重要的日誌模塊,它們正是咱們今天要討論的主角:redo log(重作日誌)和 binlog(歸檔日誌)。

重要的日誌模塊:redo log

MySQL 裏常常說到的 WAL 技術,WAL 的全稱是 Write-Ahead Logging,它的關鍵點就是先寫日誌,再寫磁盤。

具體來講,當有一條記錄須要更新的時候,InnoDB 引擎就會先把記錄寫到 redo log(粉板)裏面,並更新內存,這個時候更新就算完成了。同時,InnoDB 引擎會在適當的時候,將這個操做記錄更新到磁盤裏面,而這個更新每每是在系統比較空閒的時候作。

與此相似,InnoDB 的 redo log 是固定大小的,好比能夠配置爲一組 4 個文件,每一個文件的大小是 1GB,那麼這塊「粉板」總共就能夠記錄 4GB 的操做。從頭開始寫,寫到末尾就又回到開頭循環寫。

clipboard.png

write pos 是當前記錄的位置,一邊寫一邊後移,寫到第 3 號文件末尾後就回到 0 號文件開頭。checkpoint 是當前要擦除的位置,也是日後推移而且循環的,擦除記錄前要把記錄更新到數據文件。

write pos 和 checkpoint 之間的是「粉板」上還空着的部分,能夠用來記錄新的操做。若是 write pos 追上 checkpoint,表示「粉板」滿了,這時候不能再執行新的更新,得停下來先擦掉一些記錄,把 checkpoint 推動一下。

有了 redo log,InnoDB 就能夠保證即便數據庫發生異常重啓,以前提交的記錄都不會丟失,這個能力稱爲crash-safe。

重要的日誌模塊:binlog

redo log 是 InnoDB 引擎特有的日誌,而 Server 層也有本身的日誌,稱爲 binlog(歸檔日誌)。

爲何會有兩份日誌呢?

由於最開始 MySQL 裏並無 InnoDB 引擎。MySQL 自帶的引擎是 MyISAM,可是 MyISAM 沒有 crash-safe 的能力,binlog 日誌只能用於歸檔。而 InnoDB 是另外一個公司以插件形式引入 MySQL 的,既然只依靠 binlog 是沒有 crash-safe 能力的,因此 InnoDB 使用另一套日誌系統——也就是 redo log 來實現 crash-safe 能力。

這兩種日誌有如下三點不一樣。

  • 1 redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 層實現的,全部引擎均可以使用。
  • 2 redo log 是物理日誌,記錄的是「在某個數據頁上作了什麼修改」;binlog 是邏輯日誌,記錄的是這個語句的原始邏輯,好比「給
    ID=2 這一行的 c 字段加 1 」。
  • 3 redo log 是循環寫的,空間固定會用完;binlog 是能夠追加寫入的。「追加寫」是指 binlog
    文件寫到必定大小後會切換到下一個,並不會覆蓋之前的日誌。

有了對這兩個日誌的概念性理解,咱們再來看執行器和 InnoDB 引擎在執行這個簡單的 update 語句時的內部流程。

  • 1 執行器先找引擎取 ID=2 這一行。ID 是主鍵,引擎直接用樹搜索找到這一行。若是 ID=2
    這一行所在的數據頁原本就在內存中,就直接返回給執行器;不然,須要先從磁盤讀入內存,而後再返回。
  • 2 執行器拿到引擎給的行數據,把這個值加上 1,好比原來是 N,如今就是 N+1,獲得新的一行數據,再調用引擎接口寫入這行新數據。
  • 3 引擎將這行新數據更新到內存中,同時將這個更新操做記錄到 redo log 裏面,此時 redo log 處於 prepare
    狀態。而後告知執行器執行完成了,隨時能夠提交事務。
  • 4 執行器生成這個操做的 binlog,並把 binlog 寫入磁盤。
  • 5 執行器調用引擎的提交事務接口,引擎把剛剛寫入的 redo log 改爲提交(commit)狀態,更新完成。

update 語句執行流程:

clipboard.png

將 redo log 的寫入拆成了兩個步驟:prepare 和 commit,這就是"兩階段提交"。

兩階段提交

前面咱們說過了,binlog 會記錄全部的邏輯操做,而且是採用「追加寫」的形式。

因爲 redo log 和 binlog 是兩個獨立的邏輯若是不使用「兩階段提交」,那麼數據庫的狀態就有可能和用它的日誌恢復出來的庫的狀態不一致。

小結

redo log 用於保證 crash-safe 能力。innodbflushlogattrx_commit 這個參數設置成 1 的時候,表示每次事務的 redo log 都直接持久化到磁盤。這個參數我建議你設置成 1,這樣能夠保證 MySQL 異常重啓以後數據不丟失。

sync_binlog 這個參數設置成 1 的時候,表示每次事務的 binlog 都持久化到磁盤。這個參數我也建議你設置成 1,這樣能夠保證 MySQL 異常重啓以後 binlog 不丟失。


事務隔離:爲何你改了我還看不見?

簡單來講,事務就是要保證一組數據庫操做,要麼所有成功,要麼所有失敗。在 MySQL 中,事務支持是在引擎層實現的。你如今知道,MySQL 是一個支持多引擎的系統,但並非全部的引擎都支持事務。好比 MySQL 原生的 MyISAM 引擎就不支持事務,這也是 MyISAM 被 InnoDB 取代的重要緣由之一。

隔離性與隔離級別

在談隔離級別以前,你首先要知道,你隔離得越嚴實,效率就會越低。SQL 標準的事務隔離級別包括:讀未提交(read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和串行化(serializable )。SQL 標準的事務隔離級別包括:

  • 讀未提交是指,一個事務還沒提交時,它作的變動就能被別的事務看到。
  • 讀提交是指,一個事務提交以後,它作的變動纔會被其餘事務看到。
  • 可重複讀是指,一個事務執行過程當中看到的數據,老是跟這個事務在啓動時看到的數據是一致的。固然在可重複讀隔離級別下,未提交變動對其餘事務也是不可見的。
  • 串行化,顧名思義是對於同一行記錄,「寫」會加「寫鎖」,「讀」會加「讀鎖」。當出現讀寫鎖衝突的時候,後訪問的事務必須等前一個事務執行完成,才能繼續執行。

clipboard.png

  • 若隔離級別是「讀未提交」, 則 V1 的值就是 2。這時候事務 B 雖然尚未提交,可是結果已經被 A 看到了。所以,V二、V3 也都是2。
  • 若隔離級別是「讀提交」,則 V1 是 1,V2 的值是 2。事務 B 的更新在提交後才能被 A 看到。因此, V3 的值也是 2。
  • 若隔離級別是「可重複讀」,則 V一、V2 是 1,V3 是 2。之因此 V2 仍是1,遵循的就是這個要求:事務在執行期間看到的數據先後必須是一致的。
  • 若隔離級別是「串行化」,則在事務 B 執行「將 1 改爲 2」的時候,會被鎖住。直到事務 A 提交後,事務 B 才能夠繼續執行。因此從 A的角度看,V一、V2 值是 1,V3 的值是 2。

在實現上,數據庫裏面會建立一個視圖,訪問的時候以視圖的邏輯結果爲準。在「可重複讀」隔離級別下,這個視圖是在事務啓動時建立的,整個事務存在期間都用這個視圖。在「讀提交」隔離級別下,這個視圖是在每一個 SQL 語句開始執行的時候建立的。這裏須要注意的是,「讀未提交」隔離級別下直接返回記錄上的最新值,沒有視圖概念;而「串行化」隔離級別下直接用加鎖的方式來避免並行訪問。

Oracle 數據庫的默認隔離級別其實就是「讀提交」。

事務啓動時的視圖能夠認爲是靜態的,不受其餘事務更新的影響。

事務隔離的實現

在 MySQL 中,實際上每條記錄在更新的時候都會同時記錄一條回滾操做。記錄上的最新值,經過回滾操做,均可以獲得前一個狀態的值。

同一條記錄在系統中能夠存在多個版本,就是數據庫的多版本併發控制(MVCC)。

你必定會問,回滾日誌總不能一直保留吧,何時刪除呢?答案是,在不須要的時候才刪除。也就是說,系統會判斷,當沒有事務再須要用到這些回滾日誌時,回滾日誌會被刪除。

何時纔不須要了呢?就是當系統裏沒有比這個回滾日誌更早的 read-view 的時候。

長事務意味着系統裏面會存在很老的事務視圖。因爲這些事務隨時可能訪問數據庫裏面的任何數據,因此這個事務提交以前,數據庫裏面它可能用到的回滾記錄都必須保留,這就會致使大量佔用存儲空間。

在 MySQL 5.5 及之前的版本,回滾日誌是跟數據字典一塊兒放在 ibdata 文件裏的,即便長事務最終提交,回滾段被清理,文件也不會變小。

除了對回滾段的影響,長事務還佔用鎖資源。

事務的啓動方式

MySQL 的事務啓動方式有如下幾種:

  • 1 顯式啓動事務語句, begin 或 start transaction。配套的提交語句是 commit,回滾語句是 rollback。
  • 2 set autocommit=0,這個命令會將這個線程的自動提交關掉。

有些客戶端鏈接框架會默認鏈接成功後先執行一個 set autocommit=0 的命令。這就致使接下來的查詢都在事務中,若是是長鏈接,就致使了意外的長事務。

所以,我會建議你老是使用 set autocommit=1, 經過顯式語句的方式來啓動事務。

可是有的開發同窗會糾結「多一次交互」的問題。對於一個須要頻繁使用事務的業務,第二種方式每一個事務在開始時都不須要主動執行一次 「begin」,減小了語句的交互次數。


深刻淺出索引

索引的出現其實就是爲了提升數據查詢的效率,就像書的目錄同樣。

索引的常見模型

索引的出現是爲了提升查詢效率,可是實現索引的方式卻有不少種,因此這裏也就引入了索引模型的概念。簡單的數據結構,它們分別是哈希表、有序數組和搜索樹。

哈希表是一種以鍵 - 值(key-value)存儲數據的結構,咱們只要輸入待查找的值即 key,就能夠找到其對應的值即 Value。

不可避免地,多個 key 值通過哈希函數的換算,會出現同一個值的狀況。處理這種狀況的一種方法是,拉出一個鏈表。

clipboard.png

有序數組索引只適用於靜態存儲引擎。

clipboard.png

二叉搜索樹的特色是:每一個節點的左兒子小於父節點,父節點又小於右兒子。

固然爲了維持 O(log(N)) 的查詢複雜度,你就須要保持這棵樹是平衡二叉樹。爲了作這個保證,更新的時間複雜度也是 O(log(N))。

樹能夠有二叉,也能夠有多叉。多叉樹就是每一個節點有多個兒子,兒子之間的大小保證從左到右遞增。二叉樹是搜索效率最高的,可是實際上大多數的數據庫存儲卻並不使用二叉樹。其緣由是,索引不止存在內存中,還要寫到磁盤上。

爲了讓一個查詢儘可能少地讀磁盤,就必須讓查詢過程訪問儘可能少的數據塊。那麼,咱們就不該該使用二叉樹,而是要使用「N 叉」樹。這裏,「N 叉」樹中的「N」取決於數據塊的大小。

以 InnoDB 的一個整數字段索引爲例,這個 N 差很少是 1200。這棵樹高是 4 的時候,就能夠存 1200 的 3 次方個值,這已經 17 億了。

N 叉樹因爲在讀寫上的性能優勢,以及適配磁盤的訪問模式,已經被普遍應用在數據庫引擎中了。

在 MySQL 中,索引是在存儲引擎層實現的,因此並無統一的索引標準,即不一樣存儲引擎的索引的工做方式並不同。而即便多個存儲引擎支持同一種類型的索引,其底層的實現也可能不一樣。

InnoDB 的索引模型

在 InnoDB 中,表都是根據主鍵順序以索引的形式存放的,這種存儲方式的表稱爲索引組織表。InnoDB 使用了 B+ 樹索引模型,因此數據都是存儲在 B+ 樹中的。

每個索引在 InnoDB 裏面對應一棵 B+ 樹。索引類型分爲主鍵索引和非主鍵索引。

主鍵索引的葉子節點存的是整行數據。在 InnoDB 裏,主鍵索引也被稱爲聚簇索引(clustered index)。

非主鍵索引的葉子節點內容是主鍵的值。在 InnoDB 裏,非主鍵索引也被稱爲二級索引(secondary index)。

基於主鍵索引和普通索引的查詢有什麼區別?

  • 若是語句是 select * from T where ID=500,即主鍵查詢方式,則只須要搜索 ID 這棵 B+ 樹;
  • 若是語句是 select * from T where k=5,即普通索引查詢方式,則須要先搜索 k 索引樹,獲得 ID 的值爲
    500,再到 ID 索引樹搜索一次。這個過程稱爲回表。

也就是說,基於非主鍵索引的查詢須要多掃描一棵索引樹。

索引維護

而更糟的狀況是,若是 R5 所在的數據頁已經滿了,根據 B+ 樹的算法,這時候須要申請一個新的數據頁,而後挪動部分數據過去。這個過程稱爲頁分裂。

當相鄰兩個頁因爲刪除了數據,利用率很低以後,會將數據頁作合併。合併的過程,能夠認爲是分裂過程的逆過程。

假設你的表中確實有一個惟一字段,好比字符串類型的身份證號,那應該用身份證號作主鍵,仍是用自增字段作主鍵呢?

因爲每一個非主鍵索引的葉子節點上都是主鍵的值。若是用身份證號作主鍵,那麼每一個二級索引的葉子節點佔用約 20 個字節,而若是用整型作主鍵,則只要 4 個字節,若是是長整型(bigint)則是 8 個字節。

顯然,主鍵長度越小,普通索引的葉子節點就越小,普通索引佔用的空間也就越小。

有沒有什麼場景適合用業務字段直接作主鍵的呢?仍是有的。好比,有些業務的場景需求是這樣的:

  • 1 只有一個索引;
  • 2 該索引必須是惟一索引。

你必定看出來了,這就是典型的 KV 場景。

因爲沒有其餘索引,因此也就不用考慮其餘索引的葉子節點大小的問題。

回到主鍵索引樹搜索的過程,咱們稱爲回表。

覆蓋索引

若是執行的語句是 select ID from T where k between 3 and 5,這時只須要查 ID 的值,而 ID 的值已經在 k 索引樹上了,所以能夠直接提供查詢結果,不須要回表。也就是說,在這個查詢裏面,索引 k 已經「覆蓋了」咱們的查詢需求,咱們稱爲覆蓋索引。

因爲覆蓋索引能夠減小樹的搜索次數,顯著提高查詢性能,因此使用覆蓋索引是一個經常使用的性能優化手段。

最左前綴原則

B+ 樹這種索引結構,能夠利用索引的「最左前綴」,來定位記錄。索引項是按照索引定義裏面出現的字段順序排序的。

在創建聯合索引的時候,如何安排索引內的字段順序。

第一原則是,若是經過調整順序,能夠少維護一個索引,那麼這個順序每每就是須要優先考慮採用的。

索引下推

在 MySQL 5.6 以前,只能從 ID3 開始一個個回表。到主鍵索引上找出數據行,再對比字段值。

MySQL 5.6 引入的索引下推優化(index condition pushdown), 能夠在索引遍歷過程當中,對索引中包含的字段先作判斷,直接過濾掉不知足條件的記錄,減小回表次數。

clipboard.png

圖3

clipboard.png

圖4

在圖 3 和 4 這兩個圖裏面,每個虛線箭頭表示回表一次。

圖 3 中,在 (name,age) 索引裏面我特地去掉了 age 的值,這個過程 InnoDB 並不會去看 age 的值,只是按順序把「name 第一個字是’張’」的記錄一條條取出來回表。所以,須要回表 4 次。

圖 4 跟圖 3 的區別是,InnoDB 在 (name,age) 索引內部就判斷了 age 是否等於 10,對於不等於 10 的記錄,直接判斷並跳過。在咱們的這個例子中,只須要對 ID四、ID5 這兩條記錄回表取數據判斷,就只須要回表 2 次。


全局鎖和表鎖:給表加個字段怎麼有這麼多阻礙?

數據庫鎖設計的初衷是處理併發問題。當出現併發訪問的時候,數據庫須要合理地控制資源的訪問規則。而鎖就是用來實現這些訪問規則的重要數據結構。

根據加鎖的範圍,MySQL 裏面的鎖大體能夠分紅全局鎖、表級鎖和行鎖三類。

全局鎖

顧名思義,全局鎖就是對整個數據庫實例加鎖。MySQL 提供了一個加全局讀鎖的方法,命令是 Flush tables with read lock (FTWRL)。當你須要讓整個庫處於只讀狀態的時候,可使用這個命令,以後其餘線程的如下語句會被阻塞:數據更新語句(數據的增刪改)、數據定義語句(包括建表、修改表結構等)和更新類事務的提交語句。

全局鎖的典型使用場景是,作全庫邏輯備份。

可是讓整庫都只讀,聽上去就很危險:

  • 若是你在主庫上備份,那麼在備份期間都不能執行更新,業務基本上就得停擺;
  • 若是你在從庫上備份,那麼備份期間從庫不能執行主庫同步過來的 binlog,會致使主從延遲。

一致性讀是好,但前提是引擎要支持這個隔離級別。好比,對於 MyISAM 這種不支持事務的引擎,若是備份過程當中有更新,老是隻能取到最新的數據,那麼就破壞了備份的一致性。這時,咱們就須要使用 FTWRL 命令了。

若是有的表使用了不支持事務的引擎,那麼備份就只能經過 FTWRL 方法。這每每是 DBA 要求業務開發人員使用 InnoDB 替代 MyISAM 的緣由之一。

業務的更新不僅是增刪改數據(DML),還有多是加字段等修改表結構的操做(DDL)。不管是哪一種方法,一個庫被全局鎖上之後,你要對裏面任何一個表作加字段操做,都是會被鎖住的。

表級鎖

MySQL 裏面表級別的鎖有兩種:一種是表鎖,一種是元數據鎖(meta data lock,MDL)。

表鎖的語法是 lock tables … read/write。與 FTWRL 相似,能夠用 unlock tables 主動釋放鎖,也能夠在客戶端斷開的時候自動釋放。須要注意,lock tables 語法除了會限制別的線程的讀寫外,也限定了本線程接下來的操做對象。

舉個例子, 若是在某個線程 A 中執行 lock tables t1 read, t2 write; 這個語句,則其餘線程寫 t一、讀寫 t2 的語句都會被阻塞。同時,線程 A 在執行 unlock tables 以前,也只能執行讀 t一、讀寫 t2 的操做。連寫 t1 都不容許,天然也不能訪問其餘表。

而對於 InnoDB 這種支持行鎖的引擎,通常不使用 lock tables 命令來控制併發,畢竟鎖住整個表的影響面仍是太大。

另外一類表級的鎖是 MDL(metadata lock)。MDL 不須要顯式使用,在訪問一個表的時候會被自動加上。MDL 的做用是,保證讀寫的正確性。你能夠想象一下,若是一個查詢正在遍歷一個表中的數據,而執行期間另外一個線程對這個表結構作變動,刪了一列,那麼查詢線程拿到的結果跟表結構對不上,確定是不行的。

所以,在 MySQL 5.5 版本中引入了 MDL,當對一個表作增刪改查操做的時候,加 MDL 讀鎖;當要對錶作結構變動操做的時候,加 MDL 寫鎖。

  • 讀鎖之間不互斥,所以你能夠有多個線程同時對一張表增刪改查。
  • 讀寫鎖之間、寫鎖之間是互斥的,用來保證變動表結構操做的安全性。所以,若是有兩個線程要同時給一個表加字段,其中一個要等另外一個執行完才能開始執行。

你確定知道,給一個表加字段,或者修改字段,或者加索引,須要掃描全表的數據。在對大表操做的時候,你確定會特別當心,以避免對線上服務形成影響。

clipboard.png

咱們能夠看到 session A 先啓動,這時候會對錶 t 加一個 MDL 讀鎖。因爲 session B 須要的也是 MDL 讀鎖,所以能夠正常執行。

以後 session C 會被 blocked,是由於 session A 的 MDL 讀鎖尚未釋放,而 session C 須要 MDL 寫鎖,所以只能被阻塞。

若是隻有 session C 本身被阻塞還沒什麼關係,可是以後全部要在表 t 上新申請 MDL 讀鎖的請求也會被 session C 阻塞。前面咱們說了,全部對錶的增刪改查操做都須要先申請 MDL 讀鎖,就都被鎖住,等於這個表如今徹底不可讀寫了。

事務中的 MDL 鎖,在語句執行開始時申請,可是語句結束後並不會立刻釋放,而會等到整個事務提交後再釋放。

如何安全地給小表加字段?

首先咱們要解決長事務,事務不提交,就會一直佔着 MDL 鎖。

小結

全局鎖主要用在邏輯備份過程當中。對於所有是 InnoDB 引擎的庫,我建議你選擇使用–single-transaction 參數,對應用會更友好。

表鎖通常是在數據庫引擎不支持行鎖的時候纔會被用到的。若是你發現你的應用程序裏有 lock tables 這樣的語句,你須要追查一下,比較可能的狀況是:

  • 要麼是你的系統如今還在用 MyISAM 這類不支持事務的引擎,那要安排升級換引擎;
  • 要麼是你的引擎升級了,可是代碼還沒升級。我見過這樣的狀況,最後業務開發就是把 lock tables 和 unlock tables 改爲
    begin 和 commit,問題就解決了。

MDL 會直到事務提交才釋放,在作表結構變動的時候,你必定要當心不要致使鎖住線上查詢和更新。


我給你留一個問題吧,備份通常都會在備庫上執行,你在用–single-transaction 方法作邏輯備份的過程當中,若是主庫上的一個小表作了一個 DDL,好比給一個表上加了一列。這時候,從備庫上會看到什麼現象呢?

Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION  WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 時刻 1 */
Q4:show create table `t1`;
/* 時刻 2 */
Q5:SELECT * FROM `t1`;
/* 時刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 時刻 4 */
/* other tables */

參考答案以下:

若是在 Q4 語句執行以前到達,現象:沒有影響,備份拿到的是 DDL 後的表結構。

若是在「時刻 2」到達,則表結構被改過,Q5 執行的時候,報 Table definition has changed, please retry transaction,現象:mysqldump 終止;

若是在「時刻 2」和「時刻 3」之間到達,mysqldump 佔着 t1 的 MDL 讀鎖,binlog 被阻塞,現象:主從延遲,直到 Q6 執行完成。

從「時刻 4」開始,mysqldump 釋放了 MDL 讀鎖,現象:沒有影響,備份拿到的是 DDL 前的表結構。


行鎖功過:怎麼減小行鎖對性能的影響?

MySQL 的行鎖是在引擎層由各個引擎本身實現的。但並非全部的引擎都支持行鎖,好比 MyISAM 引擎就不支持行鎖。不支持行鎖意味着併發控制只能使用表鎖。InnoDB 是支持行鎖的,這也是 MyISAM 被 InnoDB 替代的重要緣由之一。(innodb行級鎖是經過鎖索引記錄實現的。)

顧名思義,行鎖就是針對數據表中行記錄的鎖。這很好理解,好比事務 A 更新了一行,而這時候事務 B 也要更新同一行,則必須等事務 A 的操做完成後才能進行更新。

從兩階段鎖提及

在下面的操做序列中,事務 B 的 update 語句執行時會是什麼現象呢?假設字段 id 是表 t 的主鍵。

clipboard.png

你能夠驗證一下:實際上事務 B 的 update 語句會被阻塞,直到事務 A 執行 commit 以後,事務 B 才能繼續執行。

知道了這個答案,你必定知道了事務 A 持有的兩個記錄的行鎖,都是在 commit 的時候才釋放的。

也就是說,在 InnoDB 事務中,行鎖是在須要的時候才加上的,但並非不須要了就馬上釋放,而是要等到事務結束時才釋放。這個就是兩階段鎖協議。

死鎖和死鎖檢測

當併發系統中不一樣線程出現循環資源依賴,涉及的線程都在等待別的線程釋放資源時,就會致使這幾個線程都進入無限等待的狀態,稱爲死鎖。

clipboard.png

這時候,事務 A 在等待事務 B 釋放 id=2 的行鎖,而事務 B 在等待事務 A 釋放 id=1 的行鎖。 事務 A 和事務 B 在互相等待對方的資源釋放,就是進入了死鎖狀態。當出現死鎖之後,有兩種策略:

  • 一種策略是,直接進入等待,直到超時。這個超時時間能夠經過參數 innodblockwait_timeout 來設置。
  • 另外一種策略是,發起死鎖檢測,發現死鎖後,主動回滾死鎖鏈條中的某一個事務,讓其餘事務得以繼續執行。將參數
    innodbdeadlockdetect 設置爲 on,表示開啓這個邏輯。

在 InnoDB 中,innodblockwait_timeout 的默認值是 50s,意味着若是採用第一個策略,當出現死鎖之後,第一個被鎖住的線程要過 50s 纔會超時退出,而後其餘線程纔有可能繼續執行。對於在線服務來講,這個等待時間每每是沒法接受的。

因此,超時時間設置過短的話,會出現不少誤傷。

因此,正常狀況下咱們仍是要採用第二種策略,即:主動死鎖檢測,並且 innodbdeadlockdetect 的默認值自己就是 on。主動死鎖檢測在發生死鎖的時候,是可以快速發現並進行處理的,可是它也是有額外負擔的。

每一個新來的被堵住的線程,都要判斷會不會因爲本身的加入致使了死鎖,這是一個時間複雜度是 O(n) 的操做。假設有 1000 個併發線程要同時更新同一行,那麼死鎖檢測操做就是 100 萬這個量級的。雖然最終檢測的結果是沒有死鎖,可是這期間要消耗大量的 CPU 資源。

問題的癥結在於,死鎖檢測要耗費大量的 CPU 資源。

一種頭痛醫頭的方法,就是若是你能確保這個業務必定不會出現死鎖,能夠臨時把死鎖檢測關掉。

另外一個思路是控制併發度。根據上面的分析,你會發現若是併發可以控制住,好比同一行同時最多隻有 10 個線程在更新,那麼死鎖檢測的成本很低,就不會出現這個問題。一個直接的想法就是,在客戶端作併發控制。可是,你會很快發現這個方法不太可行,由於客戶端不少。我見過一個應用,有 600 個客戶端,這樣即便每一個客戶端控制到只有 5 個併發線程,彙總到數據庫服務端之後,峯值併發數也可能要達到 3000。

所以,這個併發控制要作在數據庫服務端。

小結

調整語句順序並不能徹底避免死鎖。因此咱們引入了死鎖和死鎖檢測的概念,以及提供了三個方案,來減小死鎖對數據庫的影響。減小死鎖的主要方向,就是控制訪問相同資源的併發事務量。


我給你留一個問題吧,若是你要刪除一個表裏面的前 10000 行數據,有如下三種方法能夠作到:

  • 第一種,直接執行 delete from T limit 10000;
  • 第二種,在一個鏈接中循環執行 20 次 delete from T limit 500;
  • 第三種,在 20 個鏈接中同時執行 delete from T limit 500。

你會選擇哪種方法呢?爲何呢?

確實是這樣的,第二種方式是相對較好的。

第一種方式(即:直接執行 delete from T limit 10000)裏面,單個語句佔用時間長,鎖的時間也比較長;並且大事務還會致使主從延遲。

第三種方式(即:在 20 個鏈接中同時執行 delete from T limit 500),會人爲形成鎖衝突。


事務究竟是隔離的仍是不隔離的?

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

clipboard.png

begin/start transaction 命令並非一個事務的起點,在執行到它們以後的第一個操做 InnoDB 表的語句,事務才真正啓動。若是你想要立刻啓動一個事務,可使用 start transaction with consistent snapshot 這個命令。

在這個例子中,事務 C 沒有顯式地使用 begin/commit,表示這個 update 語句自己就是一個事務,語句完成的時候會自動提交。事務 B 在更新了行以後查詢 ; 事務 A 在一個只讀事務中查詢,而且時間順序上是在事務 B 的查詢以後。

這時,若是我告訴你事務 B 查到的 k 的值是 3,而事務 A 查到的 k 的值是 1。

在 MySQL 裏,有兩個「視圖」的概念:

  • 一個是 view。它是一個用查詢語句定義的虛擬表,在調用的時候執行查詢語句並生成結果。建立視圖的語法是 create view …
    ,而它的查詢方法與表同樣。
  • 另外一個是 InnoDB 在實現 MVCC 時用到的一致性讀視圖,即 consistent read view,用於支持 RC(Read
    Committed,讀提交)和 RR(Repeatable Read,可重複讀)隔離級別的實現。

它沒有物理結構,做用是事務執行期間用來定義「我能看到什麼數據」。

「快照」在 MVCC 裏是怎麼工做的?

在可重複讀隔離級別下,事務在啓動的時候就「拍了個快照」。注意,這個快照是基於整庫的。

InnoDB 裏面每一個事務有一個惟一的事務 ID,叫做 transaction id。它是在事務開始的時候向 InnoDB 的事務系統申請的,是按申請順序嚴格遞增的。

而每行數據也都是有多個版本的。每次事務更新數據的時候,都會生成一個新的數據版本,而且把 transaction id 賦值給這個數據版本的事務 ID,記爲 row trx_id。同時,舊的數據版本要保留,而且在新的數據版本中,可以有信息能夠直接拿到它。

也就是說,數據表中的一行記錄,其實可能有多個版本 (row),每一個版本有本身的 row trx_id。

clipboard.png

圖中虛線框裏是同一行數據的 4 個版本,當前最新版本是 V4,k 的值是 22,它是被 transaction id 爲 25 的事務更新的,所以它的 row trx_id 也是 25。

實際上,圖 2 中的三個虛線箭頭,就是 undo log;而 V一、V二、V3 並非物理上真實存在的,而是每次須要的時候根據當前版本和 undo log 計算出來的。好比,須要 V2 的時候,就是經過 V4 依次執行 U三、U2 算出來。

按照可重複讀的定義,一個事務啓動的時候,可以看到全部已經提交的事務結果。可是以後,這個事務執行期間,其餘事務的更新對它不可見。

所以,一個事務只須要在啓動的時候聲明說,「以我啓動的時刻爲準,若是一個數據版本是在我啓動以前生成的,就認;若是是我啓動之後才生成的,我就不認,我必需要找到它的上一個版本」。

固然,若是「上一個版本」也不可見,那就得繼續往前找。

在實現上, InnoDB 爲每一個事務構造了一個數組,用來保存這個事務啓動瞬間,當前正在「活躍」的全部事務 ID。「活躍」指的就是,啓動了但還沒提交。

數組裏面事務 ID 的最小值記爲低水位,當前系統裏面已經建立過的事務 ID 的最大值加 1 記爲高水位。

這個視圖數組和高水位,就組成了當前事務的一致性視圖(read-view)。

而數據版本的可見性規則,就是基於數據的 row trx_id 和這個一致性視圖的對比結果獲得的。

clipboard.png

這樣,對於當前事務的啓動瞬間來講,一個數據版本的 row trx_id,有如下幾種可能:

若是落在綠色部分,表示這個版本是已提交的事務或者是當前事務本身生成的,這個數據是可見的;

若是落在紅色部分,表示這個版本是由未來啓動的事務生成的,是確定不可見的;

若是落在黃色部分,那就包括兩種狀況:

  • a. 若 row trx_id 在數組中,表示這個版本是由還沒提交的事務生成的,不可見;
  • b. 若 row trx_id 不在數組中,表示這個版本是已經提交了的事務生成的,可見。

由於以後的更新,生成的版本必定屬於上面的 2 或者 3(a) 的狀況,而對它來講,這些新的數據版本是不存在的,因此這個事務的快照,就是「靜態」的了。

因此你如今知道了,InnoDB 利用了「全部數據都有多個版本」的這個特性,實現了「秒級建立快照」的能力。

這裏,咱們不妨作以下假設:

  • 事務 A 開始前,系統裏面只有一個活躍事務 ID 是 99;
  • 事務 A、B、C 的版本號分別是 100、10一、102,且當前系統裏只有這四個事務;
  • 三個事務開始前,(1,1)這一行數據的 row trx_id 是 90。

這樣,事務 A 的視圖數組就是 [99,100], 事務 B 的視圖數組是 [99,100,101], 事務 C 的視圖數組是 [99,100,101,102]。

clipboard.png

從圖中能夠看到,第一個有效更新是事務 C,把數據從 (1,1) 改爲了 (1,2)。這時候,這個數據的最新版本的 row trx_id 是 102,而 90 這個版本已經成爲了歷史版本。

第二個有效更新是事務 B,把數據從 (1,2) 改爲了 (1,3)。這時候,這個數據的最新版本(即 row trx_id)是 101,而 102 又成爲了歷史版本。

你可能注意到了,在事務 A 查詢的時候,其實事務 B 尚未提交,可是它生成的 (1,3) 這個版本已經變成當前版本了。但這個版本對事務 A 必須是不可見的,不然就變成髒讀了。

好,如今事務 A 要來讀數據了,它的視圖數組是 [99,100]。固然了,讀數據都是從當前版本讀起的。因此,事務 A 查詢語句的讀數據流程是這樣的:

  • 找到 (1,3) 的時候,判斷出 row trx_id=101,比高水位大,處於紅色區域,不可見;
  • 接着,找到上一個歷史版本,一看 row trx_id=102,比高水位大,處於紅色區域,不可見;
  • 再往前找,終於找到了(1,1),它的 row trx_id=90,比低水位小,處於綠色區域,可見。

這樣執行下來,雖然期間這一行數據被修改過,可是事務 A 不論在何時查詢,看到這行數據的結果都是一致的,因此咱們稱之爲一致性讀。

一個數據版本,對於一個事務視圖來講,除了本身的更新老是可見之外,有三種狀況:

  • 版本未提交,不可見;
  • 版本已提交,可是是在視圖建立後提交的,不可見;
  • 版本已提交,並且是在視圖建立前提交的,可見。

如今,咱們用這個規則來判斷圖 4 中的查詢結果,事務 A 的查詢語句的視圖數組是在事務 A 啓動的時候生成的,這時候:

  • (1,3) 還沒提交,屬於狀況 1,不可見;
  • (1,2) 雖然提交了,可是是在視圖數組建立以後提交的,屬於狀況 2,不可見;
  • (1,1) 是在視圖數組建立以前提交的,可見。

更新邏輯

你看圖 5 中,事務 B 的視圖數組是先生成的,以後事務 C 才提交,不是應該看不見 (1,2) 嗎,怎麼能算出 (1,3) 來?

clipboard.png

是的,若是事務 B 在更新以前查詢一次數據,這個查詢返回的 k 的值確實是 1。

可是,當它要去更新數據的時候,就不能再在歷史版本上更新了,不然事務 C 的更新就丟失了。所以,事務 B 此時的 set k=k+1 是在(1,2)的基礎上進行的操做。

因此,這裏就用到了這樣一條規則:更新數據都是先讀後寫的,而這個讀,只能讀當前的值,稱爲「當前讀」(current read)。

所以,在更新的時候,當前讀拿到的數據是 (1,2),更新後生成了新版本的數據 (1,3),這個新版本的 row trx_id 是 101。

其實,除了 update 語句外,select 語句若是加鎖,也是當前讀。

再往前一步,假設事務 C 不是立刻提交的,而是變成了下面的事務 C’,會怎麼樣呢?

clipboard.png

事務 C’的不一樣是,更新後並無立刻提交,在它提交前,事務 B 的更新語句先發起了。前面說過了,雖然事務 C’還沒提交,可是 (1,2) 這個版本也已經生成了,而且是當前的最新版本。那麼,事務 B 的更新語句會怎麼處理呢?

事務 C’沒提交,也就是說 (1,2) 這個版本上的寫鎖還沒釋放。而事務 B 是當前讀,必需要讀最新版本,並且必須加鎖,所以就被鎖住了,必須等到事務 C’釋放這個鎖,才能繼續它的當前讀。

clipboard.png

事務的可重複讀的能力是怎麼實現的?

可重複讀的核心就是一致性讀(consistent read);而事務更新數據的時候,只能用當前讀。若是當前的記錄的行鎖被其餘事務佔用的話,就須要進入鎖等待。

而讀提交的邏輯和可重複讀的邏輯相似,它們最主要的區別是:

  • 在可重複讀隔離級別下,只須要在事務開始的時候建立一致性視圖,以後事務裏的其餘查詢都共用這個一致性視圖;
  • 在讀提交隔離級別下,每個語句執行前都會從新算出一個新的視圖。

那麼,咱們再看一下,在讀提交隔離級別下,事務 A 和事務 B 的查詢語句查到的 k,分別應該是多少呢?

下面是讀提交時的狀態圖,能夠看到這兩個查詢語句的建立視圖數組的時機發生了變化,就是圖中的 read view 框。

clipboard.png

這時,事務 A 的查詢語句的視圖數組是在執行這個語句的時候建立的,時序上 (1,2)、(1,3) 的生成時間都在建立這個視圖數組的時刻以前。可是,在這個時刻:

  • (1,3) 還沒提交,屬於狀況 1,不可見;
  • (1,2) 提交了,屬於狀況 3,可見。

因此,這時候事務 A 查詢語句返回的是 k=2。

顯然地,事務 B 查詢結果 k=3。

小結

InnoDB 的行數據有多個版本,每一個數據版本有本身的 row trxid,每一個事務或者語句有本身的一致性視圖。普通查詢語句是一致性讀,一致性讀會根據 row trxid 和一致性視圖肯定數據版本的可見性。

  • 對於可重複讀,查詢只認可在事務啓動前就已經提交完成的數據;
  • 對於讀提交,查詢只認可在語句啓動前就已經提交完成的數據;

而當前讀,老是讀取已經提交完成的最新版本。

固然,MySQL 8.0 已經能夠把表結構放在 InnoDB 字典裏了,也許之後會支持表結構的可重複讀。

clipboard.png

相關文章
相關標籤/搜索