MySQL實戰45講學習筆記:第二十講

 1、引子

在上一篇文章最後,我給你留了一個關於加鎖規則的問題。今天,咱們就從這個問題提及吧。mysql

爲了便於說明問題,這一篇文章,咱們就先使用一個小一點兒的表。建表和初始化語句以下(爲了便於本期的例子說明,我把上篇文章中用到的表結構作了點兒修改):sql

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB;

insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

這個表除了主鍵 id 外,還有一個索引 c,初始化語句在表中插入了 6 行數據。數據庫

上期我留給你的問題是,下面的語句序列,是怎麼加鎖的,加的鎖又是何時釋放的呢bash

begin;
select * from t where d=5 for update;
commit;

比較好理解的是,這個語句會命中 d=5 的這一行,對應的主鍵 id=5,所以在 select 語句執行完成後,id=5 這一行會加一個寫鎖,並且因爲兩階段鎖協議,這個寫鎖會在執行
commit 語句的時候釋放。session

因爲字段 d 上沒有索引,所以這條查詢語句會作全表掃描。那麼,其餘被掃描到的,可是不知足條件的 5 行記錄上,會不會被加鎖呢?併發

咱們知道,InnoDB 的默認事務隔離級別是可重複讀,因此本文接下來沒有特殊說明的部分,都是設定在可重複讀隔離級別下。運維

2、幻讀是什麼?

如今,咱們就來分析一下,若是隻在 id=5 這一行加鎖,而其餘行的不加鎖的話,會怎麼樣。測試

下面先來看一下這個場景(注意:這是我假設的一個場景):spa

圖 1 假設只在 id=5 這一行加行鎖線程


能夠看到,session A 裏執行了三次查詢,分別是 Q一、Q2 和 Q3。它們的 SQL 語句相同,都是 select * from t where d=5 for update。這個語句的意思你應該很清楚了,查

全部 d=5 的行,並且使用的是當前讀,而且加上寫鎖。如今,咱們來看一下這三條 SQL語句,分別會返回什麼結果。

1. Q1 只返回 id=5 這一行;
2. 在 T2 時刻,session B 把 id=0 這一行的 d 值改爲了 5,所以 T3 時刻 Q2 查出來的是id=0 和 id=5 這兩行;
3. 在 T4 時刻,session C 又插入一行(1,1,5),所以 T5 時刻 Q3 查出來的是 id=0、id=1 和 id=5 的這三行。

其中,Q3 讀到 id=1 這一行的現象,被稱爲「幻讀」。也就是說,幻讀指的是一個事務在先後兩次查詢同一個範圍的時候,後一次查詢看到了前一次查詢沒有看到的行。

這裏,我須要對「幻讀」作一個說明:

1. 在可重複讀隔離級別下,普通的查詢是快照讀,是不會看到別的事務插入的數據的。所以,幻讀在「當前讀」下才會出現。
2. 上面 session B 的修改結果,被 session A 以後的 select 語句用「當前讀」看到,不能稱爲幻讀。幻讀僅專指「新插入的行」。

若是隻從第 8 篇文章《事務究竟是隔離的仍是不隔離的?》咱們學到的事務可見性規則來分析的話,上面這三條 SQL 語句的返回結果都沒有問題。

由於這三個查詢都是加了 for update,都是當前讀。而當前讀的規則,就是要能讀到全部已經提交的記錄的最新值。而且,session B 和 sessionC 的兩條語句,執行後就會提交,因此 Q2 和 Q3 就是應該看到這兩個事務的操做效果,並且也看到了,這跟事務的可見性規則並不矛盾。

可是,這是否是真的沒問題呢?不,這裏還真就有問題。

3、幻讀有什麼問題?

一、首先是語義上的

session A 在 T1 時刻就聲明瞭,「我要把全部 d=5 的行鎖住,不許別的事務進行讀寫操做」。而實際上,這個語義被破壞了。

