InnoDB存储引擎:锁
什么是锁
锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问。
数据库使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性。
InnoDB 存储引擎锁的实现和 Oracle 数据库非常类似,提供一致性的非锁定读、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。
lock 与 latch
在数据库中,lock 与 latch 斗可以被称为“锁”,但是两者有着截然不同的意义。
latch 一般称为闩锁,因为其要求锁定的时间必须非常短,若持续的时间长,则应用的性能非常差。在 InnoDB存储引擎中,latch 又可以分为 mutex(互斥)和 rwlock(读写锁),其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。
lock 的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般 lock 的对象仅在事务 commit 或 rollback 后进行释放(不同事务隔离级别释放的时间可能不同),此外,lock 在大多数数据库中都有死锁机制。
下图显示了 latch 和 lock 的不同:
InnoDB存储引擎中的锁
锁的类型
InnoDB存储引擎实现了如下两种标准的行级锁:
- 共享锁(S Lock),允许事务读一行数据。
- 排他锁(X Lock),允许事务删除或更新一行数据。
锁兼容:如果一个事务 T1 已经获得了行 r 的共享锁,那么另外的事务 T2 可以立即获得行 r 的共享锁,因为读取并没有改变行 r 的数据。
锁不兼容:但若有其他的事务 T3 想获得行 r 的排他锁,则其必须等待事务 T1、T2 释放行 r 上的共享锁。
下表显示了共享锁和排他锁的兼容性:
X | S | |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
注意:上述 S 和 X 锁都是行锁,兼容是指对同一记录锁的兼容性情况。
此外,InnoDB存储引擎支持多粒度(granular)锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度(fine granularity)上进行加锁,如下图所示:
若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。
InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:
- 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁
- 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫以外的任何请求。表级意向锁与行级锁的兼容性如下表所示:
一致性非锁定读
一致性非锁定读:InnoDB存储引擎通过行多版本控制的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行 DELETE 或 UPDATE 操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB存储引擎会去读取一个快照数据。
快照数据是指该行的之前版本的数据,该实现是通过 undo 段来完成。
非锁定读机制极大地提高了数据库的并发性。在InnoDB存储引擎的默认设置下,这是默认的读取方式,即读取不会占用和等待表上的锁。
然而InnoDB存储引擎在不同的事务隔离级别下,对于快照数据的定义是不相同的:
- READ COMMITTED 事务隔离级别:非一致性读总是读取被锁定行的最新一份快照数据。
- REPEATABLE READ 事务隔离级别:非一致性读总是读取事务开始时的行数据版本。
InnoDB存储引擎默认事务隔离级别为 REPEATABLE READ。
一致性锁定读
在默认配置下,InnoDB存储引擎的 SELECT 操作使用一致性非锁定读。但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。而这要求数据库支持加锁语句,即使是对于 SELECT 的只读操作。InnoDB存储引擎对于 SELECT 语句支持两种一致性的锁定读操作:
- SELECT...FOR UPDATE(对读取的行记录加一个 X 锁)
- SELECT..LOCK IN SHARE MODE(对读取的行记录加一个 S 锁)
自增长与锁
在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化。执行如下语句得到计数器的值:
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;
插入操作会根据这个自增长的计数器值加1赋予自增长列,这个实现方式称为 AUTO-INC Locking。这种锁其实采用了一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的 SQL 语句后立即释放。
外键和锁
在InnoDB存储引擎中,对于一个外键列,如果没有显式地对这个列加索引,InnoDB存储引擎自动对其加一个索引,因为这样可以避免表锁。
对于外键值的插入或更新,首先需要查询父表中的记录。但是对于父表的 SELECT 操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题。
锁的算法
InnoDB存储引擎有3种行锁的算法:
- Record Lock (单个行记录上的锁)
- Gap Lock(间隙锁,锁定一个范围,但不包含记录本身)
- Next-Key Lock(Gap Lock + Record Lock ,锁定一个范围,并且锁定记录本身)
Next-Key Lock 的设计目的是为了解决幻象问题(Phantom Problem)。
幻象问题:指在同一事务下,连续执行两次同样的 SQL 语句可能导致不同的结果,第二次的 SQL 语句可能会返回之前不存在的行。
例如一个索引有 10,11,13 和 20 这四个值,那么该索引可能被 Next-Key Locking 的区间为:
(-∞,10)
[10, 11)
[11, 13)
[13, 20)
[20, +∞)
然而当查询的索引含有唯一属性时,InnoDB存储引擎会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。也就是 Next-Key Lock 降级为 Record Lock 仅在查询的列是唯一索引的情况下。
Gap Lock 的作用是为了阻止多个事务将记录插入到同一范围内,而这也会导致幻象问题。
还要注意一种情况,若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么查询其实就是 range 类型查询,而不是 point 类型查询,故 InnoDB存储引擎依然使用 Next-Key Lock 进行锁定。
锁问题
锁提高了并发,但是锁也会带来三种问题:
- 脏读
- 不可重复读
- 丢失更新
脏读
理解脏读之前,要理解脏数据的概念。首先要明白一点,脏数据和脏页是完全不同的两个概念。
脏页:在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的。
脏数据:指事务对缓冲池中行记录的修改,并且还没有被提交。
脏页是因为数据库实例内存和磁盘的异步造成的,这并不会影响数据的一致性;脏数据缺截然不同,如果读到了脏数据,即一个事务可以读到另外一个事务中未提交的数据,显然违反了数据库的隔离性。
脏读发生的条件是需要事务的隔离级别为 READ UNCOMMITTED。
不可重复读
不可重复读是指在一个事务内多次读取同一数据集合,即在一个事务还没有结束的时候,另外一个事务也访问该同一数据集合,并做了一些 DML 操作。这就可能造成第一个事务两次读到的数据可能是不一样的,违反了数据库事务的一致性。
不可重复读发生的条件是需要事务的隔离级别为 READ COMMITTED。
丢失更新
丢失更新是另一个锁导致的问题,简单来说就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致性。
例如:
- 事务 T1 将行记录 r 更新为 v1,但是事务 T1 并未被提交。
- 与此同时,事务 T2 将行记录 r 更新为 v2,事务 T2 为提交。
- 事务 T1 提交。
- 事务 T2 提交。
当事务 T1 和 T2 同时提交时,那么两次更新中的一次更新会覆盖另一次更新。
阻塞
因为不同锁的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。阻塞并不是一件坏事,它确保事务可以并发且正常地运行。
需要注意的是,在默认情况下 InnoDB存储引擎不会回滚超时引发的错误异常。
死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。
解决死锁问题最简单的方式就是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其一个事务进行回滚,另一个等待的事务就能继续进行。
除了超时,当前数据库还采用 wait-for graph(等待图)的方式来进行死锁检测。
等待图要求数据库保存两种信息:
- 锁的信息链表
- 事务等待链表
通过上述链表可以构造一张图,而图中如果存在回路,就代表存在死锁。在等待图中,事务为图中的节点。图中事务 T1 指向 T2 边的定义为:
- 事务T1等待事务T2所占用的资源
- 事务T1最终等待T2所占用的资源,也就是事务之间在等待相同的资源,而事务T1发生在事务T2的后面
来看一个例子:
- 图中共有4个事务,t1、t2、t3、t4
- 事务 t2 对 row1 占用 x 锁,事务 t1 对 row2 占用 s 锁
- 事务 t1 需要等待事务 t2 中 row1 的资源,因此图中有条边从节点 t1 指向节点 t2
- 事务 t2 需要等待事务 t1、t4 所占用的 row2 对象,故而存在节点 t2 到节点 t1、t4 的边
- 事务 t3 需要等待事务 t1、t4、t2所占用的 row2 对象,故而存在节点 t3到节点 t1、t4、t2 的边
最终的等待图如下所示:
存在回路 (t1,t2),故而存在死锁。等待图的死锁检测通常采用深度优先的算法实现。
锁升级
锁升级是指将当前锁的粒度降低。这种升级保护了系统资源,防止系统使用太多的内存来维护锁,在一定程度上提高了效率。
不过要注意的就是 InnoDB存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,而是根据每个事务访问的每个页对锁进行管理的。
InnoDB存储引擎根据页进行加锁,并采用位图方式。