背景
生產環境出現MySQL死鎖異常,MySQL版本5.6,隔離級別 RC。
[CommandConsumer-pool-thread-1] Process error :
org.springframework.dao.DeadlockLoserDataAccessException:
### Error querying database. Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
### The error may exist in class path resource [mybatis/mapper/sequence.xml]
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: SELECT current_seq FROM sequence WHERE type = ? AND `date` = ? FOR UPDATE
### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
複製程式碼
程式碼分析
根據日誌記錄,導致死鎖的關鍵程式碼如下
/**
* 根據傳入引數,生成一個序列號。
*
* @param type 序列號型別
* @param date 時間
* @return 一個新的序列號,第一次呼叫返回1,後續根據呼叫次數遞增。
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
public int getSequence(String type, LocalDate date) {
// select * from sequence where type = #{type} and date = #{date} for update
Sequence seq = mapper.selectForUpdate(type, date);
// seq 還未初始化,select for update 就沒鎖住
if (seq == null) {
// insert ignore into sequence(type, date, current_seq) values(#{type}, #{date}, #{currentSeq})
if (mapper.insertIgnore(type, date, 1)) {
return 1;
}
// insert ignore 競爭失敗,重試
return getSequence(type, date);
}
// update sequence set current_seq = current_seq + 1 where id = #{id}
mapper.forwardSeq(seq.getId(), 1);
return seq.getCurrentSeq() + 1;
}
CREATE TABLE `sequence` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`type` varchar(32) NOT NULL COMMENT '型別',
`date` date NOT NULL COMMENT '時間',
`current_seq` int(11) NOT NULL COMMENT '當前最大序號',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_seq` (`date`,`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='序列號'
複製程式碼
功能簡述 這段程式碼主要實現了一個序列號的獲取功能,這種功能常用於生成單據號。 舉個例子:我們需要給每個付款單生成一個付款單號,格式為:A-20200101,表示A公司在20200101這一天的付款單。 但是A公司每天不止一個付款單,為了保證付款單號的唯一性,我們還需要加一個自增的序列號。例如:A-20200101-1,表示A在2020-01-01這天的第一個付款單,以此類推,第二個、第三個付款單號即 A-20200101-2,A-20200101-3...
程式碼實現 為了保證在併發環境下,序列號不會重複,程式碼裡先通過 select 唯一索引 for update 鎖住某一行資料,然後更新該行資料的current_seq = current_seq + 1,返回current_seq。
但有一個邊界條件需要特殊處理,那就是第一次呼叫該函式時,資料還不存在, select 唯一索引 for update 返回 null ,需要 insert 一個序列號為1的初始資料,為了防止 for update 返回 null 沒鎖住導致多次 insert ,程式碼裡用了 insert ignore,當 insert ignore 失敗時重新呼叫(遞迴) getSequence 獲取下一個序列號。
看完程式碼,並沒有發現明顯異常,我們嘗試在本地復現一下死鎖。
本地復現死鎖:
手動復現:
- 準備條件
- MySQL 5.6
- 事務隔離級別 RC
- 準備兩個資料庫連線 A、B
- 通過觀察SQL日誌,並經過多次實驗,發現以下兩種操作可以復現死鎖
- 操作步驟1
- A begin; insert (ignore) xxx; 執行失敗,因為xxx已存在。
- B begin; select xxx for update; 阻塞,因為A insert 已持有鎖
- A select xxx for update; 成功
- B 阻塞結束,提示死鎖
- 操作步驟2
- A begin; select xxx for update; 成功執行,持有排他鎖
- B begin; select xxx for update; 阻塞,等待A釋放排他鎖
- A insert (ignore) xxx; 成功執行
- B 阻塞結束,提示死鎖
- 觸發死鎖操作的共性
- 都是某資料已存在,某事務內通過 insert 拿到鎖再去操作 select for update,或通過 select for update 拿到鎖再去操作 insert,就會造成其他 for update 等待鎖的事務提示死鎖。
- 死鎖原理
- 尚不明確(路過的朋友有知道的還請賜教)
單元測試復現:
@Autowired
private ISequenceService sequenceService;
@Test
public void test() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
List<Runnable> runnableList = Lists.newLinkedList();
for (int i = 0; i < 100; i++) {
runnableList.add(() -> sequenceService.getSequence("TX", LocalDate.now()));
}
runnableList.forEach(executorService::execute);
executorService.shutdown();
executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
}
複製程式碼
解決方案
- 通過本地手動復現死鎖,我們發現當在一個事務中 insert ignore 失敗後 select for update ,才會出現死鎖,那麼避免兩個操作在同一個事務出現即可。
- 更改後程式碼
/**
* 根據傳入引數,生成一個序列號。
*
* @param type 序列號型別
* @param date 時間
* @return 一個新的序列號,第一次呼叫返回1,後續根據呼叫次數遞增。
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
public int getSequence(String type, LocalDate date) {
// select * from sequence where type = #{type} and date = #{date} for update
Sequence seq = mapper.selectForUpdate(type, date);
// seq 還未初始化,select for update 就沒鎖住
if (seq == null) {
// insert ignore into sequence(type, date, current_seq) values(#{type}, #{date}, #{currentSeq})
if (mapper.insertIgnore(type, date, 1)) {
return 1;
}
// insert ignore 競爭失敗,在一個新事務中重試,從而避免死鎖
return applicationContext.getBean(ISequenceService.class).getSequence(type, date);
}
// update sequence set current_seq = current_seq + 1 where id = #{id}
mapper.forwardSeq(seq.getId(), 1);
return seq.getCurrentSeq() + 1;
}
複製程式碼
- 經過單元測試驗證,成功解決死鎖問題。
總結
- 在帶多個鎖的方法中使用遞迴時,要特別注意,容易造成加多個鎖順序不一致的問題,從而引發死鎖(跟本例無關,純屬感想)。
- 在一個事務中 select for update 鎖住某行資料後,再 insert (ignore) 這行資料,出現死鎖,這個可以理解,因為一般不會有邏輯 select 查出來資料了,還 insert 。但是我們無意中會寫出 insert ignore 失敗後 select for update 這種程式碼,而它跟 select for update 後 insert 在加鎖的原理上是基本一致的,會造成死鎖,所以日常寫程式碼要注意這一點。