对于开发人员而言,对于锁和事务不会陌生。为了避免多线程同时读写共享数据发生竞争的问题,因此,锁是一种比较常用的方式。而事务的定义,主要体现在其基本特性:原子性,一致性,隔离性,持久性。之前的一篇文章有所提到过。但我相信很多开发人员对事务和锁的理解是不够深入的,包括我自己,始终觉得好像很容易回答,又答不出所以然,平常也应该有人问某条SQL会加什么锁呢,为什么我在方法上加了@Transactional就发生死锁了呢?本文将阐述InnoDB存储引擎中的锁及事务模型相关的细节,希望能帮助开发人员了解InnoDB如何有效处理并发事务等机制。
在InnoDB中有多种类型的锁,分别适用于不同的场景,如共享/排它锁,意向锁,记录锁等,下面将逐一对其进行阐述。
InnoDB实现了两种类型的标准行级锁:共享锁(S)和排它锁(X)。下面分别阐述其作用及区别:
如图中所示,事务1和事务2并发开启事务(操作1,2),并通过语句LOCK IN SHARE MODE对同一行记录请求了共享锁(操作3,4),此时,事务3开启(操作5),并尝试更新同一行记录(操作6),但此时会被事务1和事务2持有的共享锁阻塞,直到事务1和事务2均提交了事务(操作7,8),事务3的更新操作得以执行,并提交事务(操作9)。
如图中所示,事务1并发开启事务(操作1),并通过UPDATE语句请求到了行记录的排它锁(操作2);此时,事务2对同一行记录进行读操作(操作3),由于InnoDB的默认事务隔离级别为REPEATABLE READ,因此该操作得以执行;事务3开启事务(操作4),并尝试修改同一行记录(操作5),但该行记录的排它锁已被事务1获取,因此被阻塞,直到超时重新开启事务,此时,提交事务1,事务3的更新操作(操作6)方可执行。
InnoDB支持多粒度锁,允许行锁和表锁共存,为了支持这种多粒度锁的场景,InnoDB提出了另一种锁--意向锁。意向锁属于表级锁,用来表明事务想要在表中的行上获取什么类型的锁(共享或独占),不同的事务可以在同一个表上获取不同类型的意向锁,但是第一个事务获取表上的意向排它锁(IX)可以阻塞其他事务获取该表上的任何S或X锁。相反,获取表上的意向共享锁(IS)的第一个事务将阻塞其他事务获取表上的任何X锁。两阶段过程允许按顺序解决锁定请求,而不阻塞锁定和对应的兼容操作。InnoDB这样定义了上述两种意向锁:
意向锁同时需要遵循以下协议:
这些规则可以方便地通过下面的锁定类型兼容性表格来概括:
X | IX | S | IS | |
X | 冲突 | 冲突 | 冲突 | 冲突 |
IX | 冲突 | 兼容 | 冲突 | 兼容 |
S | 冲突 | 冲突 | 兼容 | 兼容 |
IS | 冲突 | 兼容 | 兼容 | 兼容 |
如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。意向锁只会阻塞其它事务对全表的请求,例如LOCK TABLES …WRITE,意向锁的主要目的是表明该事务将要或者正在对表中的记录加锁。
记录锁针对的是索引记录。比如,SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;将阻塞其他事务对t.c1 = 10记录进行更新,插入,或删除。
记录锁总是锁定索引记录,即使表里并没有定义索引,对于这种情况,InnoDB将为该表创建隐藏的聚簇索引,用该索引来锁定记录。
间隙锁是一种位于索引记录间锁间的锁(包括位于第一条索引记录之前,或最后一条索引记录之后的间隙),而不锁住记录本身。比如,语句SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;将阻塞其他事务插入t.c1=15,而不管t.c1列中是否已经有15这个值,因为处于范围值的间隙均已被加锁。但对于查询间隙锁锁定范围内的索引值,若该值不存在,则不会阻塞事务,如SELECT c1 FROM t WHERE c1 = 15 FOR UPDATE,若c1=15不存在,则不会阻塞该事务。
间隙锁可以锁住单个索引值,或多个索引值,甚至是空值。间隙锁通常是性能和并发性的折衷部分,往往用在事务隔离级别中,比如InnoDB的默认隔离级别REAPTABLE READ,在范围查询时会加上间隙锁,这样不会造成幻读(Phantom Read)现象(即统一事务多次读取,结果不一样),但当间隙锁已经成为性能瓶颈,则可以尝试降低事务隔离级别为读已提交(Read Committed),则可以不使用间隙锁。因此,间隙锁的作用就是防止事务幻读。
对于使用唯一索引锁定单行,以查询唯一记录的语句,不需要间隙锁。(但这不包括查询条件仅包含多列唯一索引的某些情况,在这种情况下,会使用间隙锁)例如,如果id列具有唯一索引,则以下语句仅使用 对于id值为100的行的索引记录锁定,并且对于其他会话是否在前一个间隙中插入记录,并不会加间隙锁:
若id并没有索引,或不是唯一索引,则上面的语句将锁定之前的间隙。还值得注意的是,同一间隙上的冲突的锁可以被不同的事务持有, 比如,事务A可以在间隙上保持共享间隙锁(间隙S锁),而事务B在同一间隙上保持排它间隙锁(间隙X锁)。 允许冲突间隙锁的原因是,如果从索引中清除记录,不同事务在该记录上持有的间隙锁必须被合并。
InnoDB中的间隙锁是“纯粹禁止的”,这意味着它们只阻止其他事务插入间隙,但不阻止其他事务在相同的间隙上获取间隙锁,因此,间隙X锁与间隙S锁具有相同的效果。
Next-Key Lock是记录锁和间隙锁的组合,锁定记录本身且锁定范围。主要目的是解决幻读的问题。
插入意向锁是在行插入之前通过INSERT操作设置的一种间隙锁。插入意向锁表示的是多个事务并发插入数据到相同的索引间隙中,但插入的位置并不同,这时彼此并不需要彼此等待。比如,假设有索引值为4和7,两个事务分别插入索引值5和6,尽管5和6均在[4, 7]间隙中,但这个两个事务并不会相互阻塞。
自增锁是针对执行插入操作中含有自增列(AUTO_INCREMENT)时的一种表级锁。在最简单的情况下,如果一个事务正在向表中插入值,则任何其他事务必须等待对该表执行自己的插入操作,以便第一个事务插入的行获取到连续的主键值。用户可以通过innodb_autoinc_lock_mode配置选项指定自增锁使用的算法,它允许您选择如何在可预测的自动递增值序列和插入操作的最大并发性之间进行权衡,具体细节可见这里。
单纯谈论某条SQL是否加锁,或加什么样的锁是没有意义的,我们还需要一些上下文,如当前的事务隔离级别,列是否为主键,是否有索引,是否是唯一索引等等。下面将通过一些例子来验证部分加锁逻辑,有可能由于不同的MySQL版本产生差异。
MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处是:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。
在MVCC并发控制中,读操作可以分成两类:快照读(Snapshot Read)与当前读(Current Read)。快照读(官方解释为非阻塞的一致性读),读取的是记录的可见版本(有可能是历史版本),不用加锁。当前读(官方解释为锁读),读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:
接下来我们将分别实践(MySQL 版本为5.7.11),在不同隔离级别,包括读已提交(Read Committed,RC)和可重复读(Reaptable Read,RR)下,InnoDB如何执行更新(如DELETE)的加锁逻辑。
如图所示,事务1先对id=8的记录加上X锁(操作1),然后,事务2可以获取到其他行记录的X锁,然而在请求id=8这条记录的X锁时被阻塞,因此,当WHERE条件中的列为主键时,将对对应的聚簇索引记录加X锁。
如图所示,事务1先对id=2的记录加上X锁(操作1),事务2可以获取到其他id记录的X锁,但不能获取到id=2或name='lin'的X锁,因此,当WHERE条件中的列为唯一索引时,将对对应索引值的记录和关联的聚簇索引记录加X锁。
如图所示,事务1先对id=2的记录加上X锁(操作1),事务2就不能再获取到name='haox1'或name='haox11'的X锁了,因此,当WHERE条件中的列为非唯一索引时,将对对应索引值的多条记录和关联的多条聚簇索引记录加X锁。
如图所示,事务1先对id=2的记录加上X锁(操作1),事务2以id列查询时均被阻塞(操作2,3),以id=2对应记录的name列值查询均被阻塞(操作6,7),以id=2对应记录的name列值以外的值查询均可以获得X锁(操作4,5),插入新记录时被允许的(操作8),因此,当WHERE条件中的列没有索引时,由于查询列上没有索引,因此只能走聚簇索引,进行全表扫描,这使得聚簇索引上所有的记录都被加上了X锁(锁住的是聚簇索引记录),在通过主键列name查询时,若以id=2对应记录的name列值以外的值作查询,则可以请求到X锁(MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁)。
如图所示,事务1先对id=11的记录加上X锁(操作1),当事务2尝试获取id=11对应记录的X锁(操作3)时被阻塞,除此之外,当事务尝试插入(id=11, name='haoc')记录时也被阻塞,从锁信息中可以看出,是被间隙锁(GAP)所阻塞,这正是隔离级别可重复读(RR)可以防止幻读的原因,即通过间隙锁(GAP)防止同一事务中多次当前读产生不同结果。当WHERE条件对应的记录不存在时(如SELECT * FROM users2 WHERE id = 404 FOR UPDATE),此时依然会有GAP被加上锁,若以上图为例,id为(13, 404)和(404, ∞)的区间都将会加上间隙锁。
下图比较清晰地展示了记录X锁和GAP锁:
如图所示,事务1先对id=11的记录加上X锁(操作1),此时由于id列没有建立索引,因此只能触发全表扫描,这将导致聚簇索引上的所有记录被加上X锁,同时,还会对记录间的间隙加上间隙锁,因此导致事务2的所有写锁请求被阻塞(如操作2,3),这种情况将导致严重的性能和死锁问题,应尽量避免,加锁示意图可见下图:
可序列化(Serializable)作为最高隔离级别,在执行类似DELETE FROM t WHERE id = 10;这样的当前读操作时,与Repeatable Read(RR)隔离级别一致。只是在执行类似SELECT * FROM t WHERE id = 10;这样的快找读操作时,Serializable隔离级别将对记录加上共享锁,这里有所提到,因此,Serializable隔离级别下,不存在MVCC式的快照读。
对于具体的加锁情况,官方也有比较清晰的解释。
在InnoDB的事务模型中,目标是将多版本数据库的最佳性能与传统的两阶段锁相结合。InnoDB在行级别执行锁定,默认情况下以Oracle风格将查询作为非锁定一致性读取(快照读)运行。通常,允许多个用户锁定InnoDB表或行的任何随机子集中的每一行,从而不会导致InnoDB内存耗尽。
事务隔离是数据库操作的基础之一。隔离性(Isolation)是ACID中的I。隔离级别是在多个事务正在进行更新和查询操作时,对性能和可靠性,一致性和可重复性之间的权衡设置。InnoDB支持SQL1992标准中的四种隔离级别(隔离级别从高到低):READ UNCOMMITTED(读未提交),READ COMMITTED(读已提交),REPEATABLE READ(可重复读),SERIALIZABLE(序列化)。InnoDB默认隔离级别可重复读,这篇文章也有所提到。开发人员可通过命令行启动参数--transaction-isolation或配置文件中transaction-isolation来指定数据库默认隔离级别。InnoDB主要通过锁策略来实现不同的隔离级别。
以下描述了MySQL如何支持上述几种隔离级别:
这是InnoDB默认的隔离级别。对于一致性读(上面提到过的快照读),其与读已提交有一个重要的区别:同一事务中的所有一致性读均读取首次读的快照数据版本,即同一事务中,相同的SELECT语句的EXPLAIN执行计划一致。对于锁读(上文提到过的当前读),如SELECT ... FOR UPDATE,SELECT ... LOCK IN SHARE MODE,DELETE,UPDATE等语句,加锁情况取决于WHERE条件列是否有唯一索引查询或范围查询,正如上文中的实践。
对于一致性读,这是一种类Oracle的隔离级别:在同一事务中,都将读取和更新最新的数据快照,即即同一事务中,相同的SELECT语句将以最新快照为准。对于锁读,InnoDB只会对索引记录加锁,而不会加额外的间隙锁。该隔离级别有可能出现幻读现象。
该隔离级别最低。允许事务读取到其他事先还未提交的数据版本,因此可能出现脏读。
该隔离级别最高。对于一致性读,当autocommit为false时,会在SELECT语句后加上共享锁LOCK IN SHARE MODE,当autocommit为true时,则不会加共享锁。对于锁读,则与可重复读一致。
在InnoDB中,所有的用户动作均发生在事务内。如果启用了自动提交(autocommmit),那么每条SQL语句将在单独的事务中执行。默认情况下,MySQL均自动提交事务。开发人员可通过BEGIN,COMMIT或ROLLBACK语句在同一事务中执行多条SQL语句。如果在连接会话中,将autocommit设置为false(SET autocommit = 0),会话中将总会开启一个事务,COMMIT和ROLLBACK将终止当前事务并开启一个新的事务。COMMIT操作意味着该事务执行的所有更新操作将被持久化,即对其他会话可见。
InnoDB中的死锁,指的是不同事务由于相互持有各自请求的锁,导致均不能继续处理后续操作的情况。比如,多个事务以不同的顺序并发请求多行记录的X锁,则有可能发生死锁;又或多个事务在获取相同的范围记录锁和间隙锁,由于时序问题,各自获取到部分锁,也可能造成死锁。
如图所示,事务1先对i=1的记录加上S锁(操作1),事务2尝试删除id=1的记录请求X锁(操作2),由于S锁不兼容X锁,导致事务2被阻塞,此时事务1也尝试删除id=1的记录请求X锁(操作3),但由于事务2先于请求X锁,这时就发生事务2将等待事务1释放S锁,事务1将等待事务2释放X锁,导致死锁,InnoDB检测到死锁情况,主动放弃事务2(这里显然放弃事务2的成本小于事务1)。
InnoDB默认开启了死锁检测(配置参数),使得InnoDB可以自动发现发生死锁的事务,并会滚最小的事务(插入,更新及删除的记录行数最少)。若希望InnoDB能检测到表锁,需要设置innodb_table_locks = 1(默认)和autocommit = 0,否则,InnoDB将不能发现由LOCK TABLES语句造成的死锁,但这时可通过系统变量innodb_lock_wait_timeout来解决。死锁检测本身也会消耗系统资源,特别在高并发情况下,若同一锁上等待的线程过多,将降低数据库性能,这时可以通过禁用配置选项innodb_deadlock_detect来关闭死锁检测,此后则通过innodb_lock_wait_timeout来回滚死锁的事务。
死锁本身是一个并发程序都会面临的问题,避免死锁也同样有一些比较通用的方法,针对数据库而言,通常有以下一些避免和解决死锁的方法:
以上,则是有关InnoDB的锁及事务模型的基础,希望在遇到相关问题时,能迎刃而解。