MySQL InnoDB存储引擎(三):锁及事务模型
2016 年 09 月 10 日
mysql

    对于开发人员而言,对于事务不会陌生。为了避免多线程同时读写共享数据发生竞争的问题,因此,是一种比较常用的方式。而事务的定义,主要体现在其基本特性:原子性一致性隔离性持久性之前的一篇文章有所提到过。但我相信很多开发人员对事务的理解是不够深入的,包括我自己,始终觉得好像很容易回答,又答不出所以然,平常也应该有人问某条SQL会加什么锁呢,为什么我在方法上加了@Transactional就发生死锁了呢?本文将阐述InnoDB存储引擎中的事务模型相关的细节,希望能帮助开发人员了解InnoDB如何有效处理并发事务等机制。

  • InnoDB锁(InnoDB Locking)

  • InnoDB中有多种类型的锁,分别适用于不同的场景,如共享/排它锁意向锁记录锁等,下面将逐一对其进行阐述。

  • 共享/排它锁(Shared and Exclusive Locks)

  • InnoDB实现了两种类型的标准行级锁:共享锁(S)排它锁(X)。下面分别阐述其作用及区别:

    共享锁(S):允许持有该锁的事务读取某一行记录。持有共享锁的事务允许其他事务读取该行记录,并且获取到该记录的共享锁,但不允许其他事务对该记录进行更新删除。如下图所示:

    如图中所示,事务1事务2并发开启事务(操作1,2),并通过语句LOCK IN SHARE MODE对同一行记录请求了共享锁(操作3,4),此时,事务3开启(操作5),并尝试更新同一行记录(操作6),但此时会被事务1事务2持有的共享锁阻塞,直到事务1事务2均提交了事务(操作7,8),事务3的更新操作得以执行,并提交事务(操作9)。

    排它锁(X):允许持有该锁的事务更新或删除某一行。持有排它锁的事务不允许其他事务对同一行记录加锁。根据事务隔离级别排它锁将可能阻塞其他对同一行记录进行更新的事务,也可能阻塞其他对同一行记录进行读取的事务。不过InnoDB默认的事务隔离级别REPEATABLE READ,该隔离级别允许较高的并发度,允许其他事务读取已经被持有了排它锁的记录,这就是一致性读。如下图所示:

    如图中所示,事务1并发开启事务(操作1),并通过UPDATE语句请求到了行记录的排它锁(操作2);此时,事务2对同一行记录进行读操作(操作3),由于InnoDB的默认事务隔离级别为REPEATABLE READ,因此该操作得以执行;事务3开启事务(操作4),并尝试修改同一行记录(操作5),但该行记录的排它锁已被事务1获取,因此被阻塞,直到超时重新开启事务,此时,提交事务1,事务3的更新操作(操作6)方可执行。

  • 意向锁(Intention Locks)

  • InnoDB支持多粒度锁,允许行锁表锁共存,为了支持这种多粒度锁的场景,InnoDB提出了另一种锁--意向锁意向锁属于表级锁,用来表明事务想要在表中的行上获取什么类型的锁(共享独占),不同的事务可以在同一个表上获取不同类型的意向锁,但是第一个事务获取表上的意向排它锁(IX)可以阻塞其他事务获取该表上的任何SX锁。相反,获取表上的意向共享锁(IS)的第一个事务将阻塞其他事务获取表上的任何X锁。两阶段过程允许按顺序解决锁定请求,而不阻塞锁定和对应的兼容操作。InnoDB这样定义了上述两种意向锁:

    意向共享锁(IS):事务T尝试在表中某些行上设置共享锁(S),如使用 SELECT ... LOCK IN SHARE MODE
    意向排它锁(IX):事务T尝试在表中某些行上设置排它锁(X),如使用SELECT ... FOR UPDATE

    意向锁同时需要遵循以下协议:

    在事务可以获取表t中的行的S锁之前,它必须首先在t上获取IS或更强的锁
    在事务可以获取行上的X锁之前,它必须首先在t上获取IX锁
  • 锁的兼容性

  • 锁兼容:当一个事务已经获取R行共享锁,另外一个事务也可以获取该行的共享锁;
    锁冲突:当一个事务已经获取R行排它锁,只有当该事务释放R行排它锁,另外一个事务才能获取该行的共享锁、排它锁。

    这些规则可以方便地通过下面的锁定类型兼容性表格来概括:

    X IX S IS
    X 冲突 冲突 冲突 冲突
    IX 冲突 兼容 冲突 兼容
    S 冲突 冲突 兼容 兼容
    IS 冲突 兼容 兼容 兼容

    如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。意向锁只会阻塞其它事务对全表的请求,例如LOCK TABLES …WRITE意向锁的主要目的是表明该事务将要或者正在对表中的记录加锁。

  • 记录锁(Record Locks)

  • 记录锁针对的是索引记录。比如,SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;将阻塞其他事务对t.c1 = 10记录进行更新,插入,或删除。

    记录锁总是锁定索引记录,即使表里并没有定义索引,对于这种情况,InnoDB将为该表创建隐藏的聚簇索引,用该索引来锁定记录。

  • 间隙锁(Gap Locks)

  • 间隙锁是一种位于索引记录间锁间的锁(包括位于第一条索引记录之前,或最后一条索引记录之后的间隙),而不锁住记录本身。比如,语句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的行的索引记录锁定,并且对于其他会话是否在前一个间隙中插入记录,并不会加间隙锁

    SELECT * FROM t WHERE id = 100;
        

    若id并没有索引,或不是唯一索引,则上面的语句将锁定之前的间隙。还值得注意的是,同一间隙上的冲突的锁可以被不同的事务持有, 比如,事务A可以在间隙上保持共享间隙锁(间隙S锁),而事务B在同一间隙上保持排它间隙锁(间隙X锁)。 允许冲突间隙锁的原因是,如果从索引中清除记录,不同事务在该记录上持有的间隙锁必须被合并。

    InnoDB中的间隙锁是“纯粹禁止的”,这意味着它们只阻止其他事务插入间隙,但不阻止其他事务在相同的间隙上获取间隙锁,因此,间隙X锁间隙S锁具有相同的效果。

  • Next-Key Lock

  • Next-Key Lock记录锁间隙锁的组合,锁定记录本身且锁定范围。主要目的是解决幻读的问题。

  • 插入意向锁(Insert Intention Locks)

  • 插入意向锁是在行插入之前通过INSERT操作设置的一种间隙锁插入意向锁表示的是多个事务并发插入数据到相同的索引间隙中,但插入的位置并不同,这时彼此并不需要彼此等待。比如,假设有索引值为4和7,两个事务分别插入索引值5和6,尽管5和6均在[4, 7]间隙中,但这个两个事务并不会相互阻塞。

  • 自增锁(AUTO-INC Locks)

  • 自增锁是针对执行插入操作中含有自增列(AUTO_INCREMENT)时的一种表级锁。在最简单的情况下,如果一个事务正在向表中插入值,则任何其他事务必须等待对该表执行自己的插入操作,以便第一个事务插入的行获取到连续的主键值。用户可以通过innodb_autoinc_lock_mode配置选项指定自增锁使用的算法,它允许您选择如何在可预测的自动递增值序列插入操作的最大并发性之间进行权衡,具体细节可见这里

  • InnoDB如何执行加锁逻辑

  • 单纯谈论某条SQL是否加锁,或加什么样的锁是没有意义的,我们还需要一些上下文,如当前的事务隔离级别,列是否为主键是否有索引是否是唯一索引等等。下面将通过一些例子来验证部分加锁逻辑,有可能由于不同的MySQL版本产生差异。

  • 快照读 vs 当前读

  • MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处是:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。

    在MVCC并发控制中,读操作可以分成两类:快照读(Snapshot Read)当前读(Current Read)快照读(官方解释为非阻塞的一致性读),读取的是记录的可见版本(有可能是历史版本),不用加锁。当前读(官方解释为锁读),读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

    在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

    快照读:简单的select操作,属于快照读,不加锁,如:
    SELECT * FROM table WHERE ?;
            
    当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁,如:
    SELECT * FROM table WHERE ? LOCK IN SHARE MODE;
    SELECT * FROM table WHERE ? FOR UPDATE;
    INSERT INTO table VALUES (...);
    UPDATE table SET ? WHERE ?;
    DELETE FROM table WHERE ?;
            
  • InnoDB常用场景的加锁逻辑

  • 接下来我们将分别实践(MySQL 版本为5.7.11),在不同隔离级别,包括读已提交(Read Committed,RC)可重复读(Reaptable Read,RR)下,InnoDB如何执行更新(如DELETE)的加锁逻辑。

  • 事务隔离级别为读已提交(RC)时

  • WHERE条件列为主键时:

    如图所示,事务1先对id=8的记录加上X锁(操作1),然后,事务2可以获取到其他行记录的X锁,然而在请求id=8这条记录的X锁时被阻塞,因此,当WHERE条件中的列为主键时,将对对应的聚簇索引记录加X锁。

    WHERE条件列为唯一索引时:

    如图所示,事务1先对id=2的记录加上X锁(操作1),事务2可以获取到其他id记录的X锁,但不能获取到id=2name='lin'的X锁,因此,当WHERE条件中的列为唯一索引时,将对对应索引值的记录和关联的聚簇索引记录加X锁。

    WHERE条件列为非唯一索引时:

    如图所示,事务1先对id=2的记录加上X锁(操作1),事务2就不能再获取到name='haox1'name='haox11'的X锁了,因此,当WHERE条件中的列为非唯一索引时,将对对应索引值的多条记录和关联的多条聚簇索引记录加X锁。

    WHERE条件列为没有索引时:

    如图所示,事务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方法,把不满足条件的记录放锁)。

  • 事务隔离级别为可重复读(RR)时

  • WHERE条件列为主键时,与隔离级别为RC时一致;
    WHERE条件列为唯一索引时,与隔离级别为RC时一致;
    WHERE条件列为非唯一索引时:

    如图所示,事务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锁

    WHERE条件列为没有索引时:

    如图所示,事务1先对id=11的记录加上X锁(操作1),此时由于id列没有建立索引,因此只能触发全表扫描,这将导致聚簇索引上的所有记录被加上X锁,同时,还会对记录间的间隙加上间隙锁,因此导致事务2的所有写锁请求被阻塞(如操作2,3),这种情况将导致严重的性能和死锁问题,应尽量避免,加锁示意图可见下图:

  • 事务隔离级别为可序列化(Serializable)时

  • 可序列化(Serializable)作为最高隔离级别,在执行类似DELETE FROM t WHERE id = 10;这样的当前读操作时,与Repeatable Read(RR)隔离级别一致。只是在执行类似SELECT * FROM t WHERE id = 10;这样的快找读操作时,Serializable隔离级别将对记录加上共享锁这里有所提到,因此,Serializable隔离级别下,不存在MVCC式的快照读。

    对于具体的加锁情况,官方也有比较清晰的解释

  • InnoDB事务模型(InnoDB Transaction Model)

  • InnoDB的事务模型中,目标是将多版本数据库的最佳性能与传统的两阶段锁相结合。InnoDB在行级别执行锁定,默认情况下以Oracle风格将查询作为非锁定一致性读取(快照读)运行。通常,允许多个用户锁定InnoDB表或行的任何随机子集中的每一行,从而不会导致InnoDB内存耗尽。

  • 事务隔离级别(Transaction Isolation Levels)

  • 事务隔离是数据库操作的基础之一。隔离性(Isolation)ACID中的I。隔离级别是在多个事务正在进行更新和查询操作时,对性能和可靠性,一致性和可重复性之间的权衡设置。InnoDB支持SQL1992标准中的四种隔离级别(隔离级别从高到低):READ UNCOMMITTED(读未提交)READ COMMITTED(读已提交)REPEATABLE READ(可重复读)SERIALIZABLE(序列化)InnoDB默认隔离级别可重复读这篇文章也有所提到。开发人员可通过命令行启动参数--transaction-isolation或配置文件中transaction-isolation来指定数据库默认隔离级别。InnoDB主要通过锁策略来实现不同的隔离级别。

    以下描述了MySQL如何支持上述几种隔离级别:

    可重复读(REPEATABLE READ)

    这是InnoDB默认的隔离级别。对于一致性读(上面提到过的快照读),其与读已提交有一个重要的区别:同一事务中的所有一致性读均读取首次读的快照数据版本,即同一事务中,相同的SELECT语句的EXPLAIN执行计划一致。对于锁读(上文提到过的当前读),如SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEDELETEUPDATE等语句,加锁情况取决于WHERE条件列是否有唯一索引查询或范围查询,正如上文中的实践。

    读已提交(READ COMMITTED)

    对于一致性读,这是一种类Oracle的隔离级别:在同一事务中,都将读取和更新最新的数据快照,即即同一事务中,相同的SELECT语句将以最新快照为准。对于锁读InnoDB只会对索引记录加锁,而不会加额外的间隙锁。该隔离级别有可能出现幻读现象。

    读未提交(READ UNCOMMITTED)

    该隔离级别最低。允许事务读取到其他事先还未提交的数据版本,因此可能出现脏读

    序列化(SERIALIZABLE)

    该隔离级别最高。对于一致性读,当autocommit为false时,会在SELECT语句后加上共享锁LOCK IN SHARE MODE,当autocommit为true时,则不会加共享锁。对于锁读,则与可重复读一致。

  • 自动提交,提交及回滚(autocommit, Commit, and Rollback)

  • InnoDB中,所有的用户动作均发生在事务内。如果启用了自动提交(autocommmit),那么每条SQL语句将在单独的事务中执行。默认情况下,MySQL均自动提交事务。开发人员可通过BEGINCOMMITROLLBACK语句在同一事务中执行多条SQL语句。如果在连接会话中,将autocommit设置为false(SET autocommit = 0),会话中将总会开启一个事务,COMMITROLLBACK将终止当前事务并开启一个新的事务。COMMIT操作意味着该事务执行的所有更新操作将被持久化,即对其他会话可见。

  • InnoDB中的死锁

  • 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来回滚死锁的事务。

  • 如何减少死锁

  • 死锁本身是一个并发程序都会面临的问题,避免死锁也同样有一些比较通用的方法,针对数据库而言,通常有以下一些避免和解决死锁的方法:

    使用SHOW ENGINE INNODB STATUS查询最近的锁争用情况;
    如果确实出现频繁的死锁提醒,可以启用配置选项innodb_print_all_deadlocks打印死锁详细错误信息;
    如果由于死锁而出错,则始终准备重新开启事务,死锁并不危险;
    保持事务小并且持续时间短,降低死锁冲突的发生;
    一旦更新完数据,则立即提交事务,而不是长时间开启会话而不提交事务;
    如果正在使用锁度(如SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE),可以降低隔离级别为读已提交
    如果在一个事务中,需要对多张表操作或同一张表需要操作不同的数据集,则应尽量保证这些操作的顺序每次都一致,类似于并发应用中的不同操作保证加锁顺序一致;
    尽量建立良好的索引,这样可以避免过多的扫表操作,减少记录锁或间隙锁;
    尽量少用锁读(SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE),而是用快照读,大部分情况是没有必要使用锁读的,若确实需要使用,可以将隔离级别降低为读已提交
  • 总结

  • 以上,则是有关InnoDB的锁及事务模型的基础,希望在遇到相关问题时,能迎刃而解。

  • 参考文献

好人,一生平安。