数据库通过锁以及锁协议来进行并发控制,解决并发事务带来的问题,本篇博文主要是解析数据库的锁协议和Mysql的默认存储引擎InnoDB的锁机制。
如果对事务隔离级别以及并发事务带来的问题不熟悉可以翻阅我的另外一篇博文–《解析事务隔离(事务隔离是如何解决脏读、幻读、不可重复读等问题)》
这篇文章中会涉及一些MVCC以及快照读、当前读的概念,如果不是很了解可以翻阅我另外一篇关于MVCC在InnoDB中实现原理的博文–《InnoDB的MVCC实现原理(InnoDB如何实现MVCC以及MVCC的工作机制)》。
在介绍锁之前,我先介绍下锁协议,为了进行并发控制,数据库的锁协议主要有两种,一种是封锁协议,另外一个是两段锁协议;封锁协议规定了何时加锁和该加什么锁以及何时释放锁的规则,而两段锁协议除了规定做相应操作前加相应的锁,更是严格的将事务的整个过程分成加锁阶段和解锁阶段两个过程,加锁阶段不能进行解锁,解锁过程不能加锁。
封锁协议规定了何时加锁、释放锁的规则,不同的规则可用于实现不同的隔离级别,解决不同的并发事务问题。(X锁即为排他锁,S锁即为共享锁,对X锁和S锁不了解的可以滑动到下面先进行了解)
一级封锁协议:更新数据前需要先加X锁,直到事务结束才释放X锁,读数据是不需要加S锁。所以只能解决第一类更新丢失问题,不能解决脏读和不可重复读等问题。
二级封锁协议:在一级封锁的基础上,事务在读取数据之前必须先对其加上S锁,读完即可释放S锁。可以解决第一类更新丢失问题和脏读。
三级封锁协议:一级封锁协议的基础上,事务在读取数据之前必须先对其加S锁,直到事务结束才释放。解决了丢失修改、脏读和不可重复读的问题。
如下表中的情况,若什么锁都不用,会出现不可重复读情况。若使用二级锁协议,事务2在时间点2读取完A值后就释放S锁,此时事务1可以获得X锁进行数据修改,时间点6,事务1提交事务,释放X锁,时间点7,事务2再次获得S锁读取值,事务2出现不可重复读问题,因此二级锁协议不能防止不可重复读问题。而若使用三级锁协议,事务2在时间点2读取完A值后并不释放S锁,一直到事务结束之后才释放S锁,事务1不可能在事务2读数据的过程中获得X锁,因此事务2第二次读A值还是30,防止了不可重复读问题。三级封锁协议通过规定了S锁在事务结束才释放禁止事务读的过程中其它事务进行写操作来防止了不可重复读问题。
时间顺序 | 事务1 | 事务2 |
---|---|---|
1 | 开始事务 | |
2 | 读取A值30 | |
3 | 开始事务 | |
4 | 读取A值30 | |
5 | A=A-10 | |
6 | 提交事务 | |
7 | 读取A值20 | |
8 | 提交事务 |
两段锁协议除了规定做相应操作前加相应的锁,更是严格的将事务的整个过程分成加锁阶段和解锁阶段两个过程,加锁阶段不能进行解锁,解锁过程不能加锁。
加锁阶段: 在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁,在进行写操作之前要申请并获得X锁。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。(加锁阶段没有明确的时间规定,所有的加锁操作做完,加锁阶段就可以结束了)
解锁阶段: 当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。
通过以下两个事务过程进行两段锁的说明,例如事务T1:Lock A,Lock B,unLock B,LockC,unLock A,unLockC,T1事务这个过程不满足两段锁协议,因为加锁阶段对B进行了解锁;事务T2:Lock A,Lock B,LockC,unLock A,unLockC,unLock B,T2这个过程是满足两段锁协议,做完所有的加锁操作之后再进行解锁。注意两段锁协议的重点是事务内的所有加锁操作都在解锁操作之前。
那么两段锁的意义在哪?两段锁协议为什么要有加锁和解锁分阶段这种规定呢?理论上两段锁协议是用来实现可串行化调度,但其实并不见得,如果你仔细阅读了博文上面三级封锁协议,你就会发现三级封锁协议其实就是基于两段锁协议的基础上,三级封锁协议无论是读锁还是写锁都是事务结束才释放,因此它也就有了加锁阶段和解锁阶段,就连三级封锁协议都不能处理幻读,那何况两段锁协议呢?两段锁协议在我看来只是理论上实现了可串行化调度,其实并没有(若有不同意的欢迎在评论区交流或者私信交流),它们都需要和相应的锁算法一同使用才能实现真正的可串行化隔离级别,例如InnoDB使用的Next-Key Locks算法(下面有详解)
锁按照锁粒度分类可分为行级锁、页级锁、表级锁;按照实现思想来分可分为悲观锁和乐观锁。虽然下图中排他锁和共享锁都归入了粒度分类中,但其实排他锁和共享锁都是悲观锁的不同实现,也可以归入其子类。
表级锁是对整张表进行上锁,而行级锁是对表中的一行行的数据进行上锁。Mysql的默认存储引擎InnoDB支持表级锁和行级锁,默认使用行级锁,能先使用行级锁就先使用行级锁,不能使用行级锁就使用表级锁。InnoDB中行级锁是建立在表的索引上面,因此只有通过索引条件检索数据才使用行级锁,否则,InnoDB将使用表锁。另外一个常用存储引擎MyISAM只支持表级锁。
页级锁是锁定粒度介于行级锁和表级锁中间的一种锁,即使你只需要一行数据,它也会把那行数据所在的数据页整个锁住。存储引擎BDB采用页面锁或表级锁,默认为页面锁。因为页级锁使用少,所以这里不做过多描述。
表级锁:锁粒度大、锁住的范围大、使用资源少、加锁快、不会出现死锁、锁冲突概率高、并发程度低。
行级锁:锁粒度小、锁住的范围小、使用的资源多、加锁慢、会出现死锁、锁冲突概率低、并发程度高。
表级锁是锁定粒度最大的一种锁,对当前操作的整张表加锁,锁冲突概率高,并发程度低。表级锁的并发程度低,那为什么不使用行级锁而使用表级锁?当你需要锁住表中大多数的数据,使用表级锁性价比更高,而且事务比较复杂时,使用行级锁会出现死锁,不如使用表级锁。表级锁主要有排他锁(写锁)、共享锁(读锁)、意向排他锁、意向共享锁等四种锁。
排他锁就是写锁,也叫做X锁。当事务A给表加上排他锁时,事务A可以对该表进行读或者写,在排他锁被释放前,其它事务不能对该表加上任意类型的锁,即不允许其它事务读写该表数据。
共享锁就是读锁,也叫做S锁。当事务A给表加上共享锁,事务A就只能对该表做读操作,不能进行写操作,其它事务都可以给表加上共享锁,但不能加排他锁。在表上的共享锁全被释放完毕之前,不能给表加上排他锁。共享锁的作用是可以让多个事务同时读表数据,但只要有一个事务读表数据时,其它任何事务都不能来更新表数据。
X锁主要是用来防止事务写表时,其它并发事务来读写该表,造成第一类丢失修改、脏读等问题。
S锁主要是用来防止事务读表时,其它并发事务写该表,可以防止不可重复读,因此可以用X锁和S锁一起来实现REPEATABLE-READ隔离级别。
我们在使用数据库的实际过程中,往往没有注意到锁的使用,这是因为数据库使用的存储引擎往往都是在进行操作时根据SQL语句自动加锁,比如MyISAM存储引擎只支持表级锁,MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行更新操作(如UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预。当然在某些特殊情况下,若需要用户添加锁,也可手动添加锁。
意向锁的作用主要是让行级锁和表级锁共存,实现多粒度锁机制。意向锁分为意向排他锁(IX锁)和意向共享锁(IS锁)两种。
InnoDB支持行级锁和表级锁,默认是行级锁,InnoDB有以下两条规定:
(1)在事务获取表中某行的行共享锁之前,它必须首先获取该表中的IS锁或更强的锁。
(2)在事务获取表中某行的行排它锁之前,它必须首先获取该表中的IX锁。
一个表中意向共享锁和意向排他锁可以同时并存多个,只加意向锁不会导致锁冲突,但意向锁会与X锁或者S锁冲突,例如两种意向锁都会与X锁冲突,IX锁与S锁冲突,当发生冲突时,会导致后加锁的事务阻塞。
意向锁存在的意义-实现多粒度锁机制,例如InnoDB存储引擎,它同时支持表级锁和行级锁。若没有意向锁,我们对表加表级X锁之前,确定表中是否有行级X锁的方法只能是遍历每一行的索引上是否有行级X锁(需要检查是否有行级X锁的原因是避免死锁,若表上有X锁,行记录的索引上也有X锁,便相互锁死)。当我们使用意向锁,我们只需确定表上是否有IX锁,便确定是否能对表加X锁。意向锁是InnoDB自动加的,不需要用户干预。
表级锁兼容图
是否兼容 | X | IX | S | IS |
---|---|---|---|---|
X | 否 | 否 | 否 | 否 |
IX | 否 | 是 | 否 | 是 |
S | 否 | 否 | 是 | 是 |
IS | 否 | 是 | 是 | 是 |
如果一个锁与现有锁兼容,则将其授予请求的事务,但如果与现有锁冲突,则不授予该锁。事务等待直到冲突的现有锁被释放。如果锁定请求与现有锁定发生冲突,并且由于可能导致死锁而无法被授予,则会发生错误。
InnoDB默认使用的锁就是行级锁,但行级锁是加在每一行数据的索引项上面,只有通过索引条件检索数据才使用行级锁,否则InnoDB将使用表锁,当然这整个过程都是InnoDB全自动完成,无需用户插手。行级锁从功能性角度分为排他锁和共享锁,特性如表级排他锁和共享锁一样,这里就不做过多阐述。
InnoDB中行级锁从锁的范围来分有三种锁(算法),InnoDB使用Next-Key Locks进行行记录的操作,但当检索条件为唯一索引时(只查出一条行记录),将Next-Key Locks降为Record Locks,因为Gap Locks在这种情况下会失效。
(1)Record Locks: 记录锁,记录锁是对记录的索引项的锁定,加锁之后不能修改或者删除该记录。因为InnoDB使用的是聚集索引,因此表中一定存在索引(即使用户没定义主键或者不存在非空唯一索引,InnoDB还是会生成一个隐藏的聚集索引)
(2)Gap Locks: 间隙锁,间隙锁是对索引记录之间的间隙的锁定,被间隙锁锁定的范围内所有行记录之间的间隙均被锁定,用来防止事务在间隙锁锁定的范围之内进行数据的插入。例如以下查询语句就会在第一个值为10的记录之前以及最后一个值为20的记录之后加上间隙锁,把c1值在10与20之间的所有行记录都锁住,然后无法插入c1值在10和20之间的记录。注意共享和排他间隙锁之间没有区别,它们彼此不冲突,并且执行相同的功能。我们可以在同一间隙中加多个共享间隙锁或者排他间隙锁,因为间隙锁只要保证没有事务在这个范围内插入数据即可,因此间隙锁可以解决幻增(幻读产生原因包括幻增和幻减)问题。当查询的条件是唯一索引且只需要检索一行时,不会使用间隙锁(这不包括搜索条件仅包含多列唯一索引的某些列的情况;在这种情况下确实发生了间隙锁定)。关闭gap锁的方法-将事务隔离级别设置为RC 。
SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;
(3)Next-Key Locks: Next-Key Locks是Record Locks和Gap Locks的结合,是索引记录上的记录锁定和索引记录之前的间隙上的间隙锁定的组合。Next-Key Locks可以解决幻读问题。InnoDB使用Next-Key锁和REPEATABLE-READ隔离级别,达到了可串行化的隔离级别效果。
上文就提到使用行级锁可能会导致死锁,这是因为事务在给多行数据上锁时,不是一次性全部加锁,而是一行行的加锁,因此当事务对多个表的行记录或者一个表的多行记录进行操作时就可能会造成死锁。如事务A需要给同一个表的S1行记录和S2行记录上排他锁,事务B也需要给S1行记录和S2行记录上排他锁。事务A先成功给S1加锁,事务B成功给S2加锁,然后都在阻塞等待对方释放锁,这时就产生了死锁。或者是事务A先查询S3行记录,此时就上了S锁,之后事务B来删除S3行记录,但因为已经上了S锁,因此事务B阻塞等待,但此时事务A又来删除S3行记录,由于B先在等待S锁释放,因此事务A等待B先获得排他锁,因此事务A与事务B相互阻塞,形成死锁。但发生死锁后,InnoDB一般都能自动检测到,并使更小的事务回退并释放锁,另一个事务获得锁,继续完成事务。但死锁检测会导致速度变慢,性能变差,因此对于一些可以避免的死锁,我们是需要去解决的,而不是完全靠存储引擎。
(1)使用SHOW ENGINE INNODB STATUS命令来确定最新死锁的原因,以帮助调整应用程序以避免死锁。
(2)如果频繁出现死锁警告,一定不要大意多的调试信息。MySQL错误日志中记录了所有的关于死锁的信息。完成调试后,请禁用此选项。
(3)写事务语句时,保持事务小且持续时间短,减少死锁发生概率。
(4)及时将未更改的数据进行提交,不要长时间保持交互式mysql会话打开却没提交事务。
(5)修改事务中的多个表或同一表中的不同行记录时,每次都要以一致的顺序执行这些操作。
(6)在进行检索时选择最优的索引。那查询需要扫描较少的索引记录,因此设置锁也会少些,不容易产生死锁。
(7)实在不行使用表级锁,表级锁不会产生死锁。
乐观锁和悲观锁与其说是两种锁,不如说是两种思想,乐观锁乐观的认为不会发生读写冲突,若发生冲突了我再来补救,因此乐观锁是先进行操作,在提交更新时,再进行检测,若检测发现产生了冲突,再将错误信息返回给用户,让用户来决定是阻塞等待还是放弃更改;而悲观锁则悲观的认为不加约束一定会产生冲突,因此悲观锁在进行操作之前会先加锁,再操作,因此导致了并发事务中其它等待锁释放的事务阻塞等待,一定程度影响了并发性能。
数据库默认的锁机制其实实现的就是悲观锁,排他锁和共享锁其实就是悲观锁的不同实现。悲观锁依赖于数据库底层的锁机制来保证数据的一致性和完整性。但悲观锁并不是万能的,很多场景并不适用。悲观锁在进行操作之前会先加锁,再操作,因此导致了并发事务中其它等待锁释放的事务阻塞等待,一定程度影响了并发性能,因此一些较大的事务,可以选择使用乐观锁。
乐观锁是先操作再检测,检测出现问题再进行补救。需要用户自行实现,一般有两种实现方式,一是版本号,另外一种是时间戳
(1)版本号实现 :使用数据版本号机制实现,这是乐观锁最常用的一种实现方式,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。每次读取数据时,将version字段的值一同读出; 每次成功提交更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次读取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,并将version值加一,否则就更新失败。
(2)时间戳实现: 原理与版本号实现相同,在数据库表中增加一个时间戳类型的字段,每次读取数据时,将该字段读取出来,在更新提交的时候检查当前数据库中数据的时间戳和自己更新前读取到的时间戳进行对比,如果一致则予以更新,并将现在的时间戳替换原时间戳,否则就更新失败。
(1) InnoDB支持行级锁和表级锁,默认使用行级锁,但因为行级锁是建立在索引之上的,若是检索条件没有使用索引,则会使用表级锁。
(2) 意向锁的作用是为了实现多粒度锁机制,让表级锁和行级锁共存。
(3)InnoDB的行级锁使用算法是Next-Key Locks,在进行范围查询或者范围更新时同时锁住记录和间隙,可以用来避免幻读问题。
(4)InnoDB的隔离级别是RR(可重复读),InnoDB通过MVCC和锁机制来实现RR,MVCC的作用是避免了读写操作之间的事务阻塞,提高了并发性能,通过锁解决了写写操作之间的冲突。只有使用普通的select才会使用MVCC进行查询,也就是快照读。如果是查询使用的是select … lock in share mode,select … for update或者使用的是insert,update,delete 语句,则还是会使用悲观锁进行当前读,防止读写、写写冲突,缺点是会导致读写阻塞。
如果对InnoDB的MVCC实现原理和其工作机制以及快照读、当前读等知识不熟悉可以翻阅我的另外一篇博文-《InnoDB的MVCC实现原理(InnoDB如何实现MVCC以及MVCC的工作机制)》。
(5)InnoDB是MVCC机制和锁机制一起工作来进行并发控制的,使用普通的select进行查询时,不会加S锁,而是通过MVCC机制进行快照读,避免了其他事务写操作阻塞等待,不加S锁也意味着当该行数据正在被写时,进行读操作的事务也无需等待可以进行快照读。使用select … lock in share mode,select … for update进行查询时就会用到锁机制中的排他锁或者共享锁进行阻塞读操作,或者是使用的insert,update,delete 等更新语句时,也会使用排他锁将数据锁住。要注意的是InnoDB使用的是Next-Key Locks算法,当它使用行排他锁或者行共享锁锁住相应的行记录时,不仅锁记录项,而且锁住记录项中间的间隙,防止幻读。
(6)InnoDB使用Next-Key Locks和RR(可重复读)隔离级别来达到了可串行化级别,防止了丢失更新、脏读、不可重复读、幻读等问题。
(7)当然InnoDB的锁机制除了上述这些锁,还包括插入意图锁、自增锁等,读者感兴趣的话可以自行翻阅官方文档。