若是如今這樣看感受還不明顯的話,我再往 session B 和 session C 裏面分別加一條 SQL語句,你再看看會出現什麼現象。

圖 2 假設只在 id=5 這一行加行鎖 -- 語義被破壞

session B 的第二條語句 update t set c=5 where id=0,語義是「我把 id=0、d=5 這一行的 c 值,改爲了 5」。

因爲在 T1 時刻,session A 還只是給 id=5 這一行加了行鎖, 並無給 id=0 這行加上鎖。所以,session B 在 T2 時刻,是能夠執行這兩條 update 語句的。這樣,就破壞了
session A 裏 Q1 語句要鎖住全部 d=5 的行的加鎖聲明。

session C 也是同樣的道理,對 id=1 這一行的修改,也是破壞了 Q1 的加鎖聲明。

二、其次,是數據一致性的問題。

咱們知道,鎖的設計是爲了保證數據的一致性。而這個一致性,不止是數據庫內部數據狀態在此刻的一致性,還包含了數據和日誌在邏輯上的一致性。

爲了說明這個問題,我給 session A 在 T1 時刻再加一個更新語句,即:update t setd=100 where d=5。

 

圖 3 假設只在 id=5 這一行加行鎖 -- 數據一致性問題

update 的加鎖語義和 select …for update 是一致的,因此這時候加上這條 update 語句也很合理。session A 聲明說「要給 d=5 的語句加上鎖」,就是爲了要更新數據,新加的

這條 update 語句就是把它認爲加上了鎖的這一行的 d 值修改爲了 100。

如今,咱們來分析一下圖 3 執行完成後,數據庫裏會是什麼結果。

1. 通過 T1 時刻,id=5 這一行變成 (5,5,100),固然這個結果最終是在 T6 時刻正式提交的 ;
2. 通過 T2 時刻,id=0 這一行變成 (0,5,5);
3. 通過 T4 時刻,表裏面多了一行 (1,5,5);
4. 其餘行跟這個執行序列無關,保持不變。

這樣看,這些數據也沒啥問題,可是咱們再來看看這時候 binlog 裏面的內容。

1. T2 時刻,session B 事務提交,寫入了兩條語句;
2. T4 時刻,session C 事務提交,寫入了兩條語句;
3. T6 時刻,session A 事務提交,寫入了 update t set d=100 where d=5 這條語句。

我統一放到一塊兒的話,就是這樣的:

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/* 全部 d=5 的行,d 改爲 100*/

好,你應該看出問題了。這個語句序列,不管是拿到備庫去執行,仍是之後用 binlog 來克隆一個庫,這三行的結果,都變成了 (0,5,100)、(1,5,100) 和 (5,5,100)。

也就是說,id=0 和 id=1 這兩行,發生了數據不一致。這個問題很嚴重,是不行的。

三、這個數據不一致究竟是怎麼引入的?

到這裏,咱們再回顧一下,這個數據不一致究竟是怎麼引入的?

咱們分析一下能夠知道,這是咱們假設「select * from t where d=5 for update 這條語句只給 d=5 這一行,也就是 id=5 的這一行加鎖」致使的。

因此咱們認爲,上面的設定不合理,要改。

那怎麼改呢?咱們把掃描過程當中碰到的行,也都加上寫鎖,再來看看執行效果。

圖 4 假設掃描到的行都被加上了行鎖


因爲 session A 把全部的行都加了寫鎖,因此 session B 在執行第一個 update 語句的時候就被鎖住了。須要等到 T6 時刻 session A 提交之後,session B 才能繼續執行。

這樣對於 id=0 這一行,在數據庫裏的最終結果仍是 (0,5,5)。在 binlog 裏面,執行序列是這樣的:

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/* 全部 d=5 的行,d 改爲 100*/

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

能夠看到,按照日誌順序執行,id=0 這一行的最終結果也是 (0,5,5)。因此,id=0 這一行的問題解決了。

