锁与事务

3/26/2022 MySQLInnoDB

# InnoDB中的锁

# 锁的分类(行锁)

# 共享锁和排他锁

# 意向锁

InnoDB存储引擎支持多粒度(granular)锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度(fine granularity)上进行加锁。

img

若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。例如图6-3,如果需要对页上的记录r进行上X锁,那么分别需要对数据库A、表、页上意向锁IX,最后对记录r上X锁。若其中任何一个部分导致等待,那么该操作需要等待粗粒度锁的完成。举例来说,在对记录r加X锁之前,已经有事务对表1进行了S表锁,那么表1上已存在S锁,之后事务需要对记录r在表1上加上IX,由于不兼容,所以该事务需要等待表锁操作的完成。

InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:

1)意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁

2)意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁

由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫以外的任何请求。

# 意向锁的作用是什么呢?

假如没有意向锁,我们执行lock table read命令来申请表锁,让整个表只能读,在获得表级别的只读锁之前,需要执行的步骤是:

  1. 数据库会先判断当前表是否加了表级别的排斥锁,因为这个时候要是加了排斥锁,是只能由加了那个排斥锁的事务来更新数据,其他事务都不能读数据,只能阻塞等待。

  2. 如果当前表没有加表级别的排斥锁,那么就需要对每一行数据进行判断,判断是否加了行级别的X锁,如果加了只能阻塞等待,这样需要对一行进行判断,性能开销太大了。

所以才有了意向锁,在获得表级别的只读锁之前,需要执行的步骤是:

  1. 第一步还是跟上面的步骤一一样

  2. 第二步只需要判断当前锁是否加了表级别的意向排斥锁,因为如果加了意向排斥锁,说明正在有事务在对数据加行锁,对数据进行更新,这样避免了对每一行数据进行判断,判断是否加了行锁。

# 一致性非锁定读

  1. 一致性的非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB存储引擎会去读取行的一个快照数据。

  2. 之所以称其为非锁定读,因为不需要等待访问的行上X锁的释放。快照数据是指该行的之前版本的数据,该实现是通过undo段来完成。而undo用来在事务中回滚数据,因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。

  3. 一个行记录可能有不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control,MVCC)。

  4. 在事务隔离级别READ COMMITTED和REPEATABLE READ(InnoDB存储引擎的默认事务隔离级别)下,InnoDB存储引擎使用非锁定的一致性读。然而,对于快照数据的定义却不相同。在READ COMMITTED事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本

  5. 对于READ COMMITTED的事务隔离级别而言,从数据库理论的角度来看,其违反了事务ACID中的I的特性,即隔离性。

img

# 一致性锁定读

在默认配置下,即事务的隔离级别为REPEATABLE READ模式下,InnoDB存储引擎的SELECT操作使用一致性非锁定读。但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。而这要求数据库支持加锁语句,即使是对于SELECT的只读操作。InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作:

SELECT…FOR UPDATE

SELECT…LOCK IN SHARE MODE

SELECT…FOR UPDATE对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。SELECT…LOCK IN SHARE MODE对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。

对于一致性非锁定读,即使读取的行已被执行了SELECT…FORUPDATE,也是可以进行读取的,这和之前讨论的情况一样。此外,SELECT…FOR UPDATE,SELECT…LOCK IN SHARE MODE必须在一个事务中,当事务提交了,锁也就释放了。因此在使用上述两句SELECT锁定语句时,务必加上BEGIN,START TRANSACTION或者SET AUTOCOMMIT=0。

就是实时读,就是读取的是实时的数据,而不快照数据,读的时候会加Next-Key Lock锁住当前的记录,以及左右两个区间的间隙,这样在读的时候就不能往我们的查询范围插入数据了。

# 锁(行锁)的算法

# 分类

InnoDB存储引擎有3种行锁的算法,其分别是:

  • Record Lock:单个行记录上的锁

  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身

  • Next-Key Lock∶Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身

# 临键锁退化为行记录锁

