一不小心,间隙锁引发的报警现场

摘要

今天来分享一下我在线上环境遇到的有关MySQL间隙锁的死锁问题。本文将讲述从发现问题到解决问题的全过程,并给出一些个人建议,其中使用的数据将做脱敏处理,但不影响食用口感。

发现问题

运维同学发现有大量的MySQL死锁日志输出,如果你的系统有对MySQL进行死锁监控,可会在图形界面上很直观的发现这个问题。

你会发现日志里有大量下面这样的log输出:

Deadlock found when trying to get lock; try restarting transaction

定位问题

既然知道了是死锁造成的问题,那怎么定位问题呢?我们可以使用show engine innodb status查看最近死锁发生的日志,下面只给出与本次死锁有关的信息:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-05-10 05:54:26 0x7f08bd92e700
*** (1) TRANSACTION:
TRANSACTION 222522, ACTIVE 84 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id 9, OS thread handle 139675518330624, query id 126 192.168.0.1 root updating
update t set a = 1 where b= 4
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 19 page no 6 n bits 80 index b of table `mydb`.`t` trx id 222522 lock_mode X waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000004; asc     ;;
 1: len 4; hex 80000004; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 222523, ACTIVE 81 sec starting index read
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id 10, OS thread handle 139675516987136, query id 127 192.168.0.1 root updating
update t set b = 1 where a= 3
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 19 page no 6 n bits 80 index b of table `mydb`.`t` trx id 222523 lock_mode X
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000004; asc     ;;
 1: len 4; hex 80000004; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 19 page no 5 n bits 1136 index a of table `mydb`.`t` trx id 222523 lock_mode X waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000003; asc     ;;
 1: len 4; hex 80000003; asc     ;;

*** WE ROLL BACK TRANSACTION (2)

从上面日志可以清楚的看到,TRANSACTION(1)正在等待RECORD LOCKS space id 19 page no 6 n bits 80 index b of table mydb.t,而这个锁正好被TRANSACTION(2)持有。而TRANSACTION(2)正在等待的锁RECORD LOCKS space id 19 page no 5 n bits 1136 index a of table mydb.t正好是TRANSACTION(1)持有的锁,这样就导致了死锁。

死锁复现

既然已经遭到了问题所在,接下来我们来看看是如何产生这个死锁的。

在这之前,需要准备一下结构和数据:

