MySQL 锁的分类和死锁的解决方案

摘要:mysql 的锁,锁的类型,两段锁协议,三级封锁协议,锁的粒度,全局锁,表锁,行锁,行锁的分析,悲观锁,乐观锁,死锁,死锁的解决方案,都是自己经过测试和总结网上和书上的资料,加上自己的理解特此记录。

锁的类型

排它锁(X):又称写锁,在写操作之前要申请获取排它锁,如果加锁成功,其它事务不能加任何锁。比如事务 A 更新 id=1 数据,这时事务 A 没有提交,事务 B 也操作同样的 id=1 这行数据,此时事务 B 只能查询数据而无法修改和删除数据(此时你一定疑惑事务 A 对数据增加排它锁,事务 B 应该是不能增加任何锁,什么操作都等待,为什么可以读取数据?这是因为事务 B 中的读是快照读),事务 B 只有等事务 A 提交或者回滚后才可以继续修改和删除操作。

共享锁(S):又称读锁,在读操作之前要申请获取共享锁,如果加锁成功,其它事务可以继续加共享锁,但是不能加排它锁。比如事务 A 开启事务后查询一条数据,在本查询还没有执行完成时,事务 B 此时可以查询数据,但是无法进行修改和删除操作

排它锁和共享锁是数据库自动添加的,但是我们也可以手动的添加排它锁和共享锁。

# 当前事务为一个查询添加一个排它锁,此时其它事物只能对 id=1 这行数据可以查询,但是无法修改和删除,并且无法再次增加排它锁
mysql> select * from table where id = 1 for update;

# 其它事务
mysql> select * from table where id = 1; # 可以查询
mysql> update table set author=13 where id = 1; # 不可以修改
mysql> select * from table where id = 1 for update; # 不可以增加排它锁
mysql> select * from table where id = 1 lock in share mode; # 不可以增加共享锁
# 当前事务为一个查询增加一个共享锁,此时其它事务对 id=1 这行数据可以查询,但是无法增加共享锁和排它锁欧
mysql> select * from table where id = 1 lock in share mode;

# 其它事务
mysql> select * from table where id = 1; # 可以查询
mysql> update table set author=13 where id = 1; # 不可以修改
mysql> select * from table where id = 1 lock in share mode; # 可以继续增加共享锁
mysql> select * from table where id = 1 for update; # 不可以增加排它锁

另外写锁比读锁的有更高的优先级,因此一个写锁请求可能会被插入到读锁队列前面(写锁可以插入到锁队列中读锁的前面,反之读锁则不能插入到写锁的前面)。


两段锁协议

# 事务 A
mysql> begin;
mysql> update table set author=13 where id = 1;
mysql> update table set author=14 where id = 2;

# 事务 B
mysql> begin;
mysql> update table set author=13 where id = 1;

# 事务 A
mysql> commit;

在上面这个操作中,事务 B 的操作会阻塞,只有等到事务 A 结束事务才可以继续操作。所以事务 A 持有的锁是在事务结束的时候才会被释放,也就是说锁是需要的时候才会加上,但并不是不需要了就马上释放,是等到事务结束后才会释放,这个就是两阶段锁协议,它将事务分成了两个阶段(加锁阶段和解锁阶段)。

加锁阶段:在该阶段可以进行加锁操作,在对任何数据进行读操作之前要申请并获得共享锁,在进行写操作之前要申请并获排它锁,如果加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。

解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。

根据两段锁协议,实际开发中如果一个事务中涉及到锁多个行操作,我们应该将造成锁冲突和并发量比较高的锁尽量往后放,如果将这个操作放在前面,那么这个操作就会最先被锁住,其它的事务等待该操作的解锁时间久会很长。


三级封锁协议

在运用 X 锁和 S 锁这两种基本封锁,对数据对象加锁时,还需要约定一些规则。例如何时申请 X 锁和 S 锁,持锁时间,何时释放等,这些规格称为封锁协议,数据库有三种封锁协议对操作数据结果会造成不同的影响。

一级封锁协议:事务在修改数据之前先增加写锁,可以防止两个事务同时修改同一个数据,在本事务 commit 或者 rollback 之前,别的事务无法修改,可以防止修改丢失的问题。因为读数据不需要加锁,所以有可能会产生不可重复读和脏读。