当查询的索引含有唯一属性时,InnoDB存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围。

img

# 行锁,间隙锁和临键锁与命中索引的切换

  • 如果查询没有命中索引,则退化为表锁;
  • 如果等值查询唯一索引且命中唯一一条记录,则退化为行锁;
  • 如果等值查询唯一索引且没有命中记录,则退化为临近结点的间隙锁;
  • 如果等值查询非唯一索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定);如果命中记录,则锁定所有命中行的临键锁,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询唯一索引或查询非唯一索引且命中记录,则锁定所有命中行的临键锁 ,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定)。

# 临键锁遇到辅助索引

正如前面所介绍的,Next-Key Lock降级为Record Lock仅在查询的列是唯一索引的情况下。若是辅助索引,则情况会完全不同。

实例分析:

CREATE TABLE z(a INT,b INT,PRIMARY KEY(a),KEY(b));
INSERT INTO z SELECT 1,1;
INSERT INTO z SELECT 3,1;
INSERT INTO z SELECT 5,3;
INSERT INTO z SELECT 7,6;
INSERT INTO z SELECT 10,8;

在会话A中执行下面的SQL语句:SELECT*FROM z WHERE b=3 FOR UPDATE;

这时SQL语句通过索引列b进行查询,因此其使用传统的Next-Key Locking技术加锁,并且由于有两个索引,其需要分别进行锁定。

对于聚集索引,其仅对列a等于5的索引加上Record Lock。而对于辅助索引,其加上的是Next-Key Lock,锁定的范围是(1,3),特别需要注意的是,InnoDB存储引擎还会对辅助索引下一个键值加上gap lock,即还有一个辅助索引范围为(3,6)的锁。

会话B运行下列语句均阻塞:

SELECT*FROM z WHERE a=5 LOCK IN SHARE MODE;
INSERT INTO z SELECT 4,2;
INSERT INTO z SELECT 6,5;

第一个SQL语句不能执行,因为在会话A中执行的SQL语句已经对聚集索引中列a=5的值加上X锁,因此执行会被阻塞。第二个SQL语句,主键插入4,没有问题,但是插入的辅助索引值2在锁定的范围(1,3)中,因此执行同样会被阻塞。第三个SQL语句,插入的主键6没有被锁定,5也不在范围(1,3)之间。但插入的值5在另一个锁定的范围(3,6)中,故同样需要等待。

# 总结

  1. Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致Phantom Problem问题(幻读)的产生。例如在上面的例子中,会话A中用户已经锁定了b=3的记录。若此时没有Gap Lock锁定(3,6),那么用户可以插入索引b列为3的记录,这会导致会话A中的用户再次执行同样查询时会返回不同的记录,即导致Phantom Problem问题的产生。

  2. 最后需再次提醒的是,对于唯一键值的锁定,Next-Key Lock降级为Record Lock仅存在于查询所有的唯一索引列。若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么查询其实是range类型查询,而不是point类型查询,故InnoDB存储引擎依然使用Next-Key Lock进行锁定。

# 幻读解决方案

在默认的事务隔离级别下,即REPEATABLE READ下,InnoDB存储引擎采用Next-Key Locking机制来避免Phantom Problem(幻像问题)。这点可能不同于与其他的数据库,如Oracle数据库,因为其可能需要在SERIALIZABLE的事务隔离级别下才能解决Phantom Problem。

Phantom Problem是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。

img

InnoDB存储引擎采用Next-Key Locking的算法避免Phantom Problem。对于上述的SQL语句SELECT*FROM t WHERE a>2 FOR UPDATE,其锁住的不是5这单个值,而是对(2,+∞)这个范围加了X锁。因此任何对于这个范围的插入都是不被允许的,从而避免Phantom Problem。

# 锁问题

# 丢失更新

img

丢失更新是另一个锁导致的问题,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致

分析

1)事务T1将行记录r更新为v1,但是事务T1并未提交。

