锁机制用于管理对共享资源的并发访问。InnoDB会在行级别上对数据上锁,也会在数据库内部其他多个地方使用锁,从而允许对多种不同资源提供并发访问。
在数据库中,lock与latch都可以被称为锁。但是两者有着截然不同的含义,本博文主要关注lock。
latch一般称为闩(shuan)锁(轻量级锁)。其要求锁定时间非常短,在InnoDB中,latch又可以分为mutex(互斥量)和rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测机制。
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且lock的对象仅在事务commit或rollback后进行释放(不同隔离级别释放的时间可能不同),此外lock是有死锁检测机制
3.1 实现如下两种标准的行级锁;
共享锁(S Lock),允许事务读取一行数据
排他锁(X Lock),允许事务删除或更新一行数据
排它锁与共享锁的兼容性
X锁与任何锁都不兼容,而S锁仅和S锁兼容,S、X都是行锁,兼容是指对同一记录(row)锁的兼容性情况。此外InnoDB支持多粒度锁定,这种锁定允许事务在行级和表级上的锁同时存在。为了支持不同粒度上的加锁操作,InnoDB支持一种额外的锁方式,称之为意向锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。如下图,如果要对记录r加X锁操作,则需要分别对数据库A、表、页 加意向锁IX,最后对记录r加X锁。若其中任何一个部分导致等待,那么该操作需要等待粗粒度锁的完成。
InnoDB支持意向锁设计比较简练,其意向锁为表级意向锁。设计目的主要是为了在一个事物中揭示下一行将被请求的锁类型。
其支持两种意向锁:
意向共享锁(IS Lock),事物想要获得一张表中某几行的共享锁
意向排它锁(IX Lock),事物想要获得一张表中某几行的排它锁
由于InnoDB支持的是行级别的锁,因此意向锁其实不会阻塞全表扫描以外的任何请求。故表级意向锁与行级锁的兼容性如下表示。
指的是InnoDB通过多行版本控制的方式来读取当前执行时间数据库中的行数据。如果读取的行正在执行DELETE或UPDATE操作,这时候读取操作不会因此去等待行上锁的释放,相反的InnoDB会去读取行的一个快照数据,之所以称其为非锁定读是因为不需要等待访问的行上的X锁的释放。快照数据是指的该行的上一个版本的数据,通过undo段来完成,而undo段用来在事务中回滚数据,因此快照数据本身没有额外开销。此外读取快照数据不需要上锁,因为没有事务需要对历史数据进行修改操作。快照数据其实就是当前行数据之前的历史版本,一个行记录可以有多条快照数据,一般称这种技术为行多版本技术,由此带来的并发控制,称之为多版本并发控制(MVCC
)
在InnoDB的默认设置下,这是默认的读取方式,但是在不同的事务隔离级别下读取的方式不同。在默认隔离级别(RR)下总是读取被锁定行事务开始时的快照数据,RC级别下总是读取被锁定行的最新的快照数
所以才有了在RC隔离级别下,可以读取到其他事务提交的记录。而在RR级别下,不能读取到其他事务提交的记录
另外还要说明一点,InnoDB读取行数据包括当前读和快照读,select ... for update ; select ... lock in share mode; 都是当前读,在RR级别下,是靠next-key lock 解决幻读,而普通的 select ... 是快照读,也就是当事务开启的时候,第一个select语句会生产一个快照记录ReadView,在当前事务下,下一次的select查询的还是这个快照记录,所以对于快照读天然解决幻读。对于幻读的解决,在第9节有更详细的解读。
在某些情况下,用户需要显示地对数据库读取操作进行加锁以保证数据逻辑的一致性。InnoDB对于SELECT 语句支持两种一致性读的锁定读操作:
SELECT ... FOR UDATE :加排他锁 X,其他事务不能对已经加了X锁的记录再加任何锁,必须等到X锁的释放才能继续加锁操作
SELECT ... LOCK IN SHARE MODE :加共享锁 S ,其他事务只能对加了S锁的记录加S锁,加X锁会进入阻塞状态直到S锁的释放
在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当对这个表进行插入操作时,计数器被初始化执行 SELECT MAX(auto_inc_col) FROM t FOR UPDATE 来得到计数器的值,插入操作会基于这个自增长计数器加1赋予自增长列。这个实现方式叫AUTO-INC Locking。是一种特殊表锁机制,为了提高插入性能,锁不是在一个事物完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放。虽然 AUTO-INC Locking 从一定程度上提高并发插入的效率,但是还存在一些性能上的问题。首先并发插入性能较差,其次对于 INSERT ... SELECT 的大数据量的插入会影响插入的性能,因为另一个事务中的插入会被阻塞。从5.1.22版本开始,InnoDB提供了一种轻量级互斥量的自增长实现机制,大大提高自增长的插入性能。
另外在InnoDB中,自增长列必须是索引,同时必须是索引的第一个列,否则会抛出异常,而myisam基于表锁自增长不用考虑并发问题
外键主要用于引用完整性的约束检查。在InnoDB存储引擎中,对于一个外键列,如果没有显示的对这个列加索引,InnoDB存储引擎自动对其加一个索引,这样可以避免表锁。对于外键值的插入或更新。首先需要查询父表中的记录,即SELECT父表。但是对于父表的SELECT操作,不是使用一致性的非锁定读的方式,因为这样会发生数据不一致的问题,而使用加S锁的方式——SELECT ... LOCK IN SHARE MODE。如果这时候父表已经加了X锁,子表上的操作会被阻塞,如下所示
在这个例子中,两个会话中的事务都没有进行COMMIT或ROLLBACK操作,而会话B的操作会被阻塞,这是因为id为3的父表在会话A中已经加了一个X锁,而此时会话B中用户有需要对父表中id为3的行加一个S锁,这时INSERT的操作会被阻塞。如果访问父表时,使用的是一致性的非锁定读,这时Session B 会读到父表有 id=3 的记录,可以进行插入操作,但是如果会话A对事物提交了,则父表就不存在id = 3的记录。数据在父、子表就会存在不一致的情况。
行锁的三种算法:
Next-Key Lock是结合 Gap Lock 和 Record Lock 的一种锁定算法,在 Next-Key Lock 算法下,InnoDB对于行的查询都是采用这种算法。例如有一个索引有10,11,13,20这四个值,那么该索引可能被Next-Key Locking的区间为(-∞,10] 、(10,11] 、(11,13] 、(13,20]、(20,+∞)。采用Next-Key Lock 锁定技术称为 Next-Key Locking 。其设计目的是为了解决幻读。这种锁定技术,锁定的不是单个值,而是一个范围。若事务T1已经通过Next-Key Locking锁定如下范围 (10,11] 、(11,13] ,当插入新的记录12时,则锁定的范围会变成(10,11] 、(11,12] 、 (12,13] 。然而当查询的索引含有唯一属性时,InnoDB会对 Next-Key Lock 进行优化,将其降级为Record Lock ,即仅锁住索引本身,而不是范围。
例子:创建表t ,执行表中的会话。
drop table if exists t;
create table t(a int primary key);
insert into t values (1);
insert into t values (2);
insert into t values (5);
表 t 中 共有1、2、5三个值,在上面的例子中,在会话A中首先对a=5进行X锁定,由于a是主键,因此对这行记录加记录锁而不是gap lock锁,这样在回话B中插入a=4的值不会阻塞,可以立即插入并返回,即锁定由 Next-Key Lock算法降级为 Record Lock,从而提高并发性。
如果是普通索引,则又是另外一种情况。
create table z(a int,b int,primary key(a),key (b));
insert into z values (1,1);
insert into z values (3,1);
insert into z values (5,3);
insert into z values (7,6);
insert into z values (10,8);
会话A
begin;
select * from z where b = 3 for update;
这时通过索引列b进行查询,使用传统的Next-Key Locking 技术加锁,由于有两个索引,需要分别加锁
对于主键索引对a=5的索引上加Record Lock,对于普通索引,加的是Next-Key Lock,锁定范围是(1,3],
需要注意的是,Innodb会对普通索引的下一个键值加上gap lock,即还有一个辅助索引范围为(3,6]的锁
会话B
begin;
select * from z where a = 5 lock in share mode; 阻塞
a=5的列上有X锁,这里的S锁需要等待X锁的释放,所以会阻塞
会话C
begin;
insert into z values (4,2); 阻塞
insert into z values (6,5); 阻塞
主键插入4没问题,但是b=2的值在锁定的范围(1,3],需要等待
主键插入6没问题,但是b=5的值在锁定的范围(3,6],需要等待
会话D
begin;
insert into z values (8,6);
insert into z values (2,0);
insert into z values (6,7);
commit;
三条sql可以立即执行,不会被阻塞
从上面的例子可以看到 ,Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,解决幻读问题。
在InnoDB引擎中,对于INSERT的操作,其会检查插入记录的下一条记录是否被锁定,若已经被锁定,则不允许查询,对于上面的例子,会话A已经锁定表z中b=3的记录,即锁定了(1,3]的范围,这时若在其他会话中进行如下的插入同样会导致阻塞。insert into z values (2,2); 因为在普通索引b上插入值为2的记录时,会检测到下一个记录3已经被索引。而将插入值改为如下的值,可以立即执行。insert into z values (2,0);
最后,对应唯一键的锁定,Next-Key Lock 降级为 Record Lock 仅存在于查询所有的唯一索引列,若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么查询的实际是range类型,不是point类型查询,故依然使用Next-Key Lock进行锁定。
幻读指的是在同一事物下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的sql语句可能返回之前不存在的行。
MVCC是否解决幻读?答案是没有完全解决,对于幻读需要了解“当前读”和“快照读的概念”。当前读是读取数据最新的版本,常见的update、insert、delete,还有select ...for update、select ... lock in share mode; 这些都是当前读。快照读指的是MVCC从ReadView中读取,所以必然不会看到新插入、删除的行,所以天然的解决了幻读的问题,对于当前读,MVCC无法解决,使用的是 Next-Key Lock 来解决。
select * from user where id < 10 for update; 范围查询,id列是主键,使用Next-Key Lock锁,锁住id<10的整个范围,其他事务无法插入id<10的数据,从而防止幻读
SQL标准中规定的RR并不能消除幻读,但是mysql的RR可以解决幻读,靠的就是Next-Key Lock锁,在默认隔离级别RR下,InnoDB采用 Next-Key Locking 机制避免幻读,在RC级别下采用Record Lock ,所以不能避免幻读。
此外,可以通过Next-Key Locking 机制在应用层面实现唯一性的检查。如 select * from table where col=xx lock in share mode; 如果查询不到数据,执行插入操作 insert into table values (...)。那么这条数据就只能被插入一次,并发的写入会导致其他的事务进入死锁从而抛异常。最终只有一个事物写入成功,典型的幂等性插入。
通过锁定机制实现事务隔离性的要求,使得事务可以并发的工作,锁提高了并发,也带来潜在的问题。由于事务隔离性的要求,锁只会带来三种问题,如果可以防止这三种情况的发生,那将不会产生并发异常。
脏读(Dirty Read):事务对缓冲池中的行记录的修改,并且没有提交。脏数据是指未提交的数据,如果读到了脏数据,即一个事务读到了另一个事务的未提交的数据,显然的违反了数据库的隔离性。脏读现象在生产环境并不常见,脏读发生的条件是事务隔离级别为RU(READ UNCOMMITTED),目前绝大多数的数据库至少设计成RC(READ COMMITTED)。InnoDB默认事务隔离级别是RR(READ REPEATABLE) 。脏读隔离看似毫无用处,但在一些比较特殊的情况下还是可以将事务的隔离级别设置为RU,例如在replication环境中的slave节点,并且在该节点上的查询并不需要特别精确的返回值。
不可重复读:指在同一事物内多次读取同一数据集合。在这个事务没有结束时,另外一个事务也访问同一数据集合。并做了MDL操作,因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,导致两次读到的数据可能是不一样的。这就发生了一个事务内两次读操作读到的数据不一样的情况,称之为不可重复读。不可重复读与脏读的区别是:脏读读到了其他事务未提交的数据,而不可重复读读到了其他事务提交的数据,违反了数据库一致性的要求。一般来说不可重复读是可以接受的,因为其读到的是已经提交的数据,本身不会带来很大的问题。因此很多数据库厂商如(Oracle,Microsoft SQL Server)将其数据库事务的默认隔离级别设置为RC,在这种隔离级别下允许不可重复读的现象。在InnoDB存储引擎中,通过使用Next-Key Lock算法来避免不可重复读的问题。在mysql官方文档中将不可重复读的问题定义为幻读,在Next-Key Lock算法下,对于索引的扫描,不仅仅锁住扫描到的索引,还锁住这些索引覆盖的范围(gap),因此在这个范围内的插入都是不允许的。这样避免了另外的事务在这个范围内的插入数据导致不可重复读的问题。因此InnoDB存储引擎的默认事务隔离级别是RR,采用Next-Key Lock算法避免不可重复读现象。
丢失更新: 一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。例如
但是在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题,这是因为即使是在RU隔离级别下,对于DML操作,需要对行或其他粗粒度级别的对象加锁。因此在上面的步骤2中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到T1提交。虽然在数据库能阻止丢失更新的问题产生,但是在生产应用中还有另一个逻辑意义的丢失更新问题,而导致该问题的产生并不是数据库本身。简单来说,出现下面的情况,就会发生丢失更新。
显然这个过程中用户User1的更新操作丢失了,避免丢失更新的问题发生,需要让事务在这种情况下的操作变成串行化,而并不是同步操作,即在上面的步骤1中,需要多读取的记录加一个X排他锁,同样在步骤2的操作上也加一个X排它锁。这样就会有一个事务必须等到另一个事务的提交操作完成后才能继续操作。
因为不同锁之间的兼容性关系,有些时刻一个事务中的锁需要等待另一个事务中的锁释放他所占用的资源,这就是阻塞,阻塞不是坏事,其为了确保事务可以并发正常的运行。在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来控制等待的时间(默认50s),innodb_rollback_on_timeout用来设定是否在等待超时对进行中的事务进行回滚操作(默认OFF,代表不会滚)。参数innodb_lock_wait_timeout是动态的,可以在mysql数据库运行时进行调整 set @@innodb_lock_wait_timeout=60;
而innodb_rollback_on_timeout是静态的,不可再启动时进行修改。当发生超时时,mysql会抛出一个1205的错误。需要牢记的是,在默认情况下,InnoDB不会回滚超时引发的错误异常,其实InnoDB在大部分情况下都不会对异常进行回滚。如果事务进行了一半后发生异常,但是没有进行commit或rollback,这种情况非常危险,因此用户必须判断是否需要commit或者rollback。之后再进行下一步操作。
死锁指的是两个或者两个以上的事务在执行的过程中,因为争夺锁资源而造成的一种互相等待的现象,若无外力作用,事务将无法推进下去,解决死锁的问题最简单的是不要有等待,将任何的等待都转换为回滚,并且事务重新开始。这样可以解决死锁但是在线上环境会导致性能的下降,严重时甚至连一个事务都不能进行,这带来的问题比死锁更严重,因为很难发现并且浪费资源。
解决死锁最简单的方式是超时,即两个事务互相等待,当一个等待时间超过某一阈值时,其中一个事务回滚,另一个事务继续进行,在InnoDB中 innodb_lock_wait_timeout 用来设置超时时间。超时机制虽然简单,但是仅通过超时后对事务进行回滚的方式来处理,或者说根据FIFO的顺序选择回滚对象。若超时事务所占的权重比较大,如事务更新了很多行,占用了较多的undi log,这时采用FIFO的方式就不合适了。因为回滚这个事务相对另一个事务所占用的时间可能会更多。
因此除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式进行死锁的检测。较之前的解决方案,这是更主动的方式。InnoDB采用的也是这种方式 wait-for graph 要求数据库保存以下两种信息:
通过上述链表可以构造出一张图,在这个图中若存在回路,就代表死锁,因此资源间互相发生等待。在wait-for graph中,事务为图中的节点。而在图中,事务T1指向T2边的定义为
下面看一个例子,下图展示了事务状态和锁的信息
在 Transaction Wait Lists 中可以看到共有4个事务 t1,t2,t3,t4,故在wait-for graph中应有4个节点。而事务t2对row1占用X锁,事务t1对row2占用s锁。事务t1需要等待事务t2中的row1的资源,因此在wait-for graph中有条边从节点t1执向t2。事务t2需要等待事务t1、t4所占用的row2对象,故而存在节点t2到节点t1、t4的边。同样存在t3到节点t1、t2、t4的边,因此最终的wait-for graph如下图所示,存在回路(t1,t2),因此存在死锁。通过上述介绍,可以发现wait-for graph是较为主动的死锁检测机制。在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常InnoDB选择回滚undo量最小的事务。wait-for graph的死锁检测通常采用深度优先的算法实现,在InnoDB1.2版本之前,采用递归方式,从1.2版本开始,对其进行了优化,将递归用非递归方式实现,进一步提高了InnoDB存储引擎的性能。
死锁应该非常少发生,若经常发生,则系统是不可用的。此外死锁的次数还要少于等待,因为至少需要2次等待才会产生死锁。
n:线程数 r:事务数 R:操作数据的集合。从公式发现nr << R ,因此事务发生死锁的概率非常低。同时事务发生死锁的概率与以下几点因素有关:
死锁实例如下:即A等待B,B等待A,这种死锁问题被称为AB-BA死锁。
上述操作中,B事务抛出1213死锁错误,原因是AB资源互相等待,大多数死锁InnoDB会侦测到,不需要人为干预,但上面例子中,B中事务抛出死锁异常,A马上获得了记录为2的资源,其实是B事务发生回滚,否则A事务不可能得到该资源的,上面内容说道InnoDB不会回滚大部分异常,但是死锁除外。发现死锁后InnoDB马上回滚事务。因此如果在应用程序中捕获了1213这个错误,其实不需要在应用中进行回滚。
是指将当前锁的粒度降低。举例来说,数据库可以把一个表的1000个行锁升级为一个页锁,或者将页锁升级为表锁。如果在数据库的设计中认为锁是一种稀有资源,而且想避免锁的开销,那数据库中会频繁出现锁升级现象。InnoDB不存在锁升级的问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理,采用位图方式,因此不管一个事务锁住页中的一个记录还是多个记录。开销都是一致的。
假设一张表有3000000个数据页,每页有100条记录,那么总共有300000000条记录。若一个事务执行全表更新的sql语句,需要对所有记录加X锁,根据每行记录产生锁对象进行加锁,并且每个锁占用10字节,仅对锁管理就需要差不多3G内存,而InnoDB存储引擎根据页进行加锁,采用位图方式,假设每个页存储的锁信息占用30个字节,则锁对象仅需90MB内存。由此可见两者对于锁资源的开销差距之大
最后:本文绝大多数内容整理自MySql技术内幕InnoDB存储引擎第二版。少部分参考了https://blog.csdn.net/v123411739/article/details/106893197这篇博文。