MySQL Gap Lock引发insert语句堵住问题排查

一、众所周知,MySQL在RR隔离级别下,会出现幻读的问题。
出现幻读的前提条件:
Innodb存储引擎,在RR隔离级别下,并且使用了当前读;
出现幻读的表现:
一个事务在前后两次查询同一范围数据的时候(当前读),后一次查询看到了前一次查询没有看到的行。两点需要说明:
1 、在可重复读隔离级别下,普通查询是快照读,不会看到其他事务插入的数据。幻读只在当前读才会出现;
2 、幻读专指新插入的行。当前读的作用就是能读到其他事务已经提交的新插入的记录。

二、幻读带来的影响是会导致主从之间数据不一致,是很严重的问题。
三、如何解决幻读的问题:
产生幻读的原因是:mysql的record lock(行锁)只能锁住行,但是新插入记录这个动作,是更新了记录之间的“间隙”。因此,为了解决幻读,innodb引入了新的锁,也就是间隙锁(Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。

四、以上介绍了间隙锁,它能帮我们解决了幻读的问题,但同时也会给我们带来一些“困扰”。接下来结合一个线上的故障case,讲解一下间隙锁,给我们带来了什么样的困扰:
1、故障现象描述:
用户报障,一个insert的SQL语句被堵住了,从DBbrain看到的现象update的SQL持有锁,导致insert语句无法写入:


图片1.png

2、分析:
1)、从上图可以看到,insert语句处于锁等待状态:LOCK WAIT。Dbbrain显示持有锁的事务为:“UPDATE t_push_task SET status=9 WHERE push_id=1384715944290652160 AND access_id=1500015064 limit 1” 以及
“UPDATE t_push_task SET status=9 WHERE push_id=1384729752719482880 AND access_id=1500015064 limit 1”

insert语句为:
INSERT INTO t_push_task SET status=0, access_id=1600007315, type=8, push_req='�????(:?�[{"Ids":["zhihuishu_class_204117682"],"IdsStru":null,"InnerOperator":1,"OuterOperator":1,"IsNot":false,"TagType":"xg_user_define"}]J�00:4895P�Z?���?��?�\n?�{"alert":{"title":"通知","subtitle":"","body":"刘婷班 ......

2)、是否这两个update语句有性能问题呢?


图片2.png

从explain执行计划分析,该SQL扫描的行数只有一行,并且是走主键索引扫描,所以update语句并没有性能问题;

3)、那为什么update语句会把insert语句堵住呢?
我们知道,insert插入记录,更新的是记录之间的“间隙”。那么是否有可能是由于间隙锁的原因,导致insert无法插入呢?