2)与此同时,事务T2将行记录r更新为v2,事务T2未提交。

3)事务T1提交。

4)事务T2提交。

**在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。**这是因为,**即使是READ UNCOMMITTED的事务隔离级别,对于行的DML操作,需要对行或其他粗粒度级别的对象加锁。**因此在上述步骤2)中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。

还有另外一种说法,即要避免丢失更新的发生,需要让事务操作变成串行化,而不是并行的操作。

# 脏读

img

在理解脏读(Dirty Read)之前,需要理解脏数据的概念。但是脏数据和之前所介绍的脏页完全是两种不同的概念。脏页指的是在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的,当然在刷新到磁盘之前,日志都已经被写入到了重做日志文件中。而所谓脏数据是指事务对缓冲池中行记录的修改,并且还没有被提交(commit)。

对于脏页的读取,是非常正常的。脏页是因为数据库实例内存和磁盘的异步造成的,这并不影响数据的一致性(或者说两者最终会达到一致性,即当脏页都刷回到磁盘)。并且因为脏页的刷新是异步的,不影响数据库的可用性,因此可以带来性能的提高。

脏数据却截然不同,脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另外一个事务中未提交的数据,则显然违反了数据库的隔离性。

脏读现象在生产环境中并不常发生,从上面的例子中就可以发现,脏读发生的条件是需要事务的隔离级别为READ UNCOMMITTED,而目前绝大部分的数据库都至少设置成READ COMMITTED。InnoDB存储引擎默认的事务隔离级别为READ REPEATABLE,Microsoft SQLServer数据库为READ COMMITTED,Oracle数据库同样也是READ COMMITTED。

# 不可重复读(幻读)

img

img

不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。

不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库事务一致性的要求

一般来说,不可重复读的问题是可以接受的,因为其读到的是已经提交的数据,本身并不会带来很大的问题。因此,很多数据库厂商(如Oracle、Microsoft SQL Server)将其数据库事务的默认隔离级别设置为READ COMMITTED,在这种隔离级别下允许不可重复读的现象。

在InnoDB存储引擎中,通过使用Next-Key Lock算法来避免不可重复读的问题。**在MySQL官方文档中将不可重复读的问题定义为Phantom Problem,即幻像问题。**在Next-Key Lock算法下,对于索引的扫描,不仅是锁住扫描到的索引,而且还锁住这些索引覆盖的范围(gap)。因此在这个范围内的插入都是不允许的。这样就避免了另外的事务在这个范围内插入数据导致的不可重复读的问题。因此,InnoDB存储引擎的默认事务隔离级别是READ REPEATABLE,采用Next-Key Lock算法,避免了不可重复读的现象。

# 死锁

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

解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。

因此,除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。

死锁示例:

img

# 事务

# 基本概念

事务会把数据库从一种一致状态转换为另一种一致状态。在数据库提交工作时,可以确保要么所有修改都已经保存了,要么所有修改都不保存。

事务可由一条非常简单的SQL语句组成,也可以由一组复杂的SQL语句组成。事务是访问并更新数据库中各种数据项的一个程序执行单元。在事务中的操作,要么都做修改,要么都不做,这就是事务的目的,也是事务模型区别与文件系统的重要特征之一。

ACID

# 事务的实现

事务隔离性由第6章讲述的锁来实现。原子性、一致性、持久性通过数据库的redo log和undo log来完成。redo log称为重做日志,用来保证事务的原子性和持久性。undo log用来保证事务的一致性。

# redo

重做日志用来实现事务的持久性,即事务ACID中的D。其由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),其是持久的。

InnoDB是事务的存储引擎,其通过Force Log at Commit机制实现事务的持久性,即**当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的COMMIT操作完成才算完成。**这里的日志是指重做日志,在InnoDB存储引擎中,由两部分组成,即redo log和undo log。**redo log用来保证事务的持久性,undo log用来帮助事务回滚及MVCC的功能。**redo log基本上都是顺序写的,在数据库运行时不需要对redo log的文件进行读取操作。而undo log是需要进行随机读写的。

