這篇文章為大家帶來了關於mysql中死鎖的相關問題,主要介紹了兩個一樣的INSERT語句引發死鎖的相關知識,希望對大家有幫助。
兩條一樣的INSERT語句竟然引發了死鎖,這究竟是人性的扭曲,還是道德的淪喪,讓我們不禁感嘆一句:臥槽!這也能死鎖,然後眼中含著悲催的淚水無奈的改起了業務代碼。
好的,在深入分析為啥兩條一樣的INSERT語句也會產生死鎖之前,我們先介紹一些基礎知識。
為了故事的順利發展,我們新建一個用了無數次的hero表:
CREATE TABLE hero ( number INT AUTO_INCREMENT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number), UNIQUE KEY uk_name (name) ) Engine=InnoDB CHARSET=utf8;
然後向這個表裡插入幾筆記錄:
INSERT INTO hero VALUES (1, 'l刘备', '蜀'), (3, 'z诸葛亮', '蜀'), (8, 'c曹操', '魏'), (15, 'x荀彧', '魏'), (20, 's孙权', '吴');
現在hero表就有了兩個索引(一個唯一二級索引,一個叢集索引),示意圖如下:
讀過《MySQL是怎麼運作的:根兒上理解MySQL》的小夥伴一定知道,INSERT語句在正常執行時是不會產生鎖定結構的,它是靠叢集索引記錄自帶的trx_id隱藏列來作為隱式鎖來保護記錄的。
但是在一些特殊場景下,INSERT語句還是會產生鎖定結構的,我們列舉一下:
1. 待插入記錄的下一筆記錄上已經被其他交易加了gap鎖定時
每插入一筆新記錄,都需要看一下待插入記錄的下一筆記錄上是否已經被加了gap鎖,如果已加gap鎖,那INSERT語句應該被阻塞,產生一個插入意向鎖。
比方說對於hero表來說,事務T1運行在REPEATABLE READ(後續簡稱為RR,後續也會把READ COMMITTED簡稱為RC)隔離等級中,執行了下邊的語句:
# 事务T1 mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> SELECT * FROM hero WHERE number < 8 FOR UPDATE; +--------+------------+---------+ | number | name | country | +--------+------------+---------+ | 1 | l刘备 | 蜀 | | 3 | z诸葛亮 | 蜀 | +--------+------------+---------+ 2 rows in set (0.02 sec)
這條語句會對主鍵值為1、3、8的這3筆記錄都添加X型next-key鎖,不信的話我們使用SHOW ENGINE INNODB STATUS語句看一下加鎖情況,圖中箭頭指向的記錄就是number值為8的記錄:
小貼士:
至於SELECT、DELETE、UPDATE語句如何加鎖,我們已經在之前的文章中分析過了,這裡就不再贅述了。
此時事務T2想插入一條主鍵值為4的叢集索引記錄,那麼T2在插入記錄前,首先要定位一下主鍵值為4的叢集索引記錄在頁面中的位置,發現主鍵值為4的下一記錄的主鍵值是8,而主鍵值是8的聚簇索引記錄已經被添加了gap鎖(next-key鎖包含了正經記錄鎖和gap鎖),那麼事務1就需要進入阻塞狀態,並產生一個類型為插入意向鎖的鎖結構。
我們在交易T2中執行一下INSERT語句驗證一下:
mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> INSERT INTO hero VALUES(4, 'g关羽', '蜀');
此時T2進入阻塞狀態,我們再使用SHOW ENGINE INNODB STATUS看一下加鎖狀況:
可見T2對主鍵值為8的叢集索引記錄加上了插入意圖鎖定(就是箭頭處指向的lock_mode X locks gap before rec insert intention),並且處在waiting狀態。
好了,驗證過之後,我們再來看看程式碼裡是如何實現的:
lock_rec_insert_check_and_lock函數用來看別的交易是否阻止本次INSERT插入,如果是,那麼本事務就為被別的事務添加了gap鎖的記錄產生一個插入意向鎖,具體過程如下:
小秘訣:
lock_rec_other_has_conflicting函數用來偵測本次要取得的鎖和記錄上已有的鎖是否有衝突,有興趣的同學可以看一下。
2. 遇到重複鍵時
#如果在插入新記錄時,發現頁面中已有的記錄的主鍵或者唯一二級索引列與待插入記錄的主鍵或唯一二級索引列值相同(不過可以有多筆記錄的唯一二級索引列的值同時為NULL,這裡不考慮這種情況了) ,此時插入新記錄的交易會取得頁面中已存在的鍵值相同的記錄的鎖定。
如果是主鍵值重複,那麼:
當隔離等級不大於RC時,插入新記錄的交易會給已存在的主鍵值重複的叢集索引記錄新增S型正經記錄鎖定。
當隔離等級不小於RR時,插入新記錄的交易會為已存在的主鍵值重複的叢集索引記錄新增S型next-key鎖定。
如果是唯一二级索引列重复,那不论是哪个隔离级别,插入新记录的事务都会给已存在的二级索引列值重复的二级索引记录添加S型next-key锁,再强调一遍,加的是next-key锁!加的是next-key锁!加的是next-key锁!这是rc隔离级别中为数不多的给记录添加gap锁的场景。
小贴士:
本来设计InnoDB的大叔并不想在RC隔离级别引入gap锁,但是由于某些原因,如果不添加gap锁的话,会让唯一二级索引中出现多条唯一二级索引列值相同的记录,这就违背了UNIQUE约束。所以后来设计InnoDB的大叔就很不情愿的在RC隔离级别也引入了gap锁。
我们也来做一个实验,现在假设上边的T1和T2都回滚了,现在将隔离级别调至RC,重新开启事务进行测试。
mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; Query OK, 0 rows affected (0.01 sec) # 事务T1 mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> INSERT INTO hero VALUES(30, 'x荀彧', '魏'); ERROR 1062 (23000): Duplicate entry 'x荀彧' for key 'uk_name'
然后执行SHOW ENGINE INNODB STATUS语句看一下T1加了什么锁:
可以看到即使现在T1的隔离级别为RC,T1仍然给name列值为'x荀彧'的二级索引记录添加了S型next-key锁(图中红框中的lock mode S)。
如果我们的INSERT语句还带有ON DUPLICATE KEY... 这样的子句,如果遇到主键值或者唯一二级索引列值重复的情况,会对B+树中已存在的相同键值的记录加X型锁,而不是S型锁(不过具体锁的具体类型是和前面描述一样的)。
好了,又到了看代码求证时间了,我们看一下吧:
row_ins_scan_sec_index_for_duplicate是检测唯一二级索引列值是否重复的函数,具体加锁的代码如下所示:
如上图所示,在遇到唯一二级索引列重复的情况时:
1号红框表示对带有ON DUPLICATE ...子句时的处理方案,具体就是添加X型锁。
2号红框表示对正常INSERT语句的处理方案,具体就是添加S型锁。
不过不论是那种情况,添加的lock_typed的值都是LOCK_ORDINARY,表示next-key锁。
在主键重复时INSERT语句的加锁代码我们就不列举了。
3. 外键检查时
当我们向子表中插入记录时,我们分两种情况讨论:
当子表中的外键值可以在父表中找到时,那么无论当前事务是什么隔离级别,只需要给父表中对应的记录添加一个S型正经记录锁就好了。
当子表中的外键值在父表中找不到时:那么如果当前隔离级别不大于RC时,不对父表记录加锁;当隔离级别不小于RR时,对父表中该外键值所在位置的下一条记录添加gap锁。
好了,基础知识预习完了,该死锁出场了。
看下边这个平平无奇的INSERT语句:
INSERT INTO hero(name, country) VALUES('g关羽', '蜀'), ('d邓艾', '魏');
这个语句用来插入两条记录,不论是在RC,还是RR隔离级别,如果两个事务并发执行它们是有一定几率触发死锁的。为了稳定复现这个死锁,我们把上边一条语句拆分成两条语句:
INSERT INTO hero(name, country) VALUES('g关羽', '蜀'); INSERT INTO hero(name, country) VALUES('d邓艾', '魏');
拆分前和拆分后起到的作用是相同的,只不过拆分后我们可以人为的控制插入记录的时机。如果T1和T2的执行顺序是这样的:
也就是:
T1先插入name值为g关羽的记录,可以插入成功,此时对应的唯一二级索引记录被隐式锁保护,我们执行SHOW ENGINE INNODB STATUS语句,发现啥一个行锁(row lock)都没有(因为SHOW ENGINE INNODB STATUS不显示隐式锁):
接着T2也插入name值为g关羽的记录。由于T1已经插入name值为g关羽的记录,所以T2在插入二级索引记录时会遇到重复的唯一二级索引列值,此时T2想获取一个S型next-key锁,但是T1并未提交,T1插入的name值为g关羽的记录上的隐式锁相当于一个X型正经记录锁(RC隔离级别),所以T2向获取S型next-key锁时会遇到锁冲突,T2进入阻塞状态,并且将T1的隐式锁转换为显式锁(就是帮助T1生成一个正经记录锁的锁结构)。这时我们再执行SHOW ENGINE INNODB STATUS语句:
可见,T1持有的name值为g关羽的隐式锁已经被转换为显式锁(X型正经记录锁,lock_mode X locks rec but not gap);T2正在等待获取一个S型next-key锁(lock mode S waiting)。
接着T1再插入一条name值为d邓艾的记录。在插入一条记录时,会在页面中先定位到这条记录的位置。在插入name值为d邓艾的二级索引记录时,发现现在页面中的记录分布情况如下所示:
很显然,name值为'd邓艾'的二级索引记录所在位置的下一条二级索引记录的name值应该是'g关羽'(按照汉语拼音排序)。那么在T1插入name值为d邓艾的二级索引记录时,就需要看一下name值为'g关羽'的二级索引记录上有没有被别的事务加gap锁。
有同学想说:目前只有T2想在name值为'g关羽'的二级索引记录上添加S型next-key锁(next-key锁包含gap锁),但是T2并没有获取到锁呀,目前正在等待状态。那么T1不是能顺利插入name值为'g关羽'的二级索引记录么?
我们看一下执行结果:
# 事务T2 mysql> INSERT INTO hero(name, country) VALUES('g关羽', '蜀'); ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
很显然,触发了一个死锁,T2被InnoDB回滚了。
这是为啥呢?T2明明没有获取到name值为'g关羽'的二级索引记录上的S型next-key锁,为啥T1还不能插入入name值为d邓艾的二级索引记录呢?
这我们还得回到代码上来,看一下插入新记录时是如何判断锁是否冲突的:
看一下画红框的注释,意思是:只要别的事务生成了一个显式的gap锁的锁结构,不论那个事务已经获取到了该锁(granted),还是正在等待获取(waiting),当前事务的INSERT操作都应该被阻塞。
回到我们的例子中来,就是T2已经在name值为'g关羽'的二级索引记录上生成了一个S型next-key锁的锁结构,虽然T2正在阻塞(尚未获取锁),但是T1仍然不能插入name值为d邓艾的二级索引记录。
这样也就解释了死锁产生的原因:
T1在等待T2释放name值为'g关羽'的二级索引记录上的gap锁。
T2在等待T1释放name值为'g关羽'的二级索引记录上的X型正经记录锁。
两个事务相互等待对方释放锁,这样死锁也就产生了。
两个方案:
方案一:一个事务中只插入一条记录。
方案二:先插入name值为'd邓艾'的记录,再插入name值为'g关羽'的记录
推荐学习:mysql视频教程
以上是一起聊聊兩條INSERT語句引發的死鎖的詳細內容。更多資訊請關注PHP中文網其他相關文章!