但同時你也能夠看到,id=1 這一行,在數據庫裏面的結果是 (1,5,5),而根據 binlog 的執行結果是 (1,5,100),也就是說幻讀的問題仍是沒有解決。爲何咱們已經這麼「兇
殘」地,把全部的記錄都上了鎖,仍是阻止不了 id=1 這一行的插入和更新呢?

緣由很簡單。在 T3 時刻,咱們給全部行加鎖的時候,id=1 這一行還不存在,不存在也就加不上鎖。

也就是說,即便把全部的記錄都加上鎖,仍是阻止不了新插入的記錄,這也是爲何「幻讀」會被單獨拿出來解決的緣由。

到這裏,其實咱們剛說明完文章的標題 :幻讀的定義和幻讀有什麼問題。

接下來,咱們再看看 InnoDB 怎麼解決幻讀的問題。

4、如何解決幻讀?

一、間隙鎖是啥?

如今你知道了,產生幻讀的緣由是,行鎖只能鎖住行,可是新插入記錄這個動做,要更新的是記錄之間的「間隙」。所以,爲了解決幻讀問題,InnoDB 只好引入新的鎖,也就是
間隙鎖 (Gap Lock)。

顧名思義,間隙鎖,鎖的就是兩個值之間的空隙。好比文章開頭的表 t,初始化插入了 6個記錄,這就產生了 7 個間隙。

圖 5 表 t 主鍵索引上的行鎖和間隙鎖


這樣,當你執行 select * from t where d=5 for update 的時候,就不止是給數據庫中已有的 6 個記錄加上了行鎖,還同時加了 7 個間隙鎖。這樣就確保了沒法再插入新的記錄。

也就是說這時候,在一行行掃描的過程當中,不只將給行加上了行鎖,還給行兩邊的空隙,也加上了間隙鎖。

二、間隙鎖跟咱們以前碰到過的鎖都不太同樣。

如今你知道了,數據行是能夠加上鎖的實體,數據行之間的間隙,也是能夠加上鎖的實體。可是間隙鎖跟咱們以前碰到過的鎖都不太同樣。

好比行鎖,分紅讀鎖和寫鎖。下圖就是這兩種類型行鎖的衝突關係。

圖 6 兩種行鎖間的衝突關係


也就是說,跟行鎖有衝突關係的是「另一個行鎖」。

可是間隙鎖不同,跟間隙鎖存在衝突關係的,是「往這個間隙中插入一個記錄」這個操做。間隙鎖之間都不存在衝突關係。

這句話不太好理解,我給你舉個例子:

圖 7 間隙鎖之間不互鎖

實際測試代碼以下:

session A

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t where c=7 lock in share mode;
Empty set (0.00 sec)

session B

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t where c=7 for update;
Empty set (0.00 sec)

這裏 session B 並不會被堵住。由於表 t 裏並無 c=7 這個記錄,所以 session A 加的是間隙鎖 (5,10)。而 session B 也是在這個間隙加的間隙鎖。它們有共同的目標,即:保護
這個間隙,不容許插入值。但,它們之間是不衝突的。

間隙鎖和行鎖合稱 next-key lock,每一個 next-key lock 是前開後閉區間。也就是說,咱們的表 t 初始化之後,若是用 select * from t for update 要把整個表全部記錄鎖起來,
就造成了 7 個 next-key lock,分別是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20,25]、(25, +supremum]。

備註:這篇文章中,若是沒有特別說明,咱們把間隙鎖記爲開區間,把next-key lock 記爲前開後閉區間。

你可能會問說,這個 supremum 從哪兒來的呢?

這是由於 +∞是開區間。實現上,InnoDB 給每一個索引加了一個不存在的最大值supremum,這樣才符合咱們前面說的「都是前開後閉區間」。

三、間隙鎖引入了什麼新的問題?