为了确保每次日志都写入重做日志文件,在每次将重做日志缓冲写入重做日志文件后,InnoDB存储引擎都需要调用一次fsync操作。**由于重做日志文件打开并没有使用O_DIRECT选项,因此重做日志缓冲先写入文件系统缓存。**为了确保重做日志写入磁盘,必须进行一次fsync操作。由于fsync的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。

参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略。该参数的默认值为1,表示事务提交时必须调用一次fsync操作。还可以设置该参数的值为0和2。0表示事务提交时不进行写入重做日志操作,这个操作仅在master thread中完成,而在master thread中每1秒会进行一次重做日志文件的fsync操作。2表示事务提交时将重做日志写入重做日志文件,但仅写入文件系统的缓存中,不进行fsync操作。在这个设置下,当MySQL数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失。而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件那部分事务。

恢复:

  1. InnoDB存储引擎在启动时不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志,如二进制日志,要快很多。

  2. 由于checkpoint表示已经刷新到磁盘页上的LSN,因此在恢复过程中仅需恢复checkpoint开始的日志部分。

img

# undo

重做日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。

redo存放在重做日志文件中,与redo不同,**undo存放在数据库内部的一个特殊段(segment)中,这个段称为undo段(undo segment)。**undo段位于共享表空间内。

**用户通常对undo有这样的误解:**undo用于将数据库物理地恢复到执行语句或事务之前的样子——但事实并非如此。undo是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。

**除了回滚操作,undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成。**当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。

最后也是最为重要的一点是,undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。

事务提交后并不能马上删除undo log及undo log所在的页。这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本。故事务提交时将undo log放入一个链表中,是否可以最终删除undo log及undo log所在页由purge线程来判断。

# purge

delete和update操作可能并不直接删除原有的数据。对于delete操作,通过前面关于undo log的介绍已经知道仅是将主键列等于1的记录delete flag设置为1,记录并没有被删除,即记录还是存在于B+树中。其次,对辅助索引上a等于1,b等于1的记录同样没有做任何处理,甚至没有产生undo log。而真正删除这行记录的操作其实被“延时”了,最终在purge操作中完成。

purge用于最终完成delete和update操作。**这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事物可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。**而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那么就可以进行真正的delete操作。可见,purge操作是清理之前的delete和update操作,将上述操作“最终”完成。而实际执行的操作为delete操作,清理之前行记录的版本。

# 事务控制语句

在MySQL命令行的默认设置下,事务都是自动提交(auto commit)的,即执行SQL语句后就会马上执行COMMIT操作。因此要显式地开启一个事务需使用命令BEGIN、START TRANSACTION,或者执行命令SET AUTOCOMMIT=0,禁用当前会话的自动提交。

img

InnoDB存储引擎中的事务都是原子的,这说明下述两种情况:**构成事务的每条语句都会提交(成为永久),或者所有语句都回滚。**这种保护还延伸到单个的语句。一条语句要么完全成功,要么完全回滚(注意,这里说的是语句回滚)。因此一条语句失败并抛出异常时,并不会导致先前已经执行的语句自动回滚。所有的执行都会得到保留,必须由用户自己来决定是否对其进行提交或回滚的操作。

# 事务的隔离级别

隔离级别越低,事务请求的锁越少或保持锁的时间就越短。这也是为什么大多数数据库系统默认的事务隔离级别是READ COMMITTED。

在SERIALIABLE的事务隔离级别,**InnoDB存储引擎会对每个SELECT语句后自动加上LOCK IN SHARE MODE,即为每个读取操作加一个共享锁。**因此在这个事务隔离级别下,读占用了锁,对一致性的非锁定读不再予以支持。这时,事务隔离级别SERIALIZABLE符合数据库理论上的要求,即事务是well-formed的,并且是twophrased的。

Last Updated: 3/28/2022, 9:29:49 PM