一、众所周知,MySQL在RR隔离级别下,会出现幻读的问题。
出现幻读的前提条件:
Innodb存储引擎,在RR隔离级别下,并且使用了当前读;
出现幻读的表现:
一个事务在前后两次查询同一范围数据的时候(当前读),后一次查询看到了前一次查询没有看到的行。两点需要说明:
1 、在可重复读隔离级别下,普通查询是快照读,不会看到其他事务插入的数据。幻读只在当前读才会出现;
2 、幻读专指新插入的行。当前读的作用就是能读到其他事务已经提交的新插入的记录。
二、幻读带来的影响是会导致主从之间数据不一致,是很严重的问题。
三、如何解决幻读的问题:
产生幻读的原因是:mysql的record lock(行锁)只能锁住行,但是新插入记录这个动作,是更新了记录之间的“间隙”。因此,为了解决幻读,innodb引入了新的锁,也就是间隙锁(Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。
四、以上介绍了间隙锁,它能帮我们解决了幻读的问题,但同时也会给我们带来一些“困扰”。接下来结合一个线上的故障case,讲解一下间隙锁,给我们带来了什么样的困扰:
1、故障现象描述:
用户报障,一个insert的SQL语句被堵住了,从DBbrain看到的现象update的SQL持有锁,导致insert语句无法写入:
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语句有性能问题呢?
从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为:
这就很奇怪了,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之间。此时会如何加锁呢?
根据原则1,加锁单位是next-key lock,故加锁的范围是(447689203,448344009];
根据优化2,这是一个等值查询,push_id=447689301,向右遍历且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁,故最终加锁的范围为(447689203,448344009)。