二级封锁协议:在一级封锁协议基础之上,事务在读数据的时候增加读锁,读完后就会释放读锁,相比一级封锁协议,二级封锁协议除了防止修改丢失,还因为加了读锁可以防止脏读,但是无法保证可重复读。

三级封锁协议:在二级封锁协议基础之上,三级封锁协议是直到事务结束的时候才会释放读锁,这样可以实现可重复读。


锁的粒度

锁的粒度通常是指锁的生效范围,比如行锁、间隙锁、页锁、表锁等,这些锁对数据的锁住的范围是不一样的。锁的粒度由数据库自动管理,也可以通过手动使用什么粒度的锁,一般来讲锁的粒度越大代表锁住的行数越多,相反锁的粒度越小代表锁住的行数越少,锁住的行数越少证明并发度越高,但是锁住的行数越少,证明锁会很多,就会带来很多锁开销和锁竞争,有可能还会导致死锁。如果系统花费大量的时间来管理锁,而不是存储数据,那么系统的性能可能会因此受影响。


锁策略

所谓锁策略,就是在选择锁粒度时要在并发度和锁开销之间做一个平衡,这种平衡当然也会影响到性能。


全局锁

全局锁可以理解为数据库级别的锁,这把锁会加给整个数据库。加上全局锁后,整个数据库就是处于只读的状态,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句,一般全局锁用于数据库的备份操作。

# 全局锁加锁命令,简称 (FTWRL)
mysql> flush tables with read lock;

# 释放全局锁命令
mysql> unlock tables;


表锁

myisam、memory 等存储引擎支持表锁。由于表锁会把整个表锁住,所以表锁是锁粒度最大的锁,发生锁冲突的概率很高,并发度很低,但是不会出现死锁。表锁是 mysql 中最基本的锁策略,并且是开销最小的策略。

在特定的场景中,表锁也可能有良好的性能。例如,read local 表锁支持某些类型的并发写操作。尽管存储引擎可以管理自己的锁,但是 mysql 本身还是会使用各种有效的表锁来实现不同的目的。例如 alter table 之类的语句使用表锁,而忽略存储引擎的锁机制。

# 手动加表锁命令
mysql> lock tables t1 read; # 只能读
mysql> lock tables t2 write; # 只能写

# 释放表锁命令
mysql> unlock tables;

除了 unlock tables 命令释放表锁,客户端断开的时候也会自动释放。lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。


行锁

行锁可以最大程度的支持并发处理(同时也带来了最大的锁开销)。innodb 存储引擎支持行锁,行锁只在存储引擎层实现,而 mysql 服务器层没有实现。

innodb 存储引擎有 3 种行锁算法:

1、行锁(record Lock):单个行记录上的锁。

2、间隙锁(gap lock):锁定一个范围,但不包含记录本身。

3、临键锁(next-key lock):gap lock + record lock,锁定一个范围,并且锁定记录本身。

next-key lock 是结合了 gap lock 和 record lock 的一种锁定算法,在 next-key lock 算法下,innodb 对于行的查询都是采用这种锁定算法,来依靠索引锁住索引区间。采用 next-key lock 的锁定技术称为 next-keying lock,其设计的目的是为了解决事务可重复读隔离级别中的幻读问题。

当查询的索引含有唯一属性时(比如主键、唯一索引),innodb 存储引擎会对 next-key lock 进行优化,将其降级为 record lock,即仅锁住索引本身,而不是范围,从而提高应用的并发性,但是如果唯一索引由多个列组成,而查询仅是查询多个唯一索引中的其中一个,还是会使用 next-key lock 进行范围锁定。

如果是辅助索引时,对其锁定的是范围,比如下面这个操作。

