慎用,Mybatis-Plus這個(gè)方法可能導(dǎo)致死鎖
1 場(chǎng)景還原
1.1 版本信息
MySQL版本:5.6.36-82.1-log
Mybatis-Plus的starter版本:3.3.2
存儲(chǔ)引擎:InnoDB
1.2 死鎖現(xiàn)象
A同學(xué)在生產(chǎn)環(huán)境使用了Mybatis-Plus提供的 com.baomidou.mybatisplus.extension.service.IService#saveOrUpdate(T, com.baomidou.mybatisplus.core.conditions.Wrapper) 方法(以下簡(jiǎn)稱B方法),并發(fā)場(chǎng)景下,數(shù)據(jù)庫報(bào)了如下錯(cuò)誤
2 為什么是間隙鎖死鎖?
如上圖示,數(shù)據(jù)庫報(bào)了死鎖,那死鎖場(chǎng)景千萬種,為什么確定B方法是由于間隙鎖導(dǎo)致的死鎖?
2.1 什么是死鎖?
兩個(gè)事務(wù)互相等待對(duì)方持有的鎖,導(dǎo)致互相阻塞,從而導(dǎo)致死鎖。
2.2 什么是間隙鎖?
- 間隙鎖是MySQL行鎖的一種,與Record lock不同的是間隙鎖鎖定的是一個(gè)間隙。
- 鎖定規(guī)則如下:
MySQL會(huì)向左找第一個(gè)比當(dāng)前索引值小的值,向右找第一個(gè)比當(dāng)前索引值大 的值(沒有則為正無窮),將此區(qū)間鎖住,從而阻止其他事務(wù)在此區(qū)間插入數(shù)據(jù)。
2.3 MySQL為什么要引入間隙鎖?
與Record lock組合成Next-key lock,在可重復(fù)讀這種隔離級(jí)別下一起工作避免幻讀。
2.4 間隙鎖死鎖分析
理論上一款開源的框架,經(jīng)過了多年打磨,提供的方法不應(yīng)該造成如此嚴(yán)重的錯(cuò)誤,但理論僅僅是理論上,事實(shí)就是發(fā)生了死鎖,于是我們開始了一輪深度排查。首先我們從這個(gè)方法的源碼入手,源碼如下:
default boolean saveOrUpdate(T entity, Wrapper<T> updateWrapper) {
return this.update(entity, updateWrapper) || this.saveOrUpdate(entity);
}
從源碼上看此方法就沒有按套路出牌,正常邏輯應(yīng)該是首先執(zhí)行查詢,存在則修改,不存在則新增,但此方法上來就執(zhí)行了修改。我們就猜想是不是MySQL在修改時(shí)增加了什么鎖導(dǎo)致了死鎖,于是我們找到了DBA獲取了最新的死鎖日志,即執(zhí)行show engine innodb status,我們發(fā)現(xiàn)了兩項(xiàng)關(guān)鍵信息如下:
*** (1) TRANSACTION:
...省略日志
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 347 n bits 80 index `PRIMARY` of table `database_name`.`table_name` trx id 71C lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
...省略日志
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 347 n bits 80 index `PRIMARY` of table `database_name`.`table_name` trx id 71D lock_mode X locks gap before rec insert intention waiting
簡(jiǎn)單翻譯一下,就是事務(wù)一在獲取插入意向鎖時(shí),需要等待間隙鎖(事務(wù)二添加)釋放,同時(shí)事務(wù)二在獲取插入意向鎖時(shí),也在等待間隙鎖釋放(事務(wù)一添加),(本文不討論MySQL在修改與插入時(shí)添加的鎖,我們把修改時(shí)添加間隙鎖,插入時(shí)獲取插入意向鎖為已知條件)那我們回到B方法,并發(fā)場(chǎng)景下,是不是就很大幾率會(huì)滿足事務(wù)一和事務(wù)二相互等待對(duì)方持有的間隙鎖,從而導(dǎo)致死鎖。
現(xiàn)在我們理論有了,我們現(xiàn)在用真實(shí)數(shù)據(jù)來驗(yàn)證此場(chǎng)景。
2.5 驗(yàn)證間隙鎖死鎖
- 準(zhǔn)備如下表結(jié)構(gòu)(以下簡(jiǎn)稱驗(yàn)證一)
create table t_gap_lock(
id int auto_increment primary key comment '主鍵ID',
name varchar(64) not null comment '名稱',
age int not null comment '年齡'
) comment '間隙鎖測(cè)試表';
- 準(zhǔn)備如下表數(shù)據(jù)
mysql> select * from t_gap_lock;
+----+------+-----+
| id | name | age |
+----+------+-----+
| 1 | 張三 | 18 |
| 5 | 李四 | 19 |
| 6 | 王五 | 20 |
| 9 | 趙六 | 21 |
| 12 | 孫七 | 22 |
+----+------+-----+
- 我們開啟事務(wù)一,并執(zhí)行如下語句,注意這個(gè)時(shí)候我們還沒有提交事務(wù)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
- 同時(shí)我們開啟事務(wù)二,并執(zhí)行如下語句,事務(wù)二我們同樣不提交事務(wù)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 7;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
- 接下來我們?cè)谑聞?wù)一中執(zhí)行如下語句
mysql> insert into t_gap_lock(id, name, age) value (7,'間隙鎖7',27);
- 我們會(huì)發(fā)現(xiàn)事務(wù)一被阻塞了,然后我們執(zhí)行以下語句看下當(dāng)前正在鎖的事務(wù)。
mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS \G;
*************************** 1. row ***************************
lock_id: 749:0:360:3
lock_trx_id: 749
lock_mode: X,GAP
lock_type: RECORD
lock_table: `test`.`t_gap_lock`
lock_index: `PRIMARY`
lock_space: 0
lock_page: 360
lock_rec: 3
lock_data: 5
*************************** 2. row ***************************
lock_id: 74A:0:360:3
lock_trx_id: 74A
lock_mode: X,GAP
lock_type: RECORD
lock_table: `test`.`t_gap_lock`
lock_index: `PRIMARY`
lock_space: 0
lock_page: 360
lock_rec: 3
lock_data: 5
2 rows in set (0.00 sec)
根據(jù)lock_type和lock_mode我們可以很清晰的看到鎖類型是行鎖,鎖模式是間隙鎖。
- 與此同時(shí)我們?cè)谑聞?wù)二中執(zhí)行如下語句
insert into t_gap_lock(id, name, age) value (4,'間隙鎖4',24);
- 一執(zhí)行以上語句,數(shù)據(jù)庫就立馬報(bào)了死鎖,并且回滾了事務(wù)二(可以在死鎖日志中看到*** WE ROLL BACK TRANSACTION (2))
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
到這里,細(xì)心的同學(xué)就會(huì)發(fā)現(xiàn),誒,你這上面故意造了一個(gè)間隙,并且讓兩個(gè)事務(wù)分別在對(duì)方的間隙中插入數(shù)據(jù),太刻意了,生產(chǎn)環(huán)境基本上不會(huì)有這種場(chǎng)景,是的,生產(chǎn)環(huán)境怎么會(huì)有這種場(chǎng)景呢,上面的數(shù)據(jù)只是為了讓大家直觀的看到間隙鎖的死鎖過程,接下來那我們?cè)賮硪唤M數(shù)據(jù),我們簡(jiǎn)稱驗(yàn)證二。
- 我們還是以驗(yàn)證一的表結(jié)構(gòu)與數(shù)據(jù),我們來執(zhí)行這樣一個(gè)操作。首先我們開始開啟事務(wù)一并且執(zhí)行如下操作,依然不提交事務(wù)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
- 同時(shí)我們開啟事務(wù)二,執(zhí)行與事務(wù)一一樣的操作,我們會(huì)驚奇的發(fā)現(xiàn),竟然也成功了。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
- 于是乎我們?cè)谑聞?wù)一執(zhí)行如下操作,我們又驚奇的發(fā)現(xiàn)事務(wù)一被阻塞了。
insert into t_gap_lock(id, name, age) value (4,'間隙鎖4',24);
- 在事務(wù)一被阻塞的同時(shí),我們?cè)谑聞?wù)二執(zhí)行同樣的語句,我們發(fā)現(xiàn)數(shù)據(jù)庫立馬就報(bào)了死鎖。
insert into t_gap_lock(id, name, age) value (4,'間隙鎖4',24);
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
驗(yàn)證二完整的復(fù)現(xiàn)了線上死鎖的過程,也就是事務(wù)一先執(zhí)行了更新語句,事務(wù)二在同一時(shí)刻也執(zhí)行了更新語句,然后事務(wù)一發(fā)現(xiàn)沒有更新到就去執(zhí)行主鍵查詢語句,發(fā)現(xiàn)確實(shí)沒有,所以執(zhí)行了插入語句,但是插入要先獲取插入意向鎖,在獲取插入意向鎖的時(shí)候發(fā)現(xiàn)這個(gè)間隙已經(jīng)被事務(wù)二加鎖了,所以事務(wù)一開始等待事務(wù)二釋放間隙鎖,同理,事務(wù)二也執(zhí)行上述操作,最終導(dǎo)致事務(wù)一與事務(wù)二互相等待對(duì)方釋放間隙鎖,最終導(dǎo)致死鎖。
驗(yàn)證二還說明了一個(gè)問題,就是間隙鎖加鎖是非互斥的,也就是事務(wù)一對(duì)間隙A加鎖后,事務(wù)二依然可以給間隙A加鎖。
3 如何解決?
3.1 關(guān)閉間隙鎖(不推薦)
- 降低隔離級(jí)別,例如降為提交讀。
- 直接修改my.cnf,將開關(guān),innodb_locks_unsafe_for_binlog改為1,默認(rèn)為0即開啟
PS:以上方法僅適用于當(dāng)前業(yè)務(wù)場(chǎng)景確實(shí)不關(guān)心幻讀的問題。
3.2 自定義saveOrUpdate方法(推薦)
建議自己編寫一個(gè)saveOrUpdate方法,當(dāng)然也可以直接采用Mybatis-Plus提供的saveOrUpdate方法,但是根據(jù)源碼發(fā)現(xiàn),會(huì)有很多額外的反射操作,并且還添加了事務(wù),大家都知道,MySQL單表操作完全不需要開事務(wù),會(huì)增加額外的開銷。
@Transactional(
rollbackFor = {Exception.class}
)
public boolean saveOrUpdate(T entity) {
if (null == entity) {
return false;
} else {
Class<?> cls = entity.getClass();
TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!", new Object[0]);
String keyProperty = tableInfo.getKeyProperty();
Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!", new Object[0]);
Object idVal = ReflectionKit.getFieldValue(entity, tableInfo.getKeyProperty());
return !StringUtils.checkValNull(idVal) && !Objects.isNull(this.getById((Serializable)idVal)) ? this.updateById(entity) : this.save(entity);
}
}
4 拓展
4.1 如果兩個(gè)事務(wù)同時(shí)修改存在的行會(huì)發(fā)生什么?
在驗(yàn)證二中兩個(gè)事務(wù)修改的都是不存在的行,都能加間隙鎖成功,那如果兩個(gè)事務(wù)修改的是存在的行,MySQL還會(huì)加間隙鎖嗎?或者說把間隙鎖從鎖間隙降為鎖一行?帶著疑問,我們執(zhí)行以下數(shù)據(jù)驗(yàn)證,我們還是使用驗(yàn)證一的表和數(shù)據(jù)。
- 首先我們開啟事務(wù)一執(zhí)行以下語句
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
- 我們?cè)匍_啟事務(wù)二,執(zhí)行同樣的語句,發(fā)現(xiàn)事務(wù)二已經(jīng)被阻塞
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t_gap_lock t set t.age = 25 where t.id = 1;
- 這個(gè)時(shí)候我們執(zhí)行以下語句看下當(dāng)前正在鎖的事務(wù)。
mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS \G;
*************************** 1. row ***************************
lock_id: 75C:0:360:2
lock_trx_id: 75C
lock_mode: X
lock_type: RECORD
lock_table: `test`.`t_gap_lock`
lock_index: `PRIMARY`
lock_space: 0
lock_page: 360
lock_rec: 2
lock_data: 1
*************************** 2. row ***************************
lock_id: 75B:0:360:2
lock_trx_id: 75B
lock_mode: X
lock_type: RECORD
lock_table: `test`.`t_gap_lock`
lock_index: `PRIMARY`
lock_space: 0
lock_page: 360
lock_rec: 2
lock_data: 1
2 rows in set (0.00 sec)
根據(jù)lock_type和lock_mode我們看到事務(wù)一和二加的鎖變成了Record Lock,并沒有再添加間隙鎖,根據(jù)以上數(shù)據(jù)驗(yàn)證MySQL在修改存在的數(shù)據(jù)時(shí)會(huì)給行加上Record Lock,與間隙鎖不同的是該鎖是互斥的,即不同的事務(wù)不能同時(shí)對(duì)同一行記錄添加Record Lock。
5 結(jié)語
雖然Mybatis-Plus提供的這個(gè)方法可能會(huì)造成死鎖,但是依然不可否認(rèn)它是一款非常優(yōu)秀的增強(qiáng)框架,其提供的lambda寫法在日常工作中極大的提高了我們的開發(fā)效率,所以凡事都有兩面性,我們應(yīng)該秉承辯證的態(tài)度,熟悉的方法嘗試用,陌生的方法謹(jǐn)慎用。
以上就是我們?cè)谏a(chǎn)環(huán)境間隙鎖死鎖分析的全過程,如果大家覺得本文讓你對(duì)間隙鎖,以及間隙鎖死鎖有一點(diǎn)的了解,別忘記一鍵三連,多多支持轉(zhuǎn)轉(zhuǎn)技術(shù),轉(zhuǎn)轉(zhuǎn)技術(shù)在未來將會(huì)給大家?guī)砀嗟纳a(chǎn)實(shí)踐與探索。
作者簡(jiǎn)介
謝星,轉(zhuǎn)轉(zhuǎn)金融技術(shù)部后端工程師。熱愛編程,熱愛分享,擁抱開源。