為什么 MySQL 插入不存在的數據會產生死鎖?
mysql 的讀寫鎖這個話題,老難了。
水太深,容易把握不住。
看書吧犯困。
但面試又繞不過去。
今天我們面向一個例子學習,希望能讓大家提起一點興趣。
直接開始吧。
有這么一張表,建表 sql 如下。
CREATE TABLE `user` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', `name` varchar(100) NOT NULL DEFAULT '' COMMENT '名字', `phone_no` int(10) NOT NULL DEFAULT '0' COMMENT '電話號碼', PRIMARY KEY (`id`), KEY `idx_phone_no` (`phone_no`)) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
這里需要關注的是,電話號碼這一列,是加了普通索引的。
mysql 有四層隔離級別,應對不同的事務并發(fā)處理能力。
假設我們現在用的是可重復讀隔離級別。
當前數據表長這樣。
注意里面沒有 phone_no=2。
現在代碼里有這么一段邏輯。
select user where phone_no=2 for update // 查詢sqlif (user 存在) { return} else { insert user; // 插入sql}
邏輯比較簡單,就是去查一下 phone_no=2 的數據存不存在。不存在的話,就插入一條到數據庫里。
目的是保證 phnotallow=2 的數據唯一。
注意跟平時的 select 查詢不一樣,select 語句后面還有個 for update ,是為了對 phone_no=2 進行加鎖,不懂沒關系,待會還會提到。
現在有兩個線程同時并發(fā)跑上面的邏輯。
結果竟然提示死鎖。
Deadlock found when trying to get lock; try restarting transaction
為什么呢?
為了解釋這個問題,我們就從 mysql 鎖的話題開始聊起。
鎖
mysql 鎖的話題很大了,這個細細聊起來又是一篇長文了。
我們簡單說一下。mysql 中,我們現在用的引擎幾乎都是 innodb,我們以此為默認條件展開話題。
常見的鎖有兩種,一種是表鎖,一種是行鎖。
當你更新一條數據時,如果你不走索引,那會鎖表,否則,鎖行。
表鎖是在你什么索引都沒有的時候,為了保證數據一致性,迫不得已加上去的,鎖的對象當然就是整個數據表了。也就是說,你在對數據表加鎖之后,再對表進行讀寫操作,結束之后對表解鎖。在此期間,其他對這張表的寫操作都得等你操作完。
是的,干等,哪怕你操作的是第 1 行的數據,其他人要操作的是第 100 行的數據,也得干等。
為了提升效率,設計 mysql 的大佬們又把鎖的粒度給減小了,粒度從鎖表變成鎖行。
也就是說,你先鎖住這個行,在讀寫完這一行之后,再解鎖,期間其他人如果要操作這一行,那要等著,如果操作其他行,那就不用等了。這樣并發(fā)量就提上去了。
而加鎖除了 update, insert 這類寫類型的語句會加之外,還可以在 select 語句的最后加入for update,這樣也能加鎖。比如:
select * from user where phone_no =2 for update;
update 語句會加鎖比較好理解,就是你要更新某一行了,防止別人這時候也要更新,所以加鎖。后者 select for update 就是告訴別人,你讀的這一行接下來是要拿來做更新的操作的(for update),在你更新完成前,誰也不能更新它。
上面這種鎖,主要是為了寫的時候加入的,叫寫鎖,也就是 X 鎖。
寫鎖跟寫鎖之間是互斥的。意思是不能同時對某一行加兩個寫鎖,凡事講究先來后到,后面加寫鎖的線程會阻塞等待前面的線程解鎖完。
既然有寫鎖,當然有讀鎖,也叫 S 鎖。
像下面這樣在 select 語句后面加上lock in share mode,就能加入讀鎖。
select * from user where phone_no =2 lock in share mode;
讀鎖和讀鎖之間就不互斥,也就是兩個線程可以對同一行數據同時加讀鎖,不會阻塞。
死鎖
簡單解釋下死鎖,一個數據表里有那么多行,我們寫代碼的時候,會執(zhí)行各種 sql 語句,期間完全可以鎖住多行。
當一個線程先鎖 A 行,再鎖 B 行時,另外一個線程反過來,先鎖 B 行,再鎖 A 行。就有可能發(fā)生兩個線程在已經持有一個鎖的同時,死等對方持有的另外一個鎖釋放的情況。
雙方都想拿對方的鎖,且自己的鎖也死死不松手,邏輯就都跑不下去了,這就是死鎖。
間隙鎖
那么我們回到文章開頭的話題上。
如果我能保證,對 id=2 的那一行加鎖,寫結束前都不釋放,期間別人都沒法寫,這樣豈不是保證數據唯一了?
道理是這么個道理沒錯,但是現在的關鍵是,phone_no=2 這一行并不存在。
select user where phone_no=2 for update
這一行 sql 一執(zhí)行,牢牢鎖住了空氣?
開個玩笑。
是不是什么也沒鎖住,這個要看隔離級別了。
phone_no 是加了索引的,且因為數據庫索引里,數據是排好序的,phone_no=1 和 phone_no=3 都存在,他們之間沒有數據,如果有 phone_no=2 這條數據的話,那也理應出現在他們中間。
那么現在的問題是,有沒有辦法鎖住 1 和 3 之間的縫隙?
有的,有個間隙鎖,這個鎖,在讀未提交和讀已提交里都沒有,它在可重復讀這個隔離級別下被引入。
而且,間隙鎖和間隙鎖之間是不互斥的。
記住上面這句話,老關鍵了。
于是乎,我們回到文章開頭的問題里,這次我加上注釋。
線程 1在可重復讀這個隔離級別下,通過 for update ,可以在 1 和 3 之間,加上間隙鎖。
線程 2 也一樣,也在 1 和 3 之間加上間隙鎖,因為間隙鎖和間隙鎖之間是不互斥的,所以也能加鎖成功。
這時候線程 1 嘗試去插入數據,插入數據的時候也會加一個特殊的鎖,專業(yè)點,叫插入意向鎖。插入意向鎖跟間隙鎖是互斥的。
但由于線程 2 前面已經加過間隙鎖了。所以線程 1 會等線程 2 釋放間隙鎖。
但線程 2,不僅不釋放間隙鎖,反而又打算加一個寫鎖。
哦吼。
相當于兩個線程在持有一個鎖的同時,還等著對方釋放鎖。
這就妥妥死鎖了。
這下,文章開頭死鎖的問題,就解釋完了。
那么問題又來了。
為什么可重復讀要引入間隙鎖?
可重復讀最關鍵的一個點是,我開了一個事務,在這個事務里,不管我讀多少次,我讀到的數據都要是一樣的,這才是可重復讀。如果 mysql 不存在間隙鎖,那么就有可能出現下面的情況。
在一個事務里,讀多次數據,發(fā)現每次數據都不同。就好像出現幻覺一樣,所以又叫幻讀。
這就跟可重復讀的定義違背了。
通過加入間隙鎖,線程 1 在第一次執(zhí)行 select for update 后,線程 2 如果再嘗試去寫數據,就會被阻塞,直到線程 1 執(zhí)行 commit 后,線程 2 阻塞結束然后執(zhí)行 insert。
可重復讀隔離級別下,通過引入間隙鎖,是為了解決幻讀的問題。
總結
- ? mysql 鎖從粒度上分為行鎖和表鎖,從行為上又分為讀鎖和寫鎖,也就是 S 鎖和 X 鎖。
- ? 兩個線程在持有鎖的同時,又想等待對方把鎖釋放掉,則會發(fā)生死鎖。
- ? 兩個間隙鎖之間不會互斥。
- ? 在可重復讀隔離級別下,通過間隙鎖解決了幻讀。
參考資料
《MYSQL 內核:INNODB 存儲引擎 卷 1》
最后
這篇文章只是想通過一個例子講講鎖的內容。并不是希望通過這樣的方式來保證并發(fā)寫入唯一數據。
如果只是想在并發(fā)寫時保證數據唯一的話,加個唯一索引吧,別搞上面這些花里胡哨的。