作者 | 小牛
Java 工程师,关注服务端技术
通过观察生产环境日志发现了一个数据库死锁问题。下面是问题排查过程的记录。
项目使用的是 MySql 数据库,版本为 5.7.24-log,使用默认存储引擎 InnoDB,默认事务隔离级别:Repeatable Read(可重复读),相关数据表如下:
CREATE TABLE `bookingproperty` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`bookingId` int(11) NOT NULL COMMENT '预约Id',
`bookingType` tinyint(4) NOT NULL COMMENT '预约类型',
`type` tinyint(4) NOT NULL COMMENT '属性类型',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '属性名',
`value` varchar(500) NOT NULL DEFAULT '' COMMENT '属性值',
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unqBookingIdTypeName` (`bookingId`,`bookingType`,`name`)
) ENGINE=InnoDB AUTO_INCREMENT=615 DEFAULT CHARSET=utf8mb4 COMMENT='预约属性';复制
一、死锁原因
1.1 死锁日志分析
通过下面的语句查看死锁日志:
show engine innodb status;
复制
日志如下:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-12-29 16:20:38 0x700002de5000
*** (1) TRANSACTION:
TRANSACTION 208825, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 947, OS thread handle 123145350713344, query id 40607 localhost 127.0.0.1 root update
insert into `clinic`.`bookingproperty` (`bookingId`, `bookingType`, `type`, `name`, `value`) values (99, 0, 0, 'INSERT_TEST', '0') on duplicate key update `clinic`.`bookingproperty`.`bookingId` = 99, `clinic`.`bookingproperty`.`bookingType` = 0, `clinic`.`bookingproperty`.`type` = 0, `clinic`.`bookingproperty`.`name` = 'INSERT_TEST', `clinic`.`bookingproperty`.`value` = '0'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1196 page no 4 n bits 624 index unqBookingIdTypeName of table `clinic`.`bookingproperty` trx id 208825 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 156 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 800000ca; asc ;;
1: len 1; hex 82; asc ;;
2: len 21; hex 564552494649434154494f4e5f53484545545f4944; asc VERIFICATION_SHEET_ID;;
3: len 4; hex 80000032; asc 2;;
*** (2) TRANSACTION:
TRANSACTION 208824, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 1
MySQL thread id 946, OS thread handle 123145350434816, query id 40601 localhost 127.0.0.1 root update
insert into `clinic`.`bookingproperty` (`bookingId`, `bookingType`, `type`, `name`, `value`) values (98, 0, 0, 'INSERT_TEST', '0') on duplicate key update `clinic`.`bookingproperty`.`bookingId` = 98, `clinic`.`bookingproperty`.`bookingType` = 0, `clinic`.`bookingproperty`.`type` = 0, `clinic`.`bookingproperty`.`name` = 'INSERT_TEST', `clinic`.`bookingproperty`.`value` = '0'
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1196 page no 4 n bits 624 index unqBookingIdTypeName of table `clinic`.`bookingproperty` trx id 208824 lock_mode X locks gap before rec
Record lock, heap no 156 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 800000ca; asc ;;
1: len 1; hex 82; asc ;;
2: len 21; hex 564552494649434154494f4e5f53484545545f4944; asc VERIFICATION_SHEET_ID;;
3: len 4; hex 80000032; asc 2;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1196 page no 4 n bits 624 index unqBookingIdTypeName of table `clinic`.`bookingproperty` trx id 208824 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 156 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 800000ca; asc ;;
1: len 1; hex 82; asc ;;
2: len 21; hex 564552494649434154494f4e5f53484545545f4944; asc VERIFICATION_SHEET_ID;;
3: len 4; hex 80000032; asc 2;;
*** WE ROLL BACK TRANSACTION (1)复制
通过死锁日志获取信息如下:
(1) TRANSACTION:
内容得知事务 1 的语句为:
insert into `clinic`.`bookingproperty` (`bookingId`, `bookingType`, `type`, `name`, `value`) values (99, 0, 0, 'INSERT_TEST', '0') on duplicate key update `clinic`.`bookingproperty`.`bookingId` = 99, `clinic`.`bookingproperty`.`bookingType` = 0, `clinic`.`bookingproperty`.`type` = 0, `clinic`.`bookingproperty`.`name` = 'INSERT_TEST', `clinic`.`bookingproperty`.`value` = '0'
复制
(2) TRANSACTION
信息得知事务 2 的语句为:
insert into `clinic`.`bookingproperty` (`bookingId`, `bookingType`, `type`, `name`, `value`) values (98, 0, 0, 'INSERT_TEST', '0') on duplicate key update `clinic`.`bookingproperty`.`bookingId` = 98, `clinic`.`bookingproperty`.`bookingType` = 0, `clinic`.`bookingproperty`.`type` = 0, `clinic`.`bookingproperty`.`name` = 'INSERT_TEST', `clinic`.`bookingproperty`.`value` = '0'
复制
(1) WAITING FOR THIS LOCK TO BE GRANTED
中的信息:
lock_mode X locks gap before rec insert intention waiting
复制
得知事务 1 等待 ”插入意向锁“;
(2) HOLDS THE LOCK(S)
中的信息:
lock_mode X locks gap before rec
复制
得知事务 2 持有 ”间隙锁“;
(2) WAITING FOR THIS LOCK TO BE GRANTED
中的信息:
lock_mode X locks gap before rec insert intention waiting
复制
得知事务 2 等待 ”插入意向锁“;
分析得知:两个事务同时持有间隙锁,等待插入意向锁,形成死锁。
1.2 规则
在解读死锁日志、找寻死锁发生原因前,我们需要先了解一些相关的约定和规则。
对于几种行锁对应的死锁日志描述如下:
记录锁(LOCKRECNOTGAP): lockmode X locks rec but not gap
间隙锁(LOCKGAP): lockmode X locks gap before rec
Next-key 锁(LOCKORNIDARY): lockmode X
插入意向锁(LOCKINSERTINTENTION): lock_mode X locks gap before rec insert intention
行锁兼容矩阵(横向是已持有锁,纵向是正在请求的锁)
1.3 锁机制
Mysql 的锁机制可以简单划分为:锁模式(lockmode)和 锁类型(locktype)。
锁模式
LOCK_S:读锁
共享锁(Share locaks,S 锁),其他事务可读取,不能修改,可以同时有多个事务对记录加读锁。
LOCK_X:写锁
排他锁(Exclusive locks,X 锁),其他事务都不可以读取和修改,同一时间只能有一个事务加写锁。
LOCK_IS:读意向锁
LOCK_IX:写意向锁
表级锁,当事务试图读或写某一条记录时,会先在表上加上意向锁,然后在要操作的记录上加读锁或写锁。这样可以很方便地判断表中是否有记录加锁。
LOCKAUTOINC:自增锁
简称 AI 锁,当插入数据且表中有自增列时,会在生成自增值前为该表加 AI 锁。
锁类型
锁类型可以分为表锁和行锁。表锁会对整张表加锁,由 Mysql 服务器实现。行锁会锁住某一行、某几行或行之间的间隙,由存储引擎实现,如InnoDb。
下面主要介绍一下上文提到的几种行锁。
记录锁(Record Lock)
单个行记录下的锁,会锁住索引值记录。如果没有设置索引会使用隐式的主键来锁定。
间隙锁(Gap Lock)
又称范围锁(Range Locks),加在两个索引之间或第一个索引之前,或者最后一个索引之后。并不锁定索引本身。
在可重复读隔离级别下才会产生间隙锁, 它可以防止其他事物在加锁范围内插入或修改记录,保证两次读取加锁范围内的记录数据不变,避免幻读发生。
在可重复读隔离级别下,标准的 SQL 规范是会存在幻读问题的,但 MySql 通过间隙锁的技术避免了幻读。但有利也有弊,通过间隙锁虽然解决了幻读问题,但增加了数据库的开销,影响了数据库的并发性能。
产生间隙锁的场景:
使用普通索引
使用多列唯一索引
使用唯一索引
范围查询,或索引对应记录不存在,会产生间隙锁
上文中的间隙锁就是由于使用了多列唯一索引产生的。
Next-Key 锁(Next-Key Locks)
可以认为是记录锁和间隙锁的组合,既锁定范围,又锁定索引记录本身。
插入意向锁(Insert Intention Locks)
是一种特殊的间隙锁,也简写成 II GAP。与上文中提到的读、写意向锁是两个不同的概念,只会在 INSERT 时才会有这个锁。
这个时候再来看上文中的行锁兼容矩阵, 在持有间隙锁或 Next-key 锁时,请求插入意向锁会有冲突。因为间隙锁的作用就是防止幻读,而正是由于在执行 INSERT 语句时需要加插入意向锁,而插入意向锁和间隙锁冲突,从而阻止了插入操作的执行,进而避免了幻读的产生。
二、场景重现
为了模拟并发操作,重现死锁问题和测试死锁修复代码。利用 CountDownLatch
简单写了个测试类如下:
@SpringBootTest
@RunWith(SpringRunner.class)
@Slf4j
//@Ignore
public class PropertyTest {
**
* 并发数量
*/
public static final int THREAD_NUM = 100;
private static long startTime = 0L;
private static AtomicInteger atomicInteger = new AtomicInteger(0);
@Autowired
private PropertyTestService propertyTestService;
@Test
public void testConcurrentInsert() {
try {
startTime = System.currentTimeMillis();
log.info("test started at: " + startTime);
初始化计数器
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(THREAD_NUM);
for (int i = 0; i < THREAD_NUM; i++) {
new Thread(new TestRun(startLatch, endLatch)).start();
}
启动多个线程
startLatch.countDown();
endLatch.await();
} catch (Exception e) {
Assert.assertFalse(e instanceof SQLException);
}
}
@After
public void afterTest() {
propertyTestService.deleteTestProperty();
}
**
* 线程类
*/
@AllArgsConstructor
private class TestRun implements Runnable {
private final CountDownLatch startLatch;
private final CountDownLatch endLatch;
@Override
public void run() {
线程等待
try {
startLatch.await();
} catch (InterruptedException e) {
log.error(e.getMessage());
}
执行操作
propertyTestService.insertTestProperty(atomicInteger.incrementAndGet());
long endTime = System.currentTimeMillis();
log.info(Thread.currentThread().getName() + " ended at: " + endTime + ", cost: " + (endTime - startTime) + " ms.");
endLatch.countDown();
}
}
}复制
在 propertyTestService.insertTestProperty 中加了 Thread.sleep(THREAD_SLEEP_TIME) 用来模拟长事务。
通过测试发现,在较短的事务下,同时发起几十个请求并没有触发死锁问题。而随着事务的加长,死锁的概率也逐渐加大。通过查阅相关代码,发现发生死锁的方法被包在一个大的事务里面,这个事务甚至还包含了多次远程调用。
其实本项目已经对分布式事务做了处理,保证了事物的一致性,同时也避免了长事务的产生。只不过项目较大,开发时间跨度也长,有些地方遗漏了处理。
三、总结
避免死锁或者减少死锁情况发生及降低死锁损失的方法:
避免长事务,尽量将长事务拆成多个短事务来处理;因为长事务占用资源多,耗时长,与其他事务冲突的概率也高;
合理设置索引;
业务场景允许的情况下,可以减低事务的隔离级别,避免间隙锁的产生;
如上文场景,可以直接使用
insert
或者insert ignore
;设置
innodb_lock_wait_timeout
,可以避免高并发时大量事务无法获取到锁而挂起,从而引发更严重的问题;业务场景允许的情况下可以使用悲观锁。
全文完
以下文章您可能也会感兴趣:
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。