在前面的文章中,就有同窗提到了這個問題。我把他的問題轉述一下,對應到咱們這個例子的表來講,業務邏輯這樣的:任意鎖住一行,若是這一行不存在的話就插入,若是存在
這一行就更新它的數據,代碼以下:

begin;
select * from t where id=N for update;

/* 若是行不存在 */
insert into t values(N,N,N);
/* 若是行存在 */
update t set d=N set id=N;

commit;

可能你會說,這個不是 insert … on duplicate key update 就能解決嗎?但其實在有多個惟一鍵的時候,這個方法是不能知足這位提問同窗的需求的。至於爲何,我會在後面的
文章中再展開說明。

如今,咱們就只討論這個邏輯。

這個同窗碰到的現象是,這個邏輯一旦有併發,就會碰到死鎖。你必定也以爲奇怪,這個邏輯每次操做前用 for update 鎖起來,已是最嚴格的模式了,怎麼還會有死鎖呢?

這裏,我用兩個 session 來模擬併發,並假設 N=9。

圖 8 間隙鎖致使的死鎖


你看到了,其實都不須要用到後面的 update 語句,就已經造成死鎖了。咱們按語句執行順序來分析一下:

1. session A 執行 select … for update 語句,因爲 id=9 這一行並不存在,所以會加上間隙鎖 (5,10);

2. session B 執行 select … for update 語句,一樣會加上間隙鎖 (5,10),間隙鎖之間不會衝突,所以這個語句能夠執行成功;
3. session B 試圖插入一行 (9,9,9),被 session A 的間隙鎖擋住了,只好進入等待;
4. session A 試圖插入一行 (9,9,9),被 session B 的間隙鎖擋住了。

至此,兩個 session 進入互相等待狀態,造成死鎖。固然,InnoDB 的死鎖檢測立刻就發現了這對死鎖關係,讓 session A 的 insert 語句報錯返回

你如今知道了,間隙鎖的引入,可能會致使一樣的語句鎖住更大的範圍,這實際上是影響了併發度的。其實,這還只是一個簡單的例子,在下一篇文章中咱們還會碰到更多、更復雜的例子。

你可能會說,爲了解決幻讀的問題,咱們引入了這麼一大串內容,有沒有更簡單一點的處理方法呢。

四、如何解決引入間隙鎖帶來的問題

我在文章一開始就說過,若是沒有特別說明,今天和你分析的問題都是在可重複讀隔離級別下的,間隙鎖是在可重複讀隔離級別下才會生效的。因此,你若是把隔離級別設置爲讀

提交的話,就沒有間隙鎖了。但同時,你要解決可能出現的數據和日誌不一致問題,須要把 binlog 格式設置爲 row。這,也是如今很多公司使用的配置組合。

一、隔離級別設置爲讀提交的話 爲何須要把binlog格式設置爲row?

前面文章的評論區有同窗留言說,他們公司就使用的是讀提交隔離級別加binlog_format=row 的組合。他曾問他們公司的 DBA 說,你爲何要這麼配置。DBA
直接答覆說,由於你們都這麼用呀。

因此,這個同窗在評論區就問說,這個配置到底合不合理。

關於這個問題自己的答案是,若是讀提交隔離級別夠用,也就是說,業務不須要可重複讀的保證,這樣考慮到讀提交下操做數據的鎖範圍更小(沒有間隙鎖),這個選擇是合理的。

但其實我想說的是,配置是否合理,跟業務場景有關,須要具體問題具體分析。

可是,若是 DBA 認爲之因此這麼用的緣由是「你們都這麼用」,那就有問題了,或者說,早晚會出問題。

二、爲何備份線程設置成可重複讀呢?

好比說,你們都用讀提交,但是邏輯備份的時候,mysqldump 爲何要把備份線程設置成可重複讀呢?(這個我在前面的文章中已經解釋過了,你能夠再回顧下第 6 篇文章《全
局鎖和表鎖 :給表加個字段怎麼有這麼多阻礙?》的內容)

