锁
共享锁和排他锁(Shared and Exclusive Locks)
InnoDB实现了标准的两种行级锁,共享(S)锁和排他(X)锁:
- 共享(S)锁允许事务持有以读取行。
- 排他(X)锁允许事务更新或删除行。
若事务T1持有行r的共享(S)锁,那么另外一个事务T2对行r请求锁则会按照如下处理:
- T2会立刻获取到请求的S锁。最终,T1和T2都持有r的S锁。
- T2无法立刻获取请求的X锁。
若事务T1持有行r的排他(X)锁,那么另外一个事务T2对行r无论请求何种类型的锁都无法立刻获取,而是事务T2必须等待事务T1释放行r上的锁。
意向锁(Intention Locks)
InnoDB支持多个粒度锁定,允许行锁和表锁共存。为了在多个粒度级别更好地实现锁定,InnoDB采用意向锁。每当获取行锁时,还会获取表上的意向锁。这可以防止有人锁定表中的单行,而该表已被其他人整体锁定,反之亦然。意向锁是表级锁定,指示事务稍后对表中的行所需的锁定类型(共享或独占)。意图锁有两种类型:
- 意向共享锁(IS)表明一个事务将要对表中的行设置共享锁。
- 意向排他锁(IX)表明一个事务将要对表中的行设置排他锁。
意向锁规定如下:
- 在一个事务获取表中的行的共享锁之前,它必须先要获取该表的IS锁或更强的锁。
- 在一个事务获取表中的行的排他锁之前,它必须先要获取该表的IX锁。
表级锁类型兼容性总结如下:
X | IX | S | IS | |
X | 冲突 | 冲突 | 冲突 | 冲突 |
IX | 冲突 | 兼容 | 冲突 | 兼容 |
S | 冲突 | 冲突 | 兼容 | 兼容 |
IS | 冲突 | 兼容 | 兼容 | 兼容 |
如果请求事务与现有锁兼容,则获得锁,但如果它与现有锁冲突则不会获得锁。事务必须等待直到冲突的现有锁得到释放。如果锁定请求与现有锁冲突由于它会导致死锁而无法获取,则会发生错误。意向锁定不会阻塞除完整表请求(如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;阻止其他事务将值15插入到列t.c1中,无论列中是否存在任何此类值,因为该范围内所有值之间的间隙都被锁定。间隙可能跨越单个索引值,多个索引值,甚至可能为空。间隙锁是性能和并发的一种折衷,它被用在特定的事务隔离级别中。
一些语句通过使用唯一索引搜索唯一行来锁定行时,间隙锁是不必要的。例如,若id列包含唯一索引,下面的语句只会在id值为100的行上使用记录锁,而不会影响其他会话在之前的间隙插入行:
SELECT * FROM child WHERE id = 100;
若id列没有索引或者有一个非唯一索引,这条语句确实会锁定该值之前的间隙。值得注意的是,不同事务可以对同一个间隙持有的冲突的锁。例如,事务A可以持有一个间隙的共享间隙锁(gap S-lock),同时事务B持有同一个间隙的排他间隙锁(gap X-lock)。其原因是,如果一条记录被索引清除,不同事务对该记录持有的间隙锁必须合并。间隙锁是存粹禁止的,意味着他的唯一目的是阻止其他事务往间隙插入。间隙锁可以共存。一个事务的间隙锁不会阻止其他事务获取同一个间隙上的间隙锁。共享和独占间隙锁没有区别。他们不会相互冲突,他们实现了同样的功能。Gap锁可以被明确关掉,可以通过调整事务隔离级别到READ COMMITTED级别来达到。
Next-key锁(Next-Key Locks)
next-key锁是索引记录上的记录锁和在索引记录前的间隙的间隙锁的组合。
InnoDB以这样的方式执行行级锁定:当它搜索或扫描表索引时,它会在遇到的索引记录上设置共享锁或排它锁。因此,行级锁实际上是索引记录锁。索引记录上的next-key锁也会影响该索引记录之前的“间隙”。也就是说,next-key锁是索引记录锁定加上索引记录之前的间隙上的间隙锁定。如果一个会话在索引中的记录R上具有共享锁或独占锁,则另一个会话不能在索引顺序中的R之前的间隙中插入新的索引记录。
假设一个索引包含10,11,13和20。该索引可能next-key锁会覆盖如下区间:
(-∞, 10] (10, 11] (11, 13] (13, 20] (20, +∞)
默认情况下,InnoDB在REPEATABLE READ事务隔离级别运行。在这种情况下,InnoDB使用next-key锁进行搜索和索引扫描,从而防止幻影行。
插入意向锁(Insert Intention Locks)
插入意向是在行插入之前由INSERT操作设置的一种间隙锁。该锁表示以这样的方式插入的意向:如果插入到相同索引间隙中的多个事务不插入间隙内的相同位置,则不需要彼此等待。假设存在值为4和7的索引记录。分别尝试插入值5和6的事务分别在获取插入行上的排它锁之前用插入意向锁锁定4和7之间的间隙,但是不要互相阻塞,因为这些行是非冲突的。
自增锁(AUTO-INC Locks)
自增锁是由插入到具有AUTO_INCREMENT列的表中的事务所采用的特殊表级锁。在最简单的情况下,如果一个事务正在向表中插入值,则任何其他事务必须等待对该表执行自己的插入,以便第一个事务插入的行接收连续的主键值。
空间索引的谓词锁(Predicate Locks for Spatial Indexes)
InnoDB支持包含空间列的列空间索引
MVCC
InnoDB是一个多版本的控制引擎,它保存了修改行的老版本信息以支持诸如并发和回滚等事务特性。这些信息存储在表空间中一个叫回滚段(rollback segment)的数据结构中。InnoDB使用回滚段的信息执行事务中回滚中的undo操作,引擎同时使用该信息构建早期行版本以保证一致性读。
在内部,InnoDB为存储在数据库中的每一行添加三个字段。 6字节的DB_TRX_ID字段指示插入或更新该行的最后一个事务的事务标识符。此外,删除在内部被视为更新,其中行中的特殊位被设置为将其标记为已删除。每行还包含一个7字节的DB_ROLL_PTR字段,称为滚动指针。滚动指针指向写入回滚段的undo日志记录。如果更新了行,则undo日志记录包含在更新行之前重建行内容所需的信息。 6字节的DB_ROW_ID字段包含在插入新行时单调增加的行ID。如果InnoDB自动生成聚簇索引,则索引包含行ID值。否则,DB_ROW_ID列不会出现在任何索引中。
一致性读(快照读)
一致性读意味着InnoDB使用多版本向查询提供某个时间点数据库快照。查询可以看到该时间点前提交的事务所做的变更,而看不到该时间点之后其他任何提交/未提交事务的变更。有一个例外,查询可以看到同一个事务内其他语句所做更改。该例外造成如下反常现象:如果更新了表里的某些记录,SELECT能看到更新过的行最新版本,也可能看到任何行的老版本。如果其他会话同步更新这张表,这个反常意味着你可能会看到该表在某个不存在的数据库状态。
可重复读(REPEATABLE READ)事务隔离级别下,同一个事务内的多个一致性读只会读取第一次读到的快照(当前事务先前更新的数据也会体现)。提交当前事务并且发起新的查询可以为查询获取较新的快照。
读已提交(READ COMMITED)事务隔离级别下,同一个事务内的一致性读会读取其他事务和当前事务提交的最新的快照。
一致性读是InnoDB处理可重复读和读已提交事务隔离级别下的SELECT语句的默认模式。一致性读不会在他访问的任何表上加锁,因此其他会话可以在一致性读作用的表上同时任意执行修改。
假设你运行在可重复读隔离级别下。当你发起一个一致性读(即普通的SELECT查询语句),InnoDB根据查询看到的数据库为你的事务分配一个时间点。如果另外一个事务在分配完该时间点之后删除(Delete)了某行,你是看不到该行被删除的。插入(Insert)和更新(Update)操作有相似的处理。你可以通过提交当前事务并且执行另外的SELECT或者START TRANSACTION WITH CONSISTENT SNAPSHOT来推进之前分配给事务的时间点。这被称作多版本并发控制(MVCC)。
以下例子中,只有当会话B提交insert并且A也已经提交之后,会话A才能看到B插入的行,因此时间点被推进到B提交之后。
Session A Session B SET autocommit=0; SET autocommit=0; time | SELECT * FROM t; | empty set | INSERT INTO t VALUES (1, 2); | v SELECT * FROM t; empty set COMMIT; SELECT * FROM t; empty set COMMIT; SELECT * FROM t; --------------------- | 1 | 2 | ---------------------
若想查询到最新的数据库状态,使用读已提交隔离级别或者加锁读。
加锁读(当前读)
若你查询数据之后在同一个事务中插入或更新了相关数据,常规的SELECT语句不会提供足够的保护。其他事务可能更新或删除了你刚查询的相同的行。InnoDB支持两种类型的加锁读以提供额外的安全性保证:
SELECT ... FOR SHARE设置一个针对读到行的共享模式锁。其他会话可以读取行,但是在你的事务提交之前无法更新这些行。如果一些行被其他事务变更,并且还未提交,那么你的查询将会等到那个事务结束并且使用最新的值。
SELECT ... FOR UPDATE对查询遇到的索引记录,锁定这些行和相关联的索引,同对这些行发起Update语句一样。其他事务会被阻塞在更新这些行,SELECT ... FOR SHARE,或者在某些特定事务级别的读取。一致性读会忽略任何设置在读视图记录上的锁。
所有的FOR SHARE和FOR UPDATE查询设置的锁会在事务提交或回滚后释放。加锁读只有在取消自动提交(START TRANSACTION开启事务或设置autocommit为0)后才生效。
在外部语句的加锁读不会锁定嵌套子查询中表的记录,除非加锁读语句同样指定在子查询中。如下列语句不会锁定表t2中的行:
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;
要锁定t2中的行,可以在子查询中增加加锁读:
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2 FOR UPDATE) FOR UPDATE;
含NOWAIT 和 SKIP LOCKED的加锁读并发性
如果行被一个事务锁定,请求相同被锁定的行的SELECT ... FOR UPDATE 或 SELECT ... FOR SHARE事务必须等待锁定的事务释放行锁。这种行为防止了事务更新或删除被其他事务要更新查询的行。然而,当请求的行被锁定,或是从结果集中排除被锁定的行是可以接受的,你希望查询立即返回,等待行锁释放是不必要的为了避免等待其他事务释放行锁,NO WAIT 和 SKIP LOCKED 选项可以用在SELECT ... FOR UPDATE 或 SELECT ... FOR SHARE加锁读语句 。
NOWAIT使用了NOWAIT的加锁读不会等待获取行锁。查询立即执行,若请求的行被锁定则执行失败。
SKIP LOCKED使用了SKIP LOCKEDd的加锁读不会等待获取行锁。查询立即执行,结果集不会返回被锁定的行。
幻影行
所谓的幻影读问题发生在一个事务中,同一个查询在不同的时间产生了不同的结果集。例如,一个SELECT语句执行了两次,但是第二次返回了第一次未返回的一行,这个行就是“幻影”行。假设子表的id列上有一个索引,并且你要读取并锁定标识符值大于100的表中的所有行,以便稍后更新所选行中的某些列:
SELECT * FROM child WHERE id > 100 FOR UPDATE;
查询从id大于100的第一个记录开始扫描索引。假设表包含id值为90和102的行。如果在扫描范围内的索引记录上设置的锁不会锁定在间隙(在这种情况下,在90和102之间的间隙),另一个会话可以在表中插入一个id为101的新行。如果你要在同一个事务中执行相同的SELECT,你会看到一个查询返回的结果集中的id为101的新行(“幻影”)。如果我们将一组行视为数据项,则新的幻影行将违反事务隔离原则:事务能够被执行,以便它在事务期间读取的数据不会更改。
为了防止幻像,InnoDB使用一种称为next-key锁定的算法,它将索引行级锁定与间隙锁定相结合。 InnoDB以这样的方式执行行级锁定:当它搜索或扫描表索引时,它会在遇到的索引记录上设置共享锁或排它锁。因此,行级锁实际上是索引记录锁。此外,索引记录上的next-key锁也会影响该索引记录之前的“间隙”。也就是说,next-key锁是索引记录锁定加上索引记录之前的间隙上的间隙锁。如果一个会话在索引中的记录R上具有共享锁或独占锁,则另一个会话不能在索引顺序中的R之前的间隙中插入新的索引记录。
当InnoDB扫描索引时,它也可以锁定索引中最后一条记录之后的间隙。就在前面的例子中发生了这样的情况:为了防止任何插入到id大于100的表中,InnoDB设置的锁包括在id值102之后的空位上的锁。
总结
MySQL 使用MVCC实现并发读和一致性读,解决当前读下的幻读问题是通过next-key实现的。
FAQ
- 对于“一致性读意味着InnoDB使用多版本向查询提供某个时间点数据库快照”,何解呢?
不同的事务隔离级别下,一致性读有不同的行为,不同的事务隔离级别在一致性读时对“某个时间点”的有不同的定义。对于可重复读级别,“某个时间点”是指当前事务中第一次读取的时间点(特指),当前事务后续的一致性读取同样基于此时间点对应的数据库快照;对于对于读已提交级别,“某个时间点”是指当前事务中每次读取的时间点(泛指),每次读取有不同的时间点。
- 一致性读时,为什么"如果更新了表里的某些记录,SELECT能看到最新版本更新过的行,也可能看到任何行的老版本“?
可重复读级别下,考虑如下场景:
Session A Session B SET autocommit=0; SET autocommit=0; SELECT * FROM t; +------+------+ | k | v | +------+------+ | 1 | 2 | +------+------+ INSERT INTO t VALUES (3, 4); time Query OK, 1 row affected (0.00 sec) | DELETE FROM t WHERE k= 1; | Query OK, 1 row affected (0.00 sec) | | COMMIT; | v UPDATE t SET v=10 WHERE k=3; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 SELECT * FROM t; +------+------+ | k | v | +------+------+ | 1 | 2 | | 3 | 10 | +------+------+ COMMIT; SELECT * FROM t; +------+------+ | k | v | +------+------+ | 3 | 10 | +------+------+
由于Update永远读取最新的数据并更新,查询可以看到同一个事务内其他语句所做更改,会话A中第二查询查询到了对会话B中提交的记录进行更新后的最新版本(3,10),但是同时也查询到了被会话B删除的当前数据库并不存在的记录(1,2)。这就造成了该次查询实际上读取到了数据库一个本不该存在的状态:即有新版本的数据,又有老版本实际上并不存在的数据。