天天看點

慎用,Mybatis-Plus這個方法可能導緻死鎖

作者:閃念基因
  • 1 場景還原
    • 1.1 版本資訊
    • 1.2 死鎖現象
  • 2 為什麼是間隙鎖死鎖?
    • 2.1 什麼是死鎖?
    • 2.2 什麼是間隙鎖?
    • 2.3 MySQL為什麼要引入間隙鎖?
    • 2.4 間隙鎖死鎖分析
    • 2.5 驗證間隙鎖死鎖
  • 3 如何解決?
    • 3.1 關閉間隙鎖(不推薦)
    • 3.2 自定義saveOrUpdate方法(推薦)
  • 4 拓展
    • 4.1 如果兩個事務同時修改存在的行會發生什麼?
  • 5 結語
慎用,Mybatis-Plus這個方法可能導緻死鎖

1 場景還原

1.1 版本資訊

MySQL版本:5.6.36-82.1-log 
Mybatis-Plus的starter版本:3.3.2
存儲引擎:InnoDB
           

1.2 死鎖現象

A同學在生産環境使用了Mybatis-Plus提供的 com.baomidou.mybatisplus.extension.service.IService#saveOrUpdate(T, com.baomidou.mybatisplus.core.conditions.Wrapper) 方法(以下簡稱B方法),并發場景下,資料庫報了如下錯誤

2 為什麼是間隙鎖死鎖?

如上圖示,資料庫報了死鎖,那死鎖場景千萬種,為什麼确定B方法是由于間隙鎖導緻的死鎖?

2.1 什麼是死鎖?

兩個事務互相等待對方持有的鎖,導緻互相阻塞,進而導緻死鎖。

2.2 什麼是間隙鎖?

  • 間隙鎖是MySQL行鎖的一種,與Record lock不同的是間隙鎖鎖定的是一個間隙。
  • 鎖定規則如下:

MySQL會向左找第一個比目前索引值小的值,向右找第一個比目前索引值大 的值(沒有則為正無窮),将此區間鎖住,進而阻止其他事務在此區間插入資料。

2.3 MySQL為什麼要引入間隙鎖?

與Record lock組合成Next-key lock,在可重複讀這種隔離級别下一起工作避免幻讀。

2.4 間隙鎖死鎖分析

理論上一款開源的架構,經過了多年打磨,提供的方法不應該造成如此嚴重的錯誤,但理論僅僅是理論上,事實就是發生了死鎖,于是我們開始了一輪深度排查。首先我們從這個方法的源碼入手,源碼如下:

default boolean saveOrUpdate(T entity, Wrapper<T> updateWrapper) {
        return this.update(entity, updateWrapper) || this.saveOrUpdate(entity);
    }
           

從源碼上看此方法就沒有按套路出牌,正常邏輯應該是首先執行查詢,存在則修改,不存在則新增,但此方法上來就執行了修改。我們就猜想是不是MySQL在修改時增加了什麼鎖導緻了死鎖,于是我們找到了DBA擷取了最新的死鎖日志,即執行show engine innodb status,我們發現了兩項關鍵資訊如下:

*** (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
           

簡單翻譯一下,就是事務一在擷取插入意向鎖時,需要等待間隙鎖(事務二添加)釋放,同時事務二在擷取插入意向鎖時,也在等待間隙鎖釋放(事務一添加),(本文不讨論MySQL在修改與插入時添加的鎖,我們把修改時添加間隙鎖,插入時擷取插入意向鎖為已知條件)那我們回到B方法,并發場景下,是不是就很大幾率會滿足事務一和事務二互相等待對方持有的間隙鎖,進而導緻死鎖。

現在我們理論有了,我們現在用真實資料來驗證此場景。

2.5 驗證間隙鎖死鎖

  • 準備如下表結構(以下簡稱驗證一)
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 '間隙鎖測試表';
           
  • 準備如下表資料
mysql> select * from t_gap_lock;
+----+------+-----+
| id | name | age |
+----+------+-----+
|  1 | 張三 |  18 |
|  5 | 李四 |  19 |
|  6 | 王五 |  20 |
|  9 | 趙六 |  21 |
| 12 | 孫七 |  22 |
+----+------+-----+
           
  • 我們開啟事務一,并執行如下語句,注意這個時候我們還沒有送出事務
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
           
  • 同時我們開啟事務二,并執行如下語句,事務二我們同樣不送出事務
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
           
  • 接下來我們在事務一中執行如下語句
mysql> insert into t_gap_lock(id, name, age) value (7,'間隙鎖7',27);  
           
  • 我們會發現事務一被阻塞了,然後我們執行以下語句看下目前正在鎖的事務。
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)
           