而後,在備份期間,備份線程用的是可重複讀,而業務線程用的是讀提交。同時存在兩種事務隔離級別,會不會有問題?

進一步地,這兩個不一樣的隔離級別現象有什麼不同的,關於咱們的業務,「用讀提交就夠了」這個結論是怎麼獲得的?

若是業務開發和運維團隊這些問題都沒有弄清楚,那麼「沒問題」這個結論,自己就是有問題的。

5、小結

今天咱們從上一篇文章的課後問題提及,提到了全表掃描的加鎖方式。咱們發現即便給全部的行都加上行鎖,仍然沒法解決幻讀問題,所以引入了間隙鎖的概念。

我碰到過不少對數據庫有必定了解的業務開發人員,他們在設計數據表結構和業務 SQL 語句的時候,對行鎖有很準確的認識,但卻不多考慮到間隙鎖。最後的結果,就是生產庫上
會常常出現因爲間隙鎖致使的死鎖現象。

行鎖確實比較直觀,判斷規則也相對簡單,間隙鎖的引入會影響系統的併發度,也增長了鎖分析的複雜度,但也有章可循。下一篇文章,我就會爲你講解 InnoDB 的加鎖規則,幫
你理順這其中的「章法」。

做爲對下一篇文章的預習,我給你留下一個思考題。

圖 9 事務進入鎖等待狀態


若是你以前沒有了解過本篇文章的相關內容,必定以爲這三個語句簡直是風馬牛不相及。

但實際上,這裏 session B 和 session C 的 insert 語句都會進入鎖等待狀態。

你能夠試着分析一下,出現這種狀況的緣由是什麼?

這裏須要說明的是,這實際上是我在下一篇文章介紹加鎖規則後才能回答的問題,是留給你做爲預習的,其中 session C 被鎖住這個分析是有點難度的。若是你沒有分析出來,也不
要氣餒,我會在下一篇文章和你詳細說明。

你也能夠說說,你的線上 MySQL 配置的是什麼隔離級別,爲何會這麼配置?你有沒有碰到什麼場景,是必須使用可重複讀隔離級別的呢?

你能夠把你的碰到的場景和分析寫在留言區裏,我會在下一篇文章選取有趣的評論跟你們一塊兒分享和分析。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

6、上期問題時間

咱們在本文的開頭回答了上期問題。有同窗的回答中還說明了讀提交隔離級別下,在語句執行完成後,是隻有行鎖的。並且語句執行完成後,InnoDB 就會把不知足條件的行行鎖去掉。

固然了,c=5 這一行的行鎖,仍是會等到 commit 的時候才釋放的。

7、經典留言

一、AI杜嘉嘉

說真的,這一系列文章實用性真的很強,老師很是負責,想必牽扯到老師大量精力,但願老師再出好文章,謝謝您了,辛苦了

做者回復:

精力花了沒事,睡一覺醒來仍是一條好漢😄
主要仍是得你們有收穫,我就值了😄

二、沉浮

經過打印鎖日誌幫助理解問題鎖信息見括號裏的說明。

TABLE LOCK table `guo_test`.`t` trx id 105275 lock mode IX
RECORD LOCKS space id 31 page no 4 n bits 80 index c of table `guo_test`.`t` trx id 105275 lock_mode X
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 ----(Next-Key Lock,索引鎖c(5,10])
0: len 4; hex 8000000a; asc ;;
1: len 4; hex 8000000a; asc ;;

Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 ----(Next-Key Lock,索引鎖c (10,15])
0: len 4; hex 8000000f; asc ;;
1: len 4; hex 8000000f; asc ;;

Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 ----(Next-Key Lock,索引鎖c (15,20])
0: len 4; hex 80000014; asc ;;
1: len 4; hex 80000014; asc ;;

