十、只查一行的语句也会慢
查询长时间不返回
select * from t where id=1;
查询结果长时间不返回。一般碰到这种情况的话,大概率是表 t 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。
- 等 MDL 锁
出现这个状态 Waiting for table metadata lock 表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select语句堵住了。
这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。 但是,由于在 show processlist 的结果里面,session A 的 Command 列是“Sleep”,导致查找起来很不方便。不过有了 performance_schema
和 sys
系统库以后,就方便多了。(MySQL 启动时需要设置 performance_schema=on
,相比于设置为 off 会有 10%左右的性能损失)通过查询 sys.schema_table_lock_waits
这张表,我们就可以直接找出造成阻塞的process id,把这个连接用 kill 命令断开即可。
- 等 flush
select * from information_schema.processlist where id=1;
Waiting for table flush 这个状态表示的是,现在有一个线程正要对表 t 做 flush 操作。MySQL 里面对表做 flush操作的用法,一般有以下两个:
flush tables t with read lock;
flush tables with read lock;
如果指定表 t 的话,代表的是**只关闭表 t;** 如果没有指定具体的表名,则表示**关闭 MySQL 里所有打开的表。**
- 等行锁
select * from t where id=1 lock in share mode;
由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的 select 语句就会被堵住。
4 号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是 KILL QUERY 4 或 KILL 4。
不过,这里不应该执行KILL QUERY 4
”。这个命令表示停止 4 号线程当前正在执行的语句,而这个方法其实是没有用的。因为占有行锁的是 update 语句,这个语句已经是之前执行完成了的,现在执行 KILL QUERY,无法让这个事务去掉 id=1 上的行锁。 实际上,KILL 4 才有效,也就是说直接断开这个连接。连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了 id=1 上的行锁。
查询慢
select * from t where c=50000 limit 1;
由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 万行。作为确认,你可以看一下慢查询日志。
select * from t where id=1;
虽然扫描行数是 1,但执行时间却长达 800 毫秒。
select * from twhere id=1 lock in share mode;
执行时扫描行数也是 1 行,执行时间是 0.2 毫秒。
session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)。带 lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果, 所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。
十、幻读的问题
当前读和快照读
当前读
select...lock in share mode (共享读锁)
select...for update
update , delete , insert
实现方式:next-key锁(行记录锁+Gap间隙锁)
间隙锁:只有在Read Repeatable、Serializable隔离级别才有, 就是锁定范围空间的数据,假设id有3,4,5,锁定id>3的数据,是指的4,5及后面的数字都会被锁定,因为此时如果不锁定没有的数据,例如当加入了新的数据id=6,就会出现幻读,间隙锁避免了幻读。
- 对主键或唯一索引,如果当前读时,where条件全部精确命中(=或者in),这种场景本身就不会出现幻读,所以只会加行记录锁。
- 没有索引的列,当前读操作时,会加全表gap锁,生产环境要注意。
- 非唯一索引列,如果where条件部分命中(>、<、like等)或者全未命中,则会加附近Gap间隙锁。例如,某表数据如下,非唯一索引2,6,9,9,11,15。如下语句要操作非唯一索引列9的数据,gap锁将会锁定的列是(6,11],该区间内无法插入数据。
快照读
单纯的select操作
Read Committed隔离级别:每次select都生成一个快照读。
Read Repeatable隔离级别:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读。
如何解决幻读?
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。
间隙锁的问题
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。
可以使用 读提交隔离级别 + binlog_format=row 的组合
十一、为什么只改一行的语句,锁这么多?
两个“原则”、两个“优化”和一个“bug”。
原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
原则 2:查找过程中访问到的对象才会加锁。
优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
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;
insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
等值查询间隙锁
由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:
- 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
- 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-keylock 退化成间隙锁,因此最终加锁的范围是 (5,10)。
所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改id=10 这行是可以的。
非唯一索引等值锁
这里 session A 要给索引 c 上 c=5 的这一行加上读锁。
- 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5] 加上 next-key lock。
- 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10] 加 next-keylock。
- 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
- 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。
需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update就不一样了。 执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in sharemode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select d from t wherec=5 lock in share mode
。
主键索引范围锁
- 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
- 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-keylock(10,15]。
所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-keylock(10,15]。这样,session B 和 session C 的结果你就能理解了。
这里你需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
非唯一索引范围锁
这次 session A 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的(5,10] 和 (10,15] 这两个 next-key lock。
这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。
唯一索引范围锁 bug
session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15] 这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。
但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock 也会被锁上。
所以,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。
非唯一索引上存在"等值"的例子
insert into t values(30,10,30);
可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。图中我画出了索引 c 上的主键 id。为了跟间隙锁的开区间形式进行区别,我用(c=10,id=30) 这样的形式,来表示索引上的一行。
delete 语句加锁的逻辑,其实跟 select ... forupdate 是类似的。
session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。
然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到(c=15,id=15) 的间隙锁。
这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁。
limit 语句加锁
session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟上个案例的结果不同。
这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间。
在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
一个死锁的例子
next-key lock 实际上是间隙锁和行锁加起来的结果。
- session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-keylock(5,10] 和间隙锁 (10,15);
- session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待;
- 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。
你可能会问,session B 的 next-key lock 不是还没申请成功吗?
其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。