【MySQL】(六)锁

开发多用户、数据库驱动的应用时,最大的一个难点是:一方面要最大程度地利用数据库的并发访问,另一方面还要确保每个用户能以一致的方式读取和修改数据。为此就有了锁(locking)的机制,同时这也是数据库系统区别于文件系统的一个关键特性。本篇文章将详细介绍InnoDB存储引擎对表中数据的锁定,同时分析InnoDB存储引擎会以怎样的粒度锁定数据。

1、什么是锁

锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问。InnoDB存储引擎会在行级别上对表数据上锁。不过InnoDB存储引擎也会在数据库内部其他多个地方使用锁,从而允许对多种不同资源提供并发访问。

对于MyISAM存储引擎,其锁是表锁设计。对于Microsoft SQL Server 2005版本之前其都是页锁的,相对于表锁的MyISAM引擎来说,并发性能有所提高。到2005版本,Microsoft SQL Server开始支持乐观并发和悲观并发,在乐观并发下支持行级锁,但是其实现方式与InnoDB存储引擎的实现方式完全不同。

InnoDB存储引擎锁的实现和Oracle数据库非常相似,提供一致性的非锁定读、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。

2、lock与latch

在数据库中,lock与latch都可以被称为“锁”。但是两者有着截然不同的含义,本篇文章主要关注的是lock。

latch一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在InnoDB存储引擎中,latch又可以分为mutex(互斥量)和rwlock(读写锁)。其目的是为了保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。

lock的对象时事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。此外,正如在大多数数据库中一样,是有死锁机制的。

3、InnoDB存储引擎中的锁

3.1、锁的类型

InnoDB存储引擎实现了如下两种标准的行级锁:

  • 共享锁(S Lock),允许事务读一行数据。
  • 排他锁(X Lock),允许事务删除或更新一行数据。

如果一个事务T1已经获得了行r的共享锁,那么另外的事务T2可以立即获得行r的共享锁,因为读取并没有改变行r的数据,成这种情况为锁兼容。但若有其他的事务T3想获得行r的排他锁,则其必须等待事务T1、T2释放行r上的共享锁——这种情况称为不兼容。
这里写图片描述

此外,InnoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁,如下图
这里写图片描述
若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。例如,如果需要对页上的记录r进行上X锁,那么分别需要对数据库A、表、页上意向锁IX,最后对记录r上X锁。若其中任何一个部分导致等待,那么该操作需要等待粗粒度锁的完成。

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

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

由于InnoDB支持的行级别的锁,因此意向锁其实不会阻塞除全表扫以以外的任何请求。故表级别意向锁与行级别意向锁的兼容性如下表所示
这里写图片描述

3.2、一致性非锁定读

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

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

快照数据其实就是当前行数据的历史版本,没行记录可能由多个版本。一个行记录可能有不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制。

在事务隔离级别READ COMMITED和REPRTABLE READ下,InnoDB存储引擎使用非锁定一致性读。在READ COMMITED事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。

3.3、一致性锁定读

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

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

对于一致性非锁定读,及时读取的行已被执行了SELECT…FOR UPDATE,也是可以进行读取的,这和上面讲的一样。此外,SELECT…LOCK IN SHARE MODE必须在一个事务中,当事务提交了,锁也就是释放了。

3.4、自增长与锁

在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当含有自增长的计数器的表进行插入操作时,这个计数器会被初始化,执行如下的语句来得到计数器的值:

SELECT MAX(auto_inc_col) FROM t FOR UPDATE;

插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称作AUTO-INC Locking。这种锁其实是一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值的插入的SQL语句后立即释放。

虽然AUTO-Locking 从一定程度上提高了并发插入效率,但还是存在一些性能上的问题。首先,对于有自增长值的列的并发插入性能较差,事务必须等待前一个插入的完成(虽然不用等待事务的完成)。其次,对于INSERT…SELECT的大量数据的插入回影响插入性能。因为一个事务中的插入会被阻塞。

InnoDB存储引擎中提供了一种轻量级互斥量的自增长实现机制,这种机制大大提高了自增长值插入的性能。

3.5、外键和锁

外键主要用于引用完整性的约束检查。在InnoDB存储引擎中,对于一个外键列,如果没有显示地对这个列加索引,InnoDB存储引擎会自动对其加一个索引,因为这样可以避免表锁。