CREATE TABLE `t`  (
  `id` int(11) NOT NULL,
  `a` int(11) NULL DEFAULT NULL,
  `b` int(11) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `a`(`a`) USING BTREE,
  INDEX `b`(`b`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

INSERT INTO `t` VALUES (1, 1, 1);
INSERT INTO `t` VALUES (2, 2, 2);
INSERT INTO `t` VALUES (3, 3, 3);
INSERT INTO `t` VALUES (4, 4, 4);
INSERT INTO `t` VALUES (5, 5, 5);

为了方面说明和数据脱敏,这里对表结构和数据做了相应处理,但丝毫不影响我们要说明的问题。现在有两个session同时访问数据库,他们访问的次序如下表:

时间 session1 session2
T1 start transaction;
select * from t where a=3 for update;
/* Lock a = (2,4] */
T2 start transaction;
select * from t where b = 4 for update;
/* Lock b = (3,5] */
T3 update t set a = 1 where b= 4;
/* waiting session2 unlock b*/
T4 update t set b = 1 where a= 3;
/* waiting session1 unlock a, ROLL BACK*/
T5 /* Query OK, 1 row affected */
commit;

现在我们来分析一下整个过程:

  1. 经过了T1时刻,session1持有a的间隙锁(2,4];
  2. 经过了T2时刻,session2持有b的间隙锁(3,5];
  3. 经过了T3时刻,session1等待session2持有的间隙锁b(3,5]被释放,并等待获得b=4的行锁。
  4. 经过了T4时刻,session2等待session1持有的间隙锁a(2,4]被释放,并等待获得a=3的行锁。到这里,MySQL检测到死锁,session1和session2都在等待对方的锁释放,MySQL使用死锁处理策略,主动回滚死锁链条中的某一个事务,至于选择哪个这个需要考虑回滚的成本。这里选择的是回滚session2中的事务。从上面提到的MySQL死锁日志中可以看到WE ROLL BACK TRANSACTION (2)
  5. 经过了T5时刻,由于session2回滚,间隙锁b(3,5]被释放,session1获得了b=4的行锁,更新成功。commit后,t表中b=4的数据行,a值被更新为1;

幻读

上面我们提到的间隙锁是怎么回事呢?InnoDB不是用行锁么,为什么还有一个间隙锁呢?其实间隙锁的出现是解决幻读的问题的。这里通过下面的例子简单介绍一下何为幻读。

在没有间隙锁的情况下,只使用行锁,看看会怎么样:

时间 session1 session2
T1 start transaction;
select * from t where a=3 for update;
result:(3,3,3)
T2 update t set a = 3 where id = 1;
T3 start transaction;
select * from t where a=3 for update;
result:(1,3,1),(3,3,3)
T4 insert into t values(6,6,3);
T5 select * from t where a=3 for update;
result:(1,3,1),(3,3,3),(6,6,3)
T6 commit;

其实上面这个表格已经十分清楚了,session1在不同的时间使用了读锁定,3次的结果都不一样,而在T5时刻读到的(6,6,3)被认为是幻读。那为什么T3时刻都到的(1,3,1)不是幻读呢?

其实幻读是针对新增或者删除所产生的读结果,对于修改所产生的读结果别认为是“当前读”。

间隙锁

由于行锁无法解决幻读问题,所以InnoDB引入了间隙锁来解决幻读。

那何为间隙锁呢,其实顾名思义,就是锁定间隙的锁,MySQL定义了间隙锁的锁范围为前开后闭区间。下面举个例子说明这个问题:

INSERT INTO `t` VALUES (10, 10, 10);
INSERT INTO `t` VALUES (20, 20, 20);
INSERT INTO `t` VALUES (30, 30, 30);

往表 t 里插入上面几条数据,在每个列上就会形成4个间隙锁区间,分别为(-∞,10],(10,20],(20,30],(30,+∞]。

你可能会觉得奇怪,InnoDB又是怎么锁定间隙的呢?我们都知道,行锁其实是通过锁定索引来达到加锁的目的的,到这里,你应该马上能想到,间隙锁其实也可以这样做,每个间隙其实是连续的有序区间,InnoDB的B+Tree不就是有序的么,通过锁定一个非叶子节点就能锁住一个区间,防止往这个区间添加或删除数据。

那没有索引的列怎么办,行锁还可以通过主键或者row_id进行锁定,可是范围区间却不能。如果需要使用到间隙锁,而对应列上没有索引的话,InnoDB会锁表,没锁,就是锁表。这一点需要注意。如果你在一个事务里执行一个update by where,字句中的列没有索引的话就会锁表。

又此可见,虽然InnoDB引入了间隙锁解决幻读问题,但同时也带来了死锁,性能等问题。

死锁解决

其实MySQL已经引入了死锁解决策略,通过上面提到的死锁策略中的最后一行WE ROLL BACK TRANSACTION (2)我们就可以发现。那我我们需要做的和可以做的就是让MySQL运行时尽量避免死锁:

  • 可以通过修改应用程序的逻辑。
  • 如果你正使用锁定读,(SELECT … FOR UPDATE 或 … LOCK IN SHARE MODE), 试着用更低的隔离级别,比如 READ COMMITTED。
  • 避免大事务

总结

今天我给出了一个线上问题的处理过程,其中分析了MySQL的幻读和解决幻读的所适用的手段——间隙锁,并分析了间隙锁带来的一些问题等等。

你可能感兴趣的:(MySQL)