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

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

MySQL 的锁、索引、事务、隔离级别系列文档

MySQL 索引的分类和各种索引的简单理解

MySQL 锁的分类和死锁的解决方案(本文)

MySQL 事务的概念和事务 ACID 基本实现原理

MySQL 数据库之 InnoDB 事务隔离级别和锁的关系


锁的类型

排它锁(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 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。


行锁的 3 种算法

innodb 存储引擎支持行锁,行锁只在存储引擎层实现。行锁可以最大程度的支持并发处理(同时也带来了最大的锁开销)。innodb 存储引擎有 3 种行锁算法,分别是:

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

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

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

innodb 存储引擎都是采用 next-key lock 算法加锁,依靠索引锁住索引区间,采用 next-key lock 的锁定技术称为 next-keying locking,其设计目的是为了解决“幻读”。但也不是每次都会锁定范围,比如当查询的索引中含有唯一属性(主键、唯一索引)时,innodb 存储引擎会对 next-key lock 进行优化,将其降级为 record lock,即仅锁住索引本身,而不是范围,从而提高应用的并发性。

# 表结构数据准备
mysql> create table t(a int primary key);
mysql> insert into t select 1;
mysql> insert into t select 2;
mysql> insert into t select 5;
# 事务 A
mysql> begin;
mysql> select * from t where a = 5 for update;
# 事务 B
mysql> begin;
mysql> insert into t select 4;
mysql> commit; # 成功不需要等待

对于上面例子很好理解,事务 A 对 a=5 增加排他锁,而由于 a 是主键唯一,所以只会锁住 a=5 这一行数据,而不是(2,5)这个范围,这样事务 B,其实只要不操作 a=5 这行数据,其它操作都可以完成也不会阻塞。即由 next-key lock 的范围锁降级为 record lock 行锁,从而提高并发性。

对于查询的列是唯一索引情况下,next-key lock 会降级为 record lock 锁。但是对于辅助索引,则情况会完全不同。比如:

# 表结构数据准备
mysql> create table z (a int, b int, primary key(a), key(b));
mysql> insert into z select 1,1;
mysql> insert into z select 3,1;
mysql> insert into z select 5,3;
mysql> insert into z select 7,6;
mysql> insert into z select 10,8;
# 事务 A
mysql> select * from z where b = 3 for update;

我们对 b = 3 增加排它锁,因为 innodb 会使用传统的 next-key lock 技术加锁。并且由于 z 表有两个索引,其需要分别进行锁定。对于聚集索引,其仅对列 a=5 增加 record lock。而对于辅助索引 b,其加上的是 next-key lock,锁定的范围就是(1,3)(3,6)这个范围,(1,3)是向下寻找靠近检索条件 b=3 的记录值 A 作为左区间,(3,6)是向上寻找靠近检索条件 b=3 的记录值 B 作为右区间,即锁定的间隙就是(A,B)范围,在这个范围插入数据就会被阻塞。

next-key lock 是结合 record lock + gap lock 的一种算法,也就是不仅会锁住一个范围,还会锁住记录本身,即对 (1,3)(3)(3,6)范围锁定。上面这段话已经证明 next-key lock 的加锁方式,其 a=5 主键增加 record lock 锁住自身,b=3 增加 gap lock 锁住一个范围。

# 事务 B
mysql> select * from z where a = 5 lock in share mode;
mysql> insert into z select 4,2;
mysql> insert into z select 6,5;

第一个语句不能执行成功,因为在事务 A 中执行的 sql 语句已经对 a=5 的值加上排它锁,因此执行会被阻塞。第二个 sql 语句,主键插入 4,没有问题,但是插入的辅助索引值 2 在锁定的范围(1,3)中,因此执行同样会被阻塞。第三个语句,插入的主键 6 没有被锁定,5 也不在范围(1,3)之间。但插入的值 5 在另一个锁定的范围(3,6)中,故同样需要等待。但是下面这几条语句不会被阻塞,可以立即执行:

mysql> insert into z select 8,6;
mysql> insert into z select 2,0;
mysql> insert into z select 6,7;

从上面的例子可以看到,gap lock 的作用是为了阻止多个事务将记录插入到同一范围内,这样会避免幻读的产生。上面的例子中,事务 A 已经锁定了 b=3 的记录。若此时没有 next-key lock 锁定 (1,3)(3)(3,6)一个范围,那么事务 B 就会插入一条 b=3 的记录,这样会导致事务 A 前后查询不一致的情况。

其实在锁住范围的同时,下面这条语句也不会执行成功,比如:

mysql> update t set b = 3 where a = 10;

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

以上就是行锁的三种算法和 next-key lock 加锁方式。我们可以这样简单理解下,innodb 加锁是依靠索引来加锁的,每次只能保证一个事务来操作指定的行,其它事务是不允许操作的,必须等一个事务完成以后,其他事务才可以继续操作。next-key locking 的加锁机制可以根据唯一索引属性降级为 record lock 锁,从而提高并发。

关于为什么要锁住范围区间和自身去解决幻读呢?我们都知道索引是有序排列的,比如 b=3 这一行,已经根据主键锁住了自身,但只能保证自身这条数据不被其他事务改动,却无法保证有人插入一条 b=3 的相同条件的数据。这样会导致当前事务再次查询多出来一条数据,出现幻读的情况。但是我们可以想象下,其他事务可以插入一条 b=4 的数据,这样并不会出现幻读。不过没有办法,innodb 是无法知道你要插入 3 或 4,他只能锁住这样一个范围,从而避免你插入 a=3 这行数据。知道这个概念后,可以发现 innodb 在加间隙锁的同时,受限于这种加锁方式,会锁住一些不必要锁住的行。

但是如果是使用没有字段的索引,那么 innodb 就匹配不到任何数据,就会给全表加间隙锁。还有一种情况就是如果索引失效的情况下也会导致表锁,比如 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:系统启动后到现在总共等待锁的次数。


悲观锁

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

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

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


乐观锁

乐观锁并不会像悲观锁那样真正的对数据加锁,而是一种理论上的逻辑锁,一般是在每行数据后面加一个 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