Record lock, heap no 7 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 ----(Next-Key Lock,索引鎖c (20,25])
0: len 4; hex 80000019; asc ;;
1: len 4; hex 80000019; asc ;;

RECORD LOCKS space id 31 page no 3 n bits 80 index PRIMARY of table `guo_test`.`t` trx id 105275 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
----(記錄鎖 鎖c=15對應的主鍵)
0: len 4; hex 8000000f; asc ;;
1: len 6; hex 0000000199e3; asc ;;
2: len 7; hex ca000001470134; asc G 4;;
3: len 4; hex 8000000f; asc ;;
4: len 4; hex 8000000f; asc ;;

Record lock, heap no 6 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 80000014; asc ;;
----(記錄鎖 鎖c=20對應的主鍵)
1: len 6; hex 0000000199e3; asc ;;
2: len 7; hex ca000001470140; asc G @;;
3: len 4; hex 80000014; asc ;;
4: len 4; hex 80000014; asc ;;

因爲字數限制,正序及無排序的日誌沒法帖出,倒序日誌比這二者,多了範圍(Next-Key Lock,索引鎖c(5,10]),
我的理解是,加鎖分兩次,

第一次,即正序的鎖,第二次爲倒序的鎖,即多出的(5,10],在RR隔離級別,
innodb在加鎖的過程當中會默認向後鎖一個記錄,加上Next-Key Lock,
第一次加鎖的時候10已經在範圍,因爲倒序,向後,即向5再加Next-key Lock,即多出的(5,10]範圍

做者回復:

優秀

三、kabuka

這樣,當你執行 select * from t where d=5 for update 的時候,就不止是給數據庫中已有的 6 個記錄加上了行鎖,還同時加了 還同時加了 7 個間隙鎖
---------------------------------------------------------------
老師這句話沒看太明白,數據庫只有一條d=5的記錄,為什麼會給6個記錄加上行鎖呢?

做者回復:

由於d上沒有索引,這個語句要走全表掃描

四、godtrue

課前思考
一、幻讀是什麼?幻讀有什麼問題?如何避免?
幻讀個人理解是,讀出的數據出現了不一致的現象,在事務的讀未提交和讀已提交這兩種事務的隔離級別下會出現幻讀的現象,問題嘛?就是數據不一致了,對於數據嚴格要求一致的場景是不可以容許的。如何避免?在可重複讀和串行化的事務隔離級別下應該不會出現
課後思考
1:學完此節後發現本身的認知,基本是錯的
1-1:什麼是幻讀?
幻讀是指在同一個事務中,存在先後兩次查詢同一個範圍的數據,可是第二次查詢卻看到了第一次查詢沒看到的行。
注意,幻讀出現的場景
第一:事務的隔離級別爲可重複讀,且是當前讀
第二:幻讀僅專指新插入的行
1-2:幻讀帶來的問題?
一是,對行鎖語義的破壞
二是,破壞了數據一致性
1-3:怎麼避免幻讀?
存儲引擎採用加間隙鎖的方式來避免出現幻讀
1-4:爲啥會出現幻讀?
行鎖只能鎖定存在的行,針對新插入的操做沒有限定
1-5:間隙鎖是啥?它怎麼避免出現幻讀的?它引入了什麼新的問題?間隙鎖,是專門用於解決幻讀這種問題的鎖,它鎖的了行與行之間的間隙,可以阻塞新插入的操做間隙鎖的引入也帶來了一些新的問題,好比:下降併發度,可能致使死鎖。注意,讀讀不互斥,讀寫/寫讀/寫寫是互斥的,可是間隙鎖之間是不衝突的,間隙鎖會阻塞插入操做另外,間隙鎖在可重複讀級別下才是有效的感謝老師的分享,意料以外的認知很好玩,也糾正了本身的認知誤差。感受本身明白了,看完評論,感受本身啥都不懂。

相關文章
相關標籤/搜索