mysql> CREATE TABLE `table` (
  `id` int(10),
  `number` int(10),
  PRIMARY KEY (`id`),
  KEY (`number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

mysql> insert into table values (1, 1),(3, 1),(5, 3),(7, 6),(10, 8);

mysql> select * from table;
+----+--------+
| id | number |
+----+--------+
|  1 | 1      |
|  3 | 1      |
|  5 | 3      |
|  7 | 6      |
|  10| 8      |
+----+--------+

mysql> select * from table where number = 3 for update;

根据上面这个表结构和查询语句,next-key lock 采用这样的方式加锁,由于 id 是聚集索引,所以仅仅会对 id 加上 record lock,而对于辅助索引,锁定一个范围,并且锁定记录本身。那就是(1,3)(3)(3,6)这个范围,(1,3)是向下寻找靠近检索条件 number=3 的记录值 A 作为左区间,(3)是检索条件本身,(3,6)是向上寻找靠近检索调条件 number=3 的记录 B 作为右区间,即锁定的间隙就是(A,B)在这个范围内插入数据就会被阻塞。

mysql> select * from table where a = 5 lock in share mode; # 无法执行,以为 a = 5 这行数据已经被加排它锁,不能加任何锁
mysql> insert into table values (4, 2); # 主键插入 4 没有问题,但是辅助索引 2 在范围锁(1,3)中,因此执行会被阻塞
mysql> insert into table values (6, 5); # 主键插入 6 没有问题,但是辅助索引 6 在范围锁(3,6)中,因此执行会被阻塞

以上操作都不会成功,都会被阻塞住,但是还需要注意的是以下这条语句也会被阻塞住。

mysql> update table set number = 6 where id = 10;

因为要解决幻读的问题,保证每次事务中的查询数量都保持不变,所以这条将 number=8 更新成 number=6 的语句也不会成功,如果他成功了,那岂不是下次查询就会多出来一条 number=6 的数据。

以上就是依靠 next-key lock 加锁机制来解决事务中可重复读隔离级别的幻读问题,gap lock 间隙锁只会在可重复读隔离级别中出现,在读已提交中是不会出现的,因为读已提交并不需要实现可重复读这个功能。需要注意的是加锁依靠索引来加锁的,如果没有检测到索引,那么就会给全表加入间隙锁,也就从间隙锁降级为表锁,因为没有索引,这些字段也就没有排序,也就没有区间,所以无法加入间隙锁。还有一种情况就是如果索引失效的情况下也会导致表锁,比如 number 字段类型是 int,你使用 number='6' 作为条件去查询,mysql 会做类型转换导致索引失效,也会降级为表锁。


行锁争用分析

可以通过 innodb_row_lock 参数来分析 mysql 系统的行锁争用情况。

mysql> show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name                 | Value  |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0      |
| Innodb_row_lock_time          | 276033 |
| Innodb_row_lock_time_avg      | 6900   |
| Innodb_row_lock_time_max      | 51051  |
| Innodb_row_lock_waits         | 40     |
+-------------------------------+--------+

# Innodb_row_lock_current_waits:当前正在等待锁定的数量。
# Innodb_row_lock_time:从系统启动到现在锁定的时长。
# Innodb_row_lock_time_avg:每次等待锁所花平均时间。
# Innodb_row_lock_time_max:从系统启动到现在锁等待最长的一次所花的时间。
# Innodb_row_lock_waits:系统启动后到现在总共等待锁的次数。


悲观锁

mysql> select * from table where id = 1 for update;

上面这条 select 语句增加了一个排它锁,其它事务就无法对该操作增加任何锁,也无法修改和删除 id=1 这行数据,只能等本事务操作完成后,其它事务才可以继续操作,这就是悲观锁。

简单来说悲观锁很悲观,每次在操作总认为其它事务会修改我的数据,所以每次操作都会上锁,加锁方式主要是利用数据库的锁机制,被锁定的数据只有等我操作完其它事务才可以继操作。由于每次操作都会上锁,所以悲观锁的并发度不是很高和对数据库资源消耗也比较大。因为此时只有一个事务在对数据操作,所以悲观锁可以有效的避免脏读的情况。


乐观锁

乐观锁并不会像悲观锁那样真正的对数据加锁,而是一种理论上的逻辑锁,一般是在每行数据后面加一个 version 字段,每次更新数据都会检测这个 version 字段是否当初获取的 version 字段值,如果相等就更新,如果不相等就证明在此期间有其他人改过,会认为此操作已过期并不会更新数据库。简单来说就是提交版本记录必须大于当前版本记录才会执行更新。

# 会话 A 获取 id=1 的数据,此时 version=1,然后修改 number=2
mysql> select id, number,version from table where id = 1;

# 在会话 A 准备修改的同时,会话 B 也获取 id=1 的数据,此时 version=1,然后修改 number=3
mysql> select id, number,version from table where id = 1;
# 会话 B 先更新了 id=1 的数据 number=3,并且更新成功
mysql> update table set number = 3, version = version + 1 where id = 1 and version = 1;

# 此时会话 A 更新 id=1 的数据 number=2,但是会话 A 会修改失败,因为数据库中的 version=2,已经找不到 id=1 and version=1 这行数据,所以会修改失败 
mysql> update table set number = 2, version = version + 1 where id = 1 and version = 1;

上面的例子利用乐观锁保证了数据的一致性,并且乐观锁机制避免了事务的等待和加锁的开销,会话 A 和 会话 B 在操作的过程中都没有加锁,大大的提高了系统的并发度。由于在获取数据的时候并没有上锁,所以乐观锁适合多读的场景,如果是多写的情况,那么有可能会话 A 会一直修改失败,我第一次修改被会话 B 修改了,第二次有可能被会话 C 修改了,导致会话 A 一致处于重试阶段。


死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺同一个锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。比如下面这种场景。

# 事务 A
mysql> start transaction;
mysql> update mytable set close = 2 where id = 3;
mysql> update mytable set close = 4 where id = 4;
mysql> commit;

# 事务 B
mysql> start transaction;
mysql> update mytable set close = 2 where id = 4;
mysql> update mytable set close = 4 where id = 3;
mysql> commit;

对于上面的事务 A 和事务 B,如果凑巧,两个事务都执行了第一条语句,更新了一行数据,同时也锁定了该行数据,接着执行第二条 update 语句,却发现该行被对方锁定,然后两个事务都在等待对方释放锁,同时又持有对方需要的锁,则就会陷入死循环,形成死锁。


死锁的解决方案

1、预防死锁,要保证事务执行之间操作的执行顺序尽量不会出现死锁的顺序。比如上面我们说到的两段锁协议,我们应该将造成锁冲突和并发量比较高的锁尽量往后放,如果将这个操作放在前面,那么这个操作就会最先被锁住,其它的事务等待该操作的解锁时间久会很长。

2、解决死锁的最简单办法就是超时等待,只有等待另一个事务提交或者回滚,本事务才可以执行下去。mysql 的 innodb_lock_wait_timeout 参数用来设置超时间,在 innodb 中 innodb_lock_wait_timeout 默认时间是 50 秒(可以更改),也就是说如果发生死锁,需要 50 秒才可以解锁。但是我们又不可以将时间设置成很小的值,比如 2 秒,这样虽然减少了死锁的等待时间,但是如果不是死锁呢?该事务就是执行了 5 秒钟,那这样就会造成该事务没有执行完,锁就已经释放了。

3、发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其它事务继续执行,只需要将 mysql 的 innodb_deadlock_detect 设置为 on(默认) 表示开启。主动死锁检测在发生死锁的时候,是能够快速发现并处理的,但是它是有额外负担的。因为,每当一个事务被锁的时候,死锁检测就要看看它所依赖的线程有没有被别人锁住。如果并发量上来,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n)的操作。死锁检测要对每个事务都去检测,但是检测了半天发现没有出现死锁,这期间会消耗大量的 cpu 资源。如果你的业务场景中不会出现死锁,你可以临时把死锁检测关闭掉,但是万一真正发生死锁后,只能靠死锁超时等待来解决了。还可以控制并发度,对于相同的更新操作,在进入引擎前排队,这样 innodb 内部就不会有大量的死锁检测工作了。

4、从业务场景的设计上优化,比如我们修改一个账户总额,我们把账户总额分成 10 份,要计算账户总额把这 10 份加起来就可以了,这样就把死锁概率降低到 1/10,可以减少锁等待个数。


参考资料:

《mysql技术内幕 InnoDB存储引擎(第二版)》- 第六章 6.4 锁的算法

一夫当关万夫莫开,MySQL全局锁(FTWRL)真香

Innodb中的事务隔离级别和锁的关系

浅谈数据库并发控制 - 锁和 MVCC

一分钟了解Mysql的间隙锁——《深究Mysql锁》

结束语:感谢您对本网站文章的浏览,欢迎您的分享和转载,但转载请说明文章出处。
Top