4)、我们来看看表结构,如下:
CREATE TABLE t_push_task (
push_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
group_id varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
status int(8) NOT NULL,
access_id bigint(11) NOT NULL,
type bigint(20) NOT NULL,
target_list mediumtext COLLATE utf8mb4_unicode_ci,
push_req blob,
create_time datetime DEFAULT CURRENT_TIMESTAMP,
push_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
push_node varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
expire_sec int(11) NOT NULL DEFAULT '259200',
start_time datetime DEFAULT NULL,
finish_time datetime DEFAULT NULL,
source int(8) NOT NULL,
msg_type int(8) NOT NULL,
msg_status int(8) NOT NULL DEFAULT '0',
push_content mediumtext COLLATE utf8mb4_unicode_ci,
last_modify_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
global_push_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
upload_id bigint(20) unsigned NOT NULL DEFAULT '0',
already_send_num bigint(20) unsigned NOT NULL DEFAULT '0',
queue_id varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT '',
collapse_id int(8) NOT NULL DEFAULT '0' COMMENT '????push_id',
expect_send_num bigint(20) unsigned DEFAULT '0',
current_index int(11) DEFAULT '-1' COMMENT '?????????',
PRIMARY KEY (push_id,push_time),
KEY idx_status_create (access_id,status,create_time),
KEY idx_status_push (access_id,status,start_time),
KEY idx_status_push_queenid (queue_id,status,push_time),
KEY idx_source_type_content_push (access_id,create_time,source,msg_type,push_content(512)),
KEY idx_push_time (push_time),
KEY idx_id_pushtime_type_msgtype_source (access_id,push_time,type,msg_type,source),
KEY idx_id_type_status_pushtime (access_id,type,status,push_time),
KEY idx_id_collapseid (access_id,collapse_id),
KEY id_idx_status (push_id,access_id,status)
) ENGINE=InnoDB AUTO_INCREMENT=500534759 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
/*!50100 PARTITION BY RANGE (to_days(push_time))
(PARTITION p201904 VALUES LESS THAN (737545) ENGINE = InnoDB,
PARTITION p201905 VALUES LESS THAN (737576) ENGINE = InnoDB,
PARTITION p201906 VALUES LESS THAN (737606) ENGINE = InnoDB,
PARTITION p201907 VALUES LESS THAN (737637) ENGINE = InnoDB,
PARTITION p201908 VALUES LESS THAN (737668) ENGINE = InnoDB,
PARTITION p201909 VALUES LESS THAN (737698) ENGINE = InnoDB,
PARTITION p201910 VALUES LESS THAN (737729) ENGINE = InnoDB,
PARTITION p201911 VALUES LESS THAN (737759) ENGINE = InnoDB,
PARTITION p201912 VALUES LESS THAN (737790) ENGINE = InnoDB,
PARTITION p202001 VALUES LESS THAN (737821) ENGINE = InnoDB,
PARTITION p202002 VALUES LESS THAN (737850) ENGINE = InnoDB,
PARTITION p202003 VALUES LESS THAN (737881) ENGINE = InnoDB,
PARTITION p202004 VALUES LESS THAN (737911) ENGINE = InnoDB,
PARTITION p202005 VALUES LESS THAN (737942) ENGINE = InnoDB,
PARTITION p202006 VALUES LESS THAN (737972) ENGINE = InnoDB,
PARTITION p202007 VALUES LESS THAN (738003) ENGINE = InnoDB,
PARTITION p202008 VALUES LESS THAN (738034) ENGINE = InnoDB,
PARTITION p202009 VALUES LESS THAN (738064) ENGINE = InnoDB,
PARTITION p202010 VALUES LESS THAN (738095) ENGINE = InnoDB,
PARTITION p202011 VALUES LESS THAN (738125) ENGINE = InnoDB,
PARTITION p202012 VALUES LESS THAN (738156) ENGINE = InnoDB,
PARTITION p202101 VALUES LESS THAN (738187) ENGINE = InnoDB,
PARTITION p202102 VALUES LESS THAN (738215) ENGINE = InnoDB,
PARTITION p202103 VALUES LESS THAN (738246) ENGINE = InnoDB,
PARTITION p202104 VALUES LESS THAN (738276) ENGINE = InnoDB,
PARTITION p202105 VALUES LESS THAN (738307) ENGINE = InnoDB,
PARTITION p202106 VALUES LESS THAN (738337) ENGINE = InnoDB,
PARTITION p202107 VALUES LESS THAN (738368) ENGINE = InnoDB,
PARTITION p202108 VALUES LESS THAN (738399) ENGINE = InnoDB,
PARTITION p202109 VALUES LESS THAN (738429) ENGINE = InnoDB,
PARTITION p202110 VALUES LESS THAN (738460) ENGINE = InnoDB,
PARTITION p202111 VALUES LESS THAN (738490) ENGINE = InnoDB,
PARTITION p202112 VALUES LESS THAN (738521) ENGINE = InnoDB,
PARTITION p2022 VALUES LESS THAN (741443) ENGINE = InnoDB) */

从表结构可以知道,该表主键为:PRIMARY KEY (push_id,push_time)。而update语句的where条件为:push_id=1384729752719482880 AND access_id=1500015064,故update语句走的是主键索引的前缀索引,长度为8个字节(push_id类型为bigint,长度为8个字节;push_time类型为datetime,长度也是8个字节);
由于push_id是自增字段auto_increment,故insert语句插入的时候,push_id会进行自增,会在当前最大max(push_id)的基础上,自增加1;

