开发多用户、 数据库驱动的应用时, 最大的一个难点是: 一方面要最大程度地利用数据库的并发访问, 另外一方面还要确保每个用户能以一致的方式读取和修改数据。 为此就有了锁(locking)的机制, 同时这也是数据库系统区别于文件系统的一个关键特性。
人们认为行级锁总会增加开销。 实际上, 只有当实现本身会增加开销时, 行级锁才会增加开销。InnoDB存储引擎不需要锁升级, 因为一个锁和多个锁的开销是相同的。
6.1 什么是锁
锁是数据库系统区别于文件系统的一个关键特性。 锁机制用于管理对共享资源的并发访问。InnoDB存储引擎会在行级别上对表数据上锁, 这固然不错。 不过InnoDB存储引擎也会在数据库内部其他多个地方使用锁, 从而允许对多种不同资源提供并发访 问。 例如, 操作缓冲池中的LRU列表, 删除、 添加、 移动LRU列表中的元素, 为了保证一致性, 必须有锁的介入。 数据库系统使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性。
对于MyISAM引擎,其锁是表锁设计。并发情况下的读没有问题,但是并发插人时的性能就要差一些了,若插入是在 底部", MyISAM存储引擎还是可以有一定的并发写入操作。
InnoDB存储引擎锁的实现和Oracle 数据库非常类似,提供一致性的非锁定读 、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。
6.2 lock与latch
latch 一般称为问锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的 时间长,则应用的性能会非常差。在InnoDB存储引擎中,latch 又可以分为 mutex (互斥量) 和rwlock (读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务 commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。此外,lock, 正如在大多数数据库中一样,是有死锁机制的。
6.3 lnnoDB存储引擎中的锁
6.3.1 锁的类型
InnoDB 存储引擎实现了如下两种标准的行级锁:
1.共享锁(S Lock), 允许事务读一行数据。
2.排他锁(X Lock), 允许事务删除或更新一行数据。
如果一个事务Tl已经获得了行r的共享锁, 那么另外的事务T2可以立即获得行r的共享锁, 因为读取并没有改变行 r 的数据, 称这种情况为锁兼容 (Lock Compatible)。 但若有其他的事务T3想获得行r的排他锁, 则其必须等待事务T1, T2释放行r上的共享锁——这种情况称为锁不兼容。
此外, InnoDB 存储引擎支持多粒度 (granular) 锁定, 这种锁定允许事务在行级上的 锁和表级上的锁同时存在。为了支待在不同粒度上进行加锁操作, InnoDB 存储引擎支持 一种额外的锁方式, 称之为意向锁 。意向锁是将锁定的对象分为多个层次, 意向锁意味着事务希望在更细粒度 (fine granularity) 上进行加锁。
若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。
lnnoDB存储引擎支持意向锁设计比较简练, 其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:
1)意向共享锁OS Lock), 事务想要获得一张表中某几行的共享锁
2)意向排他锁(IXLock), 事务想要获得一张表中某几行的排他锁
由于lnnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫以外的任何请求。
6.3.2 一致性非锁定读
一致性的非锁定读 (consistent non locking read) 是指 lnnoDB 存储引擎通过行多版本控制 (multi versioning) 的方式来读取当前执行时间数据库中行的数据。 如果读取的行正在执行 DELETE 或 UPDATE操作, 这时读取操作不会因此去等待行上 锁的释放。 相反地, InnoDB 存储引擎会去读取行的一个快照数据。
图 6-4 直观地展现了 lnnoDB 存储引擎一致性的非锁定读。 之所以称其为非锁定读, 因为不需要等待访问的行上 X 锁的释放。
快照数据是指该行的之前版本的数据,该实现是通过undo段来完成。而undo用来在事务中回滚数据, 因此快照数据本身是没有额外的开销。 此外, 读取快照数据是不需要上锁的, 因为没有事务需要对历史的数据进行修改操作。
可以看到, 非锁定读机制极大地提高了数据库的并发性。 在 InnoDB 存储引擎的默认设置下, 这是默认的读取方式, 即读取不会占用和等待表上的锁。 但是在不同事务隔离级别下, 读取的方式不同, 并不是在每个事务隔离级别下都是采用非锁定的 致性一读。 此外, 即使都是使用非锁定的一致性读, 但是对于快照数据的定义也各不相同。
通过图6-4可以知道, 快照数据其实就是当前行数据之前的历史版本, 每行记录可能有多个版本。 就图6-4所显示的, 一个行记录可能有不止一个快照数据, 一般称这种技术为行多版本技术。 由此带来的并发控制, 称之为多版本并发控制 (Multi Version Concurrency Control, MVCC)。
在事务隔离级别 READ COMMITTED 和 REPEATABLE READ (InnoDB 存储引擎的默认事务隔离级别)下, InnoDB 存储引擎使用非锁定的一致性读。 然而, 对于快照数据的定义却不相同。 在 READ COMMITTED 事务隔离级别下, 对于快照数据, 非一致性读总是读取被锁定行的最新一份快照数据。 而在 REPEATABLE READ 事务隔离级别下, 对千快照数据, 非一致性读总是读取事务开始时的行数据版本。
6.3.3 一致性锁定读
InnoDB存储引擎的SELECT操作使用一致性非锁定读。但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性
6.3.4 自增长与锁
自增长在数据库中是非常常见的一种属性,也是很多DBA或开发人员首选的主键方式。在 InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器(auto-increment counter)。当对含有自增长的计数器的表进行插人操作时,这个计 数器会被初始化,执行如下的语句来得到计数器的值:
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;
插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称做AUTO-INC Locking。这种锁其实是采用一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是 在完成对自增长值插入的 SQL语句后立即释放。
虽然AUTO-INC Locking从一定程度上提高了并发插入的效率,但还是存在一些性一能上的问题。首先,对于有自增长值的列的并发插入性能较差,事务必须等待前 个插入的完成(虽然不用等待事务的完成)。其次,对于INSERT…SELECT的大数据量的插一个事务中的插入会影响插入的性能, 因为另一个事务中的插入会被阻塞。
从 MySQL 5.1.22版本开始,InnoDB存储引擎中提供了一种轻量级互斥量的自增长实现机制,这种机制大大提高了自增长值插入的性能。并且从该版本开始,InnoDB存储引擎提供了一个参数innodb_autoinc_lock_mode来控制自增长模式,该参数默认值为1。在继续讨论新的自增长实现方式之前,需要对自增长的插人进行分类。
接着来分析参数innodb_ auto inc_ lock_ mode以及各个设置下对自增的影响,其总共有三个有效值可供设定,即0、1、2,具体说明如表6-10所示。
6.3.5 外键和锁
前面已经介绍了外键,外键主要用于引用完整性的约束检查。在InnoDB存储引擎中,对于一个外键列,如果没有显式地对这个列加索引,lnnoDB存储引擎自动对其加一个索引,因为这样可以避免表锁。
于外键值的插入或更新,首先需要查询父表中的记录,即SELECT父表。但是对于父表的SELECT操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是SELECT LOCKIN SHARE MODE方式,即主动对父表加一个S锁。如果这时父表上已经这样加X锁,子表上的操作会被阻塞。
6.4 锁的算法
6.4.1 行锁的3种算法
InnoDB存储引擎有3种行锁的算法,其分别是:
Record Lock: 单个行记录上的锁
Gap Lock: 间隙锁, 锁定一个范围, 但不包含记录本身
Next-Key Lock : Gap Lock+Record Lock, 锁定一个范围, 并且锁定记录本身
Record Lock 总是会去锁住索引记录, 如果 lnnoDB 存储引擎表在建立的时候没有设置任何一个索引, 那么这时 InnoDB 存储引擎会使用隐式的主键来进行锁定。
Next-Key Lock 是结合了 Gap Lock 和 Record Lock 的一种锁定算法, 在 Next-Key Lock 算法下, InnoDB 对于行的查询都是采用这种锁定算法。
采用 Next-Key Lock 的锁定技术称为 Next-Key Locking。 其设计的目的是为了解决 Phantom Problem, 这将在下一小节中介绍。 而利用这种锁定技术, 锁定的不是单个值, 而是一个范围, 是谓词锁 (predict lock) 的一种改进。 除了 next-key locking, 还有 previous-key locking 技术。
6.4.2 解决 Phantom Problem
在默认的事务隔离级别下, 即 REPEATABLE READ 下, InnoDB 存储引擎采用Next-Key Locking 机制来避免 Phantom Problem (幻像问题)。 这点可能不同于与其他的数据库, 如 Oracle 数据库, 因为其可能需要在 SERI ALIZABLE的事务隔离级别下才能 解决 Phantom Problem。
Phantom Problem 是指在同一事务下, 连续执行两次同样的 SOL 语句可能导致不同 的结果, 第二次的 SOL 语句可能会返回之前不存在的行。 下
InnoDB存储引擎默认的事务隔离级别是REPEATABLE READ, 在该隔离级别下, 其采用Next-Key Locking的方式来加锁。 而在事务隔离级别READ COMMITTED 下, 其仅采用Record Lock, 因此在上述的示例中, 会话A需要将事务的隔离级别设置为READ COMMITTED。
6.5 锁问题
6.5.1 脏读
在理解脏读(DirtyRead)之前,需要理解脏数据的概念。但是脏数据和之前所介绍的脏页完全是两种不同的概念。脏页指的是在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的,当然在刷新到磁盘之前,日志都已经被写入到了重做日志文件中。而所谓脏数据是指事务对缓冲池中行记录的修改,并且还没有被提交(commit)。
对于脏页的读取,是非常正常的。脏页是因为数据库实例内存和磁盘的异步造成的,这并不影响数据的一致性(或者说两者最终会达到一致性,即当脏页都刷回到磁盘)。并且因为脏页的刷新是异步的,不影响数据库的可用性,因此可以带来性能的提高。
脏数据却截然不同,脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另外一个事务中未提交的数据, 则显然违反了数据库的隔离性。
脏读指的就是在不同的事务下, 当前事务可以读到另外事务未提交的数据, 简单来说就是可以读到脏数据。
6.5.2 不可重复读
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合, 并做了一些DML操作。因此, 在第一个事务中的两次读数据之间, 由于第二个事务的修改, 那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况, 这种情况称为不可重复读。
不可重复读和脏读的区别是:脏读是读到未提交的数据, 而不可重复读读到的却是已经提交的数据, 但是其违反了数据库事务一致性的要求。可以通过下面一个例子来观察不可重复读的情况。
6.5.3 丢失更新
丢失更新是另一个锁导致的问题,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。例如:
1) 事务Tl将行记录r更新为vi, 但是事务Tl并未提交。
2, 与此同时,事务 T2 将行记录 r 更新为 v2, 事务 T2未提交。
3) 事务Tl提交。
4)事务 T2 提交。
但是,在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。这是因为,即使是READUNCOMMITTED的事务隔离级别,对于行的DML 操作,需要对行或其他粗粒度级别的对象加锁。因此在上述步骤 2) 中,事务 T2 并不能对行记录r进行更新操作,其会被阻塞,直到事务Tl提交。
虽然数据库能阻止丢失更新问题的产生,但是在生产应用中还有另一个逻辑意义的 丢失更新问题,而导致该问题的并不是因为数据库本身的问题。实际上,在所有多用户计算机系统环境下都有可能产生这个问题。简单地说来,出现下面的情况时,就会发生 丢失更新:
1) 事务Tl查询一行数据,放人本地内存,并显示给一个终端用户 User1 。
2, 事务 T2 也查询该行数据,并将取得的数据显示给终端用户 User2。
3) User1 修改这行记录,更新数据库并提交。
4) User2 修改这行记录,更新数据库并提交。
要避免丢失更新发生,需要让事务在这种情况下的操作变成串行化,而不是并行的操作。即在上述四个步骤的I)中,对用户读取的记录加上一个排他X锁。同样,在步骤2) 的操作过程中,用户同样也需要加一个排他X锁。通过这种方式,步骤2) 就必须等待一步骤I)和步骤3)完成,最后完成步骤4)。
6.6 阻塞
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是 阻塞。阻塞并不是一件坏事,其是为了确保事务可以 并发且正常地运行。
在InnoDB存储引擎中,参数innodb _lock_ wait_ timeout用来控制等待的时间(默认是50秒),innodb_rollback_on_timeout用来设定是否在等待超时时对进行 中的事务进行回滚操作(默认是OFF, 代表不回滚)。参数innodb_lock_ wait_ timeout是动态的,可以 在MySQL数据库运行时进行调整,而innodb _rollback_ on_ timeout是静态的,不 可在启动时进行修改。
需要牢记的是,在默认情况下InnoDB存储引擎不会回滚超时引发的错误异常。
6.7 死锁
6.7.1 死锁的概念
死锁是指两个或两个以上的事务在执行过程中, 因争夺锁资源而造成的一种互相等待 的现象。
解决死锁问题最简单的一种方法是超时, 即当两个事务互相等待时, 当一个等待时间超过设置的某一阙值时, 其中一个事务进行回滚, 另一个等待的事务就能继续进行。 在InnoDB存储引擎中, 参数innodb_lock_ wait_ timeout用来设置超时的时间。
超时机制虽然简单, 但是其仅通过超时后对事务进行回滚的方式来处理, 或者说其是根据FIFO的顺序选择回滚对象。 但若超时的事务所占权重比较大, 如事务操作更新 了很多行, 占用了较多的undo log, 这时采用FIFO的方式, 就显得不合适了, 因为回滚这个事务的时间相对另一个事务所占用的时间可能会很多。
因此, 除了超时机制, 当前数据库还都普遍采用wait-forgraph (等待图)的方式来进行死锁检测。 较之超时的解决方案, 这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。 wait-forgraph要求数据库保存以下两种信息:
1.锁的信息链表
2.事务等待链表
通过上述链表可以构造出一张图, 而在这个图中若存在回路, 就代表存在死锁, 因此资源间相互发生等待。 在wait-for graph中, 事务为图中的节点。 而在图中, 事务Tl指向T2边的定义为:
事务T1等待事务T2所占用的资源
事务T1最终等待T2所占用的资源, 也就是事务之间在等待相同的资源, 而事务T1发生在事务T2 的后面
下面来看一个例子, 当前事务和锁的状态如图6-5所示。
6.8 锁升级
锁升级 (Lock Escalation) 是指将当前锁的粒度降低。 举例来说, 数据库可以把一个表的 1000 个行锁升级为一个页锁, 或者将页锁升级为表锁。 如果在数据库的设计中认为锁是一种稀有资源, 而且想避免锁的开销, 那数据库中会频繁出现锁升级现象。
InnoDB存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的是位图的方式。因此不管一 个事务锁住页中一个记录还是多个记录,其开销通常都是一致 的。