记一次 MySql 数据库死锁问题排查过程

作者 | 小牛

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) 用来模拟长事务。

通过测试发现,在较短的事务下,同时发起几十个请求并没有触发死锁问题。而随着事务的加长,死锁的概率也逐渐加大。通过查阅相关代码,发现发生死锁的方法被包在一个大的事务里面,这个事务甚至还包含了多次远程调用。

其实本项目已经对分布式事务做了处理,保证了事物的一致性,同时也避免了长事务的产生。只不过项目较大,开发时间跨度也长,有些地方遗漏了处理。

三、总结

避免死锁或者减少死锁情况发生及降低死锁损失的方法:

  1. 避免长事务,尽量将长事务拆成多个短事务来处理;因为长事务占用资源多,耗时长,与其他事务冲突的概率也高;

  2. 合理设置索引;

  3. 业务场景允许的情况下,可以减低事务的隔离级别,避免间隙锁的产生;

  4. 如上文场景,可以直接使用 insert 或者 insert ignore

  5. 设置 innodb_lock_wait_timeout,可以避免高并发时大量事务无法获取到锁而挂起,从而引发更严重的问题;

  6. 业务场景允许的情况下可以使用悲观锁。

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值