MySQL锁(四)其它锁概念
好了,锁相关内容的最后一篇文章了。其实最核心的内容,表锁、行锁、读锁、写锁、间隙锁这些重要的内容我们都已经学习过了,特别是间隙锁,是不是感觉非常复杂。放心,今天的内容就比较轻松了。
自增锁
上回我们在学习 间隙锁 的时候,就顺口提了一下 自增锁 。这个锁又叫 AUTO-INC 锁,它主要是用于带自增字段 AUTO_INCREMENT 属性的,很明显,我们经常定义的自增主键就是走这个锁的。它是一个 表级锁 ,因为要保证多个线程同时插入数据时的增长序列,所以会以锁的方式实现。
什么意思呢?假设当前自增的值是 5 ,这时同时来了 3 个客户端请求要插入数据,那么为了保证插入后的结果是 5、6、7 ,就必须在第一个请求插入前,先获得 5 的锁,另外两个就要等待。当 5 插入完成后,释放自增锁,下一个请求拿到 6 的锁,依次类推。
自增锁有一个相关的配置 innodb_autoinc_lock_mode ,可以指定自增模式。它的设置有这样几个值。
0 传统模式,并发较差
1 连续锁定模式,简单插入(一条一条)时,一次申请多个值,多个事务可以拿锁,并发好一点
2 交错模式,MySQL8 引入,并发性高,但批量插入的时候可能不连续,也就是产生间隙,在主从复制中需要注意要使用行复制
通过 SHOW VARIABLES 也可以查看当前设置的情况。
mysql> show variables like 'innodb_autoinc_lock_mode';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_autoinc_lock_mode | 2 |
+--------------------------+-------+
1 row in set (0.00 sec)
需要设置为哪个值也是根据具体情况,不过我的 MySQL8 默认就是设置的 2 ,这种参数如果没有特别的需求,保持默认即可。
死锁
死锁,什么是死锁?学过 Java 或者其它多线程开发语言的同学对这个词不会陌生。当两个事务同时操作时,互相持有对方所需要的锁时,就会产生死锁。比如下面这个由于互相需要更新对方的数据而导致的死锁。
-- 事务1
mysql> begin;
Query OK, 0 rows affected (0.00 sec)mysql> update tran_innodb set name = 'joe2' where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0-- 事务2
mysql> begin;
Query OK, 0 rows affected (0.00 sec)mysql> update tran_innodb set name = 'joe3' where id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0-- 事务1
mysql> update tran_innodb set name = 'joe33' where id = 3;
-- 正常阻塞等待-- 事务2
mysql> update tran_innodb set name = 'joe22' where id = 2;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
看明白了吗?我们一步一步来看
事务1获得 id 为 2 的行锁
事务2获得 id 为 3 的行锁
事务1需要更新事务 3 的数据,但锁在事务2手中,于是正常等待
问题来了,事务2又要更新 id 为 2 的数据,但这个数据的锁在事务1手中
于是,事务2想要完成必须要获得事务1的锁,而事务1想要完成也必须得到事务2的锁,这样两个事务就陷入死循环了,谁都没办法拿到对方的锁
这就是死锁!看事务2的报错信息,Deadlock 很明显就是死锁的意思。幸好 MySQL 比较聪明,发现了死锁,让我们尝试重新开启事务,否则它们俩就只能一直僵持在这里了。
除了普通锁之外,间隙锁也是非常容易出现死锁的,比如下面这样。
-- 事务1
mysql> begin;
Query OK, 0 rows affected (0.00 sec)mysql> select * from tran_innodb where id = 17 lock in share mode;
Empty set (0.00 sec)-- 事务2mysql> begin;
Query OK, 0 rows affected (0.00 sec)mysql> select * from tran_innodb where id = 17 for update;
Empty set (0.00 sec)-- 事务3
mysql> select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks;
+---------------+-------------+------------+-----------+-----------+-----------+
| object_schema | object_name | index_name | lock_type | lock_mode | lock_data |
+---------------+-------------+------------+-----------+-----------+-----------+
| blog_test | tran_innodb | NULL | TABLE | IX | NULL |
| blog_test | tran_innodb | PRIMARY | RECORD | X,GAP | 18 |
| blog_test | tran_innodb | NULL | TABLE | IS | NULL |
| blog_test | tran_innodb | PRIMARY | RECORD | S,GAP | 18 |
+---------------+-------------+------------+-----------+-----------+-----------+
4 rows in set (0.00 sec)-- 事务2
mysql> insert into tran_innodb(id,name,age) values(17,'Joe17',17);-- 事务1
mysql> insert into tran_innodb(id,name,age) values(18,'Joe18',18);
Query OK, 1 row affected (0.01 sec)-- 事务2
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
id 为 17 的数据是在一个间隙中不存在的数据,在这里执行上锁操作后会产生 间隙锁 ,第一个事务是一个读锁,第二个事务是一个写锁。注意,间隙锁是可以共享的,不同的事务都可以拿锁,但是它们之间的写操作互斥。很神奇吧,看事务3的锁信息查询结果就可以看到,这个表同时上了 间隙锁 的 S 锁和 X 锁。
之后事务2插入数据进入阻塞状态,因为事务1的间隙锁没有释放,但是这时其实 insert 语句也获得了一个意向锁,你可以在事务2的 insert 语句之后查看锁情况。
-- 事务3
mysql> select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks;
+---------------+-------------+------------+-----------+------------------------+-----------+
| object_schema | object_name | index_name | lock_type | lock_mode | lock_data |
+---------------+-------------+------------+-----------+------------------------+-----------+
| blog_test | tran_innodb | NULL | TABLE | IX | NULL |
| blog_test | tran_innodb | NULL | TABLE | IS | NULL |
| blog_test | tran_innodb | PRIMARY | RECORD | S,GAP | 18 |
| blog_test | tran_innodb | PRIMARY | RECORD | X,GAP,INSERT_INTENTION | 18 |
| blog_test | tran_innodb | NULL | TABLE | IX | NULL |
| blog_test | tran_innodb | PRIMARY | RECORD | X,GAP | 18 |
+---------------+-------------+------------+-----------+------------------------+-----------+
6 rows in set (0.00 sec)
好乱吧,这张表现在有两个 IX 锁,接着事务1又执行一条插入语句。死锁就产生了。
事务1要完成 insert 就需要获得事务2的 insert 完成之后释放的 IX 锁,而事务2的 insert 则是需要事务1之前的 间隙锁 释放。
OK,禁止套娃,想想都晕,没看明白的小伙伴自己试试吧,手动实践是检验真理的唯一标准。我们马上就来说说怎么避免死锁的问题。
避免死锁
要避免死锁可以从以下几个方面进行考虑。
合理索引,减少锁定行,减少等待时间
长时间的 update/delete 放在事务前面
避免大事务,拆分成小事务,缩短锁定时间
高并发中系统不要主动去显式加锁,别秀操作
降低隔离级别,Read Committed 及以下的级别没有间隙锁,也就不会有间隙锁的死锁问题
最后,还有两个死锁相关的配置可以了解下。
innodb_deadlock_detect,死锁检测,这个保持开启就好了,但是耗费系统性能,不过我相信比起死锁来说,这个性能的耗费应该也是大家可以接受的,它是利用 InnoDB 的 wait-for graph 机制,这是一种主动死锁检测机制,使用深度优先的非递归算法,有兴趣的同学可以更深入地查找资料了解。
innodb_lock_wait_timeout,锁定等待时间,超过时间后还没有拿到锁就认为语句执行失败了。当不使用 innodb_deadlock_detect 时,这个超时时间就非常重要了,否则两个事务会一直僵持下去。
乐观锁与悲观锁
最后的概念,相信大家也经常听到过这两个名词。
悲观锁
悲观锁对数据被其他事务的修改持保守态度,每次拿数据都觉得别人会修改数据,所以别人拿到锁之前都会先上锁,MySQL 中的锁机制就是悲观锁。
我们在日常开发中,要确保使用索引避免影响其它数据行导致全部数据加锁退化为表锁。这样即使 InnoDB 的并发性也会变差。当然,影响并发的也不仅仅是锁,但它却是最重要的原因。如果事务都执行得很快,一般也会不有太大问题。而且我们大部分的业务都是 读多写少 的场景,毕竟 S 锁是共享的,所以大家日常只是需要注意一下大批量的更新和删除操作以及无法容忍的慢查询语句即可。
乐观锁
乐观锁则是对其它事务的数据修改持乐观态度,争取不加锁来保证数据一致性机制。比如我们可以通过业务逻辑来实现,最常见的就是通过版本号和时间戳之类的机制来实现。
版本号字段 update ... set version ... = version+1 where version = version
时间戳机制 update ... set time = current_time ... where time = time
它们也是比较适合读多的场景,本质上两个方式都是一样的原理,让一个字段成为钥匙,当字段与我们前面读取时的内容不一致时,无法修改数据。
总结
最早两个月前看书时看到锁就是一脸懵逼,接着过了两个月又开始找相关的视频,渐渐有了感觉,最后在写这几篇文章的时候又查询资料,现在才敢说是略微掌握了锁这块的知识。很神奇,本来以为我自己只能理解到 S/X 和 表/行 锁这里,而且我也认为能够了解到这里就够了,谁知道一学起来就突然把剩下的概念也都悟出来了。有的时候,学习就是这样,当你一时间还无法领悟的话,那么就放下一段时间,或者再去查找别的资料,或许某一天就突然开窍了。
关于学习的方法,要不要单独开个系列来讲讲呢?