5)、用户提供的线索:
用户反馈,由于业务逻辑设计不合理的原因,其update语句中的push_id实际上在表中是并不存在的,并且远远大于当前表中的最大push_id,当前表中最大push_id为:


图片3.png

这就很奇怪了,update语句where 条件的push_id=1384715944290652160在表当中根本就不存在,也就是说该update语句实际上没生效。update语句和insert语句风马牛不相及,为啥update会阻塞insert呢???

6)、测试实例实验一把,看看是否会出现同样的情况?
a、创建表、写入记录
MySQL [(none)]> use test;
MySQL [test]> CREATE TABLE t ( id int(11) NOT NULL, c int(11) DEFAULT NULL, d int(11) DEFAULT NULL, PRIMARY KEY (id), KEY c (c)) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)

MySQL [test]> show create table t\G;
*************************** 1. row ***************************
Table: t
Create Table: CREATE TABLE t (
id int(11) NOT NULL,
c int(11) DEFAULT NULL,
d int(11) DEFAULT NULL,
PRIMARY KEY (id),
KEY c (c)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

MySQL [test]> select * from t;
+----+------+------+
| id | c | d |
+----+------+------+
| 0 | 0 | 0 |
| 5 | 5 | 5 |
| 10 | 10 | 10 |
| 15 | 15 | 15 |
| 20 | 20 | 20 |
| 25 | 25 | 25 |
+----+------+------+
6 rows in set (0.00 sec)

b、开启两个会话,分别是session A、session B
session A:
MySQL [test]> begin;
Query OK, 0 rows affected (0.00 sec)

MySQL [test]> update t set d=99 where id=100;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0

session A更新了表中不存在的id,并且比当前max(id)还要大。此时session A的事务还未提交;

session B:
MySQL [(none)]> use test;
MySQL [test]> insert into t values(26,26,26);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

session B想要插入比当前比当前max(id)=25要大的一条记录26,结果发现被堵住了,产生了锁等待;

实验结果现象和上述用户的故障case是一样的,为什么会出现这种情况呢?

7)、next-key lock(record lock + gap lock)加锁原则:
原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间;
原则 2:查找过程中访问到的对象才会加锁;
优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁;
优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁;
一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止;

由于用户的表当中并没有push_id=1384715944290652160或者push_id=1384729752719482880,用上述加锁规则来判断:

a、加锁的单位是next-key lock,故“UPDATE t_push_task SET status=9 WHERE push_id=1384715944290652160 AND access_id=1500015064 limit 1”的加锁范围为:
(max(push_id),+∞];
b、根据优化2原则:update语句是一个等值查询(push_id=1384715944290652160),向右遍历比1384715944290652160大的是+∞,而+∞不满足查询条件,故next-key lock退化成间隙锁,锁住的范围为(max(push_id),+∞);
c、由于insert语句写入,push_id会自增,比当前最大的max(push_id)自增加1,所有落入了加锁范围(max(push_id),+∞)内,导致用户的insert语句被阻塞。
d、即:如果索引没有命中,会有间隙锁, 向左扫描扫到第一个比给定参数小的值, 向右扫描扫描到第一个比给定参数大的值, 然后以此为界,构建一个区间, 锁住整个区间内的数据
由此就能解释,为什么insert会被update阻塞了。将原因同步给用户之后,用户承诺会优化业务代码,规避间隙锁的问题。

8)、思考:
a、上述case是更新了一个比表中max(push_id)还要大的push_id,那如果更新的push_id刚好是表中存在的一条记录,比如更新的是当前的max(push_id)=500536561,那么会如何加锁呢?
根据加锁原则优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。此时只会对push_id=500536561加锁;

b、如果更新的push_id在表中不存在,但是比max(push_id)要小,例如更新的push_id=447689301,介于447689203 和 448344009之间。此时会如何加锁呢?


图片4.png

根据原则1,加锁单位是next-key lock,故加锁的范围是(447689203,448344009];
根据优化2,这是一个等值查询,push_id=447689301,向右遍历且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁,故最终加锁的范围为(447689203,448344009)。

你可能感兴趣的:(MySQL Gap Lock引发insert语句堵住问题排查)