根據lock_type和lock_mode我們可以很清晰的看到鎖類型是行鎖,鎖模式是間隙鎖。

  • 與此同時我們在事務二中執行如下語句
insert into t_gap_lock(id, name, age) value (4,'間隙鎖4',24);
           
  • 一執行以上語句,資料庫就立馬報了死鎖,并且復原了事務二(可以在死鎖日志中看到*** WE ROLL BACK TRANSACTION (2))
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 
           

到這裡,細心的同學就會發現,诶,你這上面故意造了一個間隙,并且讓兩個事務分别在對方的間隙中插入資料,太刻意了,生産環境基本上不會有這種場景,是的,生産環境怎麼會有這種場景呢,上面的資料隻是為了讓大家直覺的看到間隙鎖的死鎖過程,接下來那我們再來一組資料,我們簡稱驗證二。

  • 我們還是以驗證一的表結構與資料,我們來執行這樣一個操作。首先我們開始開啟事務一并且執行如下操作,依然不送出事務
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 
           
  • 同時我們開啟事務二,執行與事務一一樣的操作,我們會驚奇的發現,竟然也成功了。
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 
           
  • 于是乎我們在事務一執行如下操作,我們又驚奇的發現事務一被阻塞了。
insert into t_gap_lock(id, name, age) value (4,'間隙鎖4',24);  
           
  • 在事務一被阻塞的同時,我們在事務二執行同樣的語句,我們發現資料庫立馬就報了死鎖。
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
           

驗證二完整的複現了線上死鎖的過程,也就是事務一先執行了更新語句,事務二在同一時刻也執行了更新語句,然後事務一發現沒有更新到就去執行主鍵查詢語句,發現确實沒有,是以執行了插入語句,但是插入要先擷取插入意向鎖,在擷取插入意向鎖的時候發現這個間隙已經被事務二加鎖了,是以事務一開始等待事務二釋放間隙鎖,同理,事務二也執行上述操作,最終導緻事務一與事務二互相等待對方釋放間隙鎖,最終導緻死鎖。

驗證二還說明了一個問題,就是間隙鎖加鎖是非互斥的,也就是事務一對間隙A加鎖後,事務二依然可以給間隙A加鎖。

3 如何解決?

3.1 關閉間隙鎖(不推薦)

  • 降低隔離級别,例如降為送出讀。
  • 直接修改my.cnf,将開關,innodb_locks_unsafe_for_binlog改為1,預設為0即開啟

PS:以上方法僅适用于目前業務場景确實不關心幻讀的問題。

3.2 自定義saveOrUpdate方法(推薦)

建議自己編寫一個saveOrUpdate方法,當然也可以直接采用Mybatis-Plus提供的saveOrUpdate方法,但是根據源碼發現,會有很多額外的反射操作,并且還添加了事務,大家都知道,MySQL單表操作完全不需要開事務,會增加額外的開銷。

@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 如果兩個事務同時修改存在的行會發生什麼?

在驗證二中兩個事務修改的都是不存在的行,都能加間隙鎖成功,那如果兩個事務修改的是存在的行,MySQL還會加間隙鎖嗎?或者說把間隙鎖從鎖間隙降為鎖一行?帶着疑問,我們執行以下資料驗證,我們還是使用驗證一的表和資料。

  • 首先我們開啟事務一執行以下語句
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
           
  • 我們再開啟事務二,執行同樣的語句,發現事務二已經被阻塞
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 1;
           
  • 這個時候我們執行以下語句看下目前正在鎖的事務。
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)
           

根據lock_type和lock_mode我們看到事務一和二加的鎖變成了Record Lock,并沒有再添加間隙鎖,根據以上資料驗證MySQL在修改存在的資料時會給行加上Record Lock,與間隙鎖不同的是該鎖是互斥的,即不同的事務不能同時對同一行記錄添加Record Lock。

5 結語

雖然Mybatis-Plus提供的這個方法可能會造成死鎖,但是依然不可否認它是一款非常優秀的增強架構,其提供的lambda寫法在日常工作中極大的提高了我們的開發效率,是以凡事都有兩面性,我們應該秉承辯證的态度,熟悉的方法嘗試用,陌生的方法謹慎用。

以上就是我們在生産環境間隙鎖死鎖分析的全過程,如果大家覺得本文讓你對間隙鎖,以及間隙鎖死鎖有一點的了解,别忘記一鍵三連,多多支援轉轉技術,轉轉技術在未來将會給大家帶來更多的生産實踐與探索。

作者簡介

謝星,轉轉金融技術部後端工程師。熱愛程式設計,熱愛分享,擁抱開源。

來源:微信公衆号:轉轉技術

出處:https://mp.weixin.qq.com/s/wgxZK09PKTyOHNnkaxyNqg