对于外键值的插入或更新,首先需要查询父表中的记录,即SELECT父表。但是对于父表的SELECT操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是SELECT…LOCK IN SHARE MODE方式,即主动对父表加一个S锁。如果这时父表已经加X锁,子表上的操作会被阻塞。

4、锁的算法

4.1、行锁的3种算法

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

  • Record Lock:单个行记录上的锁
  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
  • Next-Key Lock:Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身

Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。

Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,InnoDB对于行的查询都是采用这种锁定算法。

4.2、解决Phantom Problem

在默认的事务隔离级别下,即REPEATABLE下,InnoDB存储存储引擎采用Next-Key Locking机制来避免Phantom problem(幻想问题)。

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

InnoDB存储引擎采用Next-Key Locking的算法避免Phantom Problem。其锁住的不是单个值,而是一个范围,因此任何对于这个范围的插入都是不被允许的,从而避免Phantom Problem。

5、锁问题

通过锁定机制可以实现事务的隔离性要求,使得事务可以并发地工作。锁提高了并发,但是却会带来潜在的问题。不过好在因为事务隔离性的要求,锁只会带来三种问题,如果可以防止这三种问题,那将不会产生并发异常。

5.1、脏读

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

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

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

脏读指的就是在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。

脏读显现在生产环境中并不常发生,脏读发生的条件是需要事务的隔离级别为READ UNCOMMITED,而目前绝大部分数据库都至少设置成READ COMMIT。

5.2、不可重复读

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

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

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

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

5.3、丢失更新

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

  1. 事务T1将行记录r更新为v1,但是事务T1并未提交。
  2. 与此同时,事务T2将行记录r更新为v2,事务T2未提交。
  3. 事务T1提交
  4. 事务T2提交

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

虽然数据库能阻止丢失更新问题的产生,但是生产应用中还有另一个逻辑意义的丢失更新问题,而导致该问题的并不是因为数据库本身的问题。实际上,在所有多用户计算机系统环境下都有可能产生这个问题。简单来说,出现下面的情况时,就会发生丢失更新:

  1. 事务T1查询一行数据,放入本地内存,并显示给一个终端用户User1
  2. 事务T2也查询该行数据,并将取得的数据显示给终端用户User2
  3. User1修改这行记录,更新数据库并提交
  4. User2修改这行记录,更新数据库并提交

显然,这个过程用户User1的修改更新操作“丢失”了,而这可能导致一个“恐怖”的结果。

要避免丢失更新发生,需要让食物在这种情况下的操作变成串行化,而不是并行的操作。即在上述四个步骤的1)中,对用户读取的记录加上一个排他X锁。同样,在步骤2)的操作过程中,用户同样也需要加一个排他X锁。通过这种方式,步骤2)就必须等待步骤1)和步骤3)完成,最后完成步骤4)。

6、阻塞

因为不同锁之前的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的起源,这就是阻塞。阻塞并不是一件坏事,其是为了确保事务可以并发且正常地运行。

7、死锁

7.1、死锁的概念

死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的的一种互相等待的现象。解决死锁问题最简单的方式是不要有等待,将任何等待都化为回滚,并且事务重新开始。这的确可以避免死锁问题的产生。然而在线上生产环境中,这可能导致并发性能的下降,甚至任何一个事务都不能进行。

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

超时机制仅通过超时后对事务进行回滚的方式来处理,或者说起是根据FIFO的顺序选择回滚对象。

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

  • 锁的信息链表
  • 事务等待链表

通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表死锁。因此资源间相互发生等待。在wait-for graph 中,事务为图中的节点。而在图中,事务T1指向T2边的定义为:

  • 事务T1等待事务T2所占用的资源
  • 事务T1最终等待T2所占用的资源,也就是事物之间在等待相同的资源,而事务T1发生在T2的后面

wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。

8、锁升级

锁升级是指将当前锁的粒度降低。举例来说,数据库可以把一个表1000个行锁升级为一个页锁,或者将页锁升级为表锁。如果在数据库的设计中认为锁是一种稀有资源,而且像避免锁的开销,那么数据库中会频繁出现锁升级现象。

9、小结

尽管锁本身相当直接,但是它的一些副作用却不是这样的。关键是用户需要理解锁带来的问题,如丢失更新、脏读、不可重复读等。如果不知道这一点,那么开发的应用程序性能就会很差。如果不学会怎样通过一些命名和数据字典来查看事务锁住了哪些资源,你可能永远不知道到底发生了什么事情,可能只是认为MySQL数据库有时会阻塞而已。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章