什么是锁
现实生活中的锁是为了保护你的私有物品,在数据库中锁是为了解决资源争抢的问题,锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访。
数据库系统使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性
InnoDB存储引擎区别于MyISAM的两个重要特征就是:InnoDB存储引擎支持事务和行级别的锁,MyISAM只支持表级别的锁
InnoDB存储引擎中的锁
InnoDB存储引擎实现了如下两种标准的行级锁:
- 共享锁(S Lock),允许事务读一行数据
- 排他锁(X Lock),允许事务删除或更新一行数据
锁的兼容性
- | X | S |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
可以看到,X排他锁不与其他锁兼容,S共享锁只与S兼容
此外,InnoDB存储引擎支持多粒度(granular)锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在
为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁(Intention Lock)。
意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度(fine granularity)上进行加锁
如上图,数据库从上到下可以分为数据库、表、页、记录四个层次,行记录是最细粒度的锁,我们在获取行锁的时候,需要从上到下各个级别分别进行锁定,最后才能获取到行锁。比如,你要获取行记录x的锁,需要先在数据库、表、页上加意向锁IX,其中任何一方需要等待锁,会造成行锁的等待
InnoDB存储引擎支持的意向锁即为表级别的锁。支持两种意向锁
- 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁
- 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫以外的任何请求。故表级意向锁与行级锁的兼容性如表所示
- | IS | IX | S | X |
---|---|---|---|---|
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
一致性非锁定读
一致性的非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。
如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。
相反地,InnoDB存储引擎会去读取行的一个快照数据
快照数据是指该行的之前版本的数据,该实现是通过undo段来完成。而undo用来在事务中回滚数据,因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作
快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本。如上图,记录B就有多个历史的快照版本
这就是大名鼎鼎的MVCC
多版本并发控制(Multi Version Concurrency Control,MVCC)是指一个行记录有多个快照版本,由多个快照版本引发的并发控制,叫做多版本并发控制
那这么多历史的快速版本,访问的时候该用哪一个呢?
- 在READ COMMITTED事务隔离级别下,非一致性读总是读取被锁定行的最新一份快照数据
- 在REPEATABLE READ事务隔离级别下,非一致性读总是读取事务开始时的行数据版本
由此可见,不同的事务隔离级别在MVCC的处理上还不一样
一致性锁定读
在默认配置下,即事务的隔离级别为REPEATABLE READ模式下,InnoDB存储引擎的SELECT操作使用一致性非锁定读
但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性
InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作:
- SELECT…FOR UPDATE 对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁
- SELECT…LOCK IN SHARE MODE对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞
对于一致性非锁定读,即使读取的行已被执行了SELECT…FOR UPDATE,也是可以进行读取的
一致性锁定读则需要检查被读取的行上有没有互斥的锁,假如有互斥的锁存在就需要等待锁的释放
锁的算法
行锁的3种算法
- Record Lock:单个行记录上的锁
- Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
- Next-Key Lock∶Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身
Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定
Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致幻读问题的产生
用户可以通过以下两种方式来显式地关闭Gap Lock:
- 将事务的隔离级别设置为READ COMMITTED
- 将参数innodb_locks_unsafe_for_binlog设置为1
在上述的配置下,除了外键约束和唯一性检查依然需要的Gap Lock,其余情况仅使用RecordLock进行锁定。
但需要牢记的是,上述设置破坏了事务的隔离性,并且对于replication,可能会导致主从数据的不一致。
Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,InnoDB对于行的查询都是采用这种锁定算法
当查询的索引含有唯一属性时,InnoDB存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围
幻像问题
幻像问题(Phantom Problem)是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行
看下面的场景:
- 表t由1、2、5这三个值组成
- 执行select * from t where a > 2 for update;
- 上述事务T1并没有提交,那么此时另一个事务T2插入4这个值,并且数据库允许这个操作
- 那么事务T1再执行上述查询,就得到4、5两笔记录,跟第一次得到的结果不一样,违反了事务的隔离性
InnoDB存储引擎采用Next-Key Locking的算法避免幻像问题。对于上述的SQL语句select * from t where a > 2 for update,其锁住的不是5这单个值,而是对(2,+∞)这个范围加了X锁。因此任何对于这个范围的插入都是不被允许的,从而避免幻像问题
InnoDB存储引擎默认的事务隔离级别是REPEATABLE READ,在该隔离级别下,其采用Next-Key Locking的方式来加锁
而在事务隔离级别READ COMMITTED下,其仅采用RecordLock行锁
锁的问题
脏读
脏读指的就是在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据
脏读发生的条件是需要事务的隔离级别为READ UNCOMMITTED,而目前绝大部分的数据库都至少设置成READCOMMITTED。
InnoDB存储引擎默认的事务隔离级别为READ REPEATABLE,Microsoft SQLServer数据库为READ COMMITTED,Oracle数据库同样也是READ COMMITTED
不可重复读
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读
不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库事务一致性的要求
一般来说,不可重复读的问题是可以接受的,因为其读到的是已经提交的数据,本身并不会带来很大的问题。因此,很多数据库厂商(如Oracle、Microsoft SQL Server)将其数据库事务的默认隔离级别设置为READ COMMITTED,在这种隔离级别下允许不可重复读的现象
在InnoDB存储引擎中,通过使用Next-Key Lock算法来避免不可重复读的问题。
在MySQL官方文档中将不可重复读的问题定义为Phantom Problem,即幻像问题。在Next-Key Lock算法下,对于索引的扫描,不仅是锁住扫描到的索引,而且还锁住这些索引覆盖的范围(gap)。
因此在这个范围内的插入都是不允许的。这样就避免了另外的事务在这个范围内插入数据导致的不可重复读的问题。因此,InnoDB存储引擎的默认事务隔离级别是READ REPEATABLE,采用Next-Key Lock算法,避免了不可重复读的现象
丢失更新
丢失更新是另一个锁导致的问题,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致
- 事务T1将行记录r更新为v1,但是事务T1并未提交
- 与此同时,事务T2将行记录r更新为v2,事务T2未提交
- 事务T1提交
- 事务T2提交
在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。这是因为,即使是READ UNCOMMITTED的事务隔离级别,对于行的DML操作,需要对行或其他粗粒度级别的对象加锁
死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象
解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来设置超时的时间
超时机制虽然简单,但是其仅通过超时后对事务进行回滚的方式来处理,或者说其是根据FIFO的顺序选择回滚对象。但若超时的事务所占权重比较大,如事务操作更新了很多行,占用了较多的undo log,这时采用FIFO的方式,就显得不合适了,因为回滚这个事务的时间相对另一个事务所占用的时间可能会很多
因此,除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来进行死锁检测。
较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。
wait-for graph要求数据库保存以下两种信息
- 锁的信息链表
- 事务等待链表
wait-for graph的死锁检测通常采用深度优先的算法实现,通常来说InnoDB存储引擎选择回滚undo量最小的事务
如果感觉对你有些帮忙,请收藏好,你的关注和点赞是对我最大的鼓励!
如果想跟我一起学习,坚信技术改变世界,请关注【Java天堂】公众号,我会定期分享自己的学习成果,第一时间推送给您