文章目录[隐藏]
锁
1. 概述
锁
是计算机协调多个进程或线程 并发访问某一资源
的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻 最多只有一个线程
在访问,保证数据的 完整性
和 一致性
。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样很重要
在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对 并发操作进行控制
,因此产生了 锁
。同时 锁机制
也为实现 MySQL 的各个隔离级别提供了保证。锁冲突
也是影响数据库 并发访问性能
的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂
2. MySQL 并发事务访问相同记录
并发事务访问相同记录的情况大致可以划分为 3 种:
2.1 读-读情况
读-读
情况,即并发事务相继读取相同的记录
。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生
2.2 写-写情况
写-写
情况,即并发事务相继对相同的记录做出改动
在这种情况下会发生 脏写
的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们 排队执行
,这个排队的过程其实是通过 锁
来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构 和记录进行关联的,如图所示:
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构
,当没有的时候就会在内存中生成一个 锁结构
与之关联。比如,事务 T1
要对这条记录做改动,就需要生成一个 锁结构
与之关联:
在锁结构
里有很多信息,为了简化理解,只把两个比较重要的属性拿了出来:
trx信息
:代表这个锁结构是哪个事务生成的is_waiting
:代表当前事务是否在等待
在事务T1
改动了这条记录后,就生成了一个锁结构
与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting
属性就是false
,我们把这个场景就称值为获取锁成功
,或者加锁成功
,然后就可以继续执行操作了
在事务T1
提交之前,另一个事务T2
也想对该记录做改动,那么先看看有没有锁结构
与这条记录关联,发现有一个锁结构
与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting
属性值为true
,表示当前事务需要等待,我们把这个场景就称之为获取锁失败
,或者加锁失败
,图示:
在事务T1
提交之后,就会把该事务生成的锁结构释放
掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2
还在等待获取锁,所以把事务T2
对应的锁结构的is_waiting
属性设置为false
,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2
就算获取到锁了。效果就是这样
小结:
-
不加锁
意思就是不需要在内存中生成对应的
锁结构
,可以直接执行操作 -
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的
锁结构
,而且锁结构的is_waiting
属性为false
,也就是事务可以继续执行操作 -
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的
锁结构
,不过锁结构的is_waiting
属性为true
,也就是事务需要等待,不可以继续执行操作
2.3 读-写或写-读情况
读-写
或 写-读
,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读
、不可重复读
、幻读
的问题
各个数据库厂商对 SQL标准
的支持都可能不一样。比如MySQL在 REPEATABLE READ
隔离级别上就已经解决了 幻读
问题
2.4 并发问题的解决方案
怎么解决 脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:
- 读操作利用多版本并发控制(
MVCC
),写操作进行加锁
所谓的MVCC
,就是生成一个ReadView
,通过ReadView
找到符合条件的记录版本(历史版本由undo日志
构建)。查询语句只能读
到在生成ReadView
之前已提交事务所做的更改
,在生成ReadView
之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作
肯定针对的是最新版本的记录
,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写
操作并不冲突
普通的
SELECT
语句在READ COMMITTED
和REPEATABLE READ
隔离级别下会使用到MVCC
读取记录
- 在
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行 SELECT 操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改
,也就是避免了脏读现象- 在
REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行 SELECT 操作才会生成一个ReadView,之后的SELECT操作都复用
这个ReadView,这样也就避免了不可重复读和幻读的问题
- 读、写操作都采用
加锁
的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本
。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额:这样在读取记录的时候就需要对其进行加锁
操作,这样也就意味着读
操作和写
操作也像写-写
操作那样排队
执行
-
脏读
的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了 -
不可重复读
的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录自然也不会发生不可重复读了 -
幻读
问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)
小结对比发现:
- 采用
MVCC
方式的话,读-写
操作彼此并不冲突,性能更高
- 采用
加锁
方式的话,读-写
操作彼此需要排队执行
,影响性能
一般情况下我们当然愿意采用 MVCC
来解决 读-写
操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用 加锁
的方式执行。下面就讲解下MySQL中不同类别的锁
3. 锁的不同角度分类
锁的分类图,如下:
3.1 从数据操作的类型划分:读锁、写锁
对于数据库中并发事务的读-读
情况并不会引起什么问题。对于写-写
、读-写
或写-读
这些情况可能会引起一些问题,需要使用MVCC
或者加锁
的方式来解决它们。在使用加锁
的方式解决问题时,由于既要允许读-读
情况不受影响,又要使写-写
、读-写
或写-读
情况中的操作相互阻塞
,所以MySQL实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为共享锁(Shared Lock,S Lock)和排他锁(Exclusive Lock,X Lock),也叫读锁(read lock)和写锁(write lock)
读锁
:也称为共享锁
、英文用 S 表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的写锁
:也称为排他锁
、英文用 X 表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源
需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上
举例(行级读写锁):如果一个事务 T1 已经获得了某个行的读锁,那么此时另外的一个事务 T2 是可以去获得这个行的读锁的,因为读取操作并没有改变 行r 的数据;但是,如果某个事务 T3 想获得 行r 的写锁,则它必须等待事务T1、T2释放掉 行r 上的读锁才行
总结:这里的兼容是指对同一张表或记录的锁的兼容性情况
X锁 | S锁 | |
---|---|---|
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
(1)锁定读
在采用加锁
方式解决脏读
、不可重复读
、幻读
这些问题时,读取一条记录时需要获取该记录的S锁
,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁
,来禁止别的事务读写该记录,为此 MySQL 提出了两种比较特殊的SELECT
语句格式:
-
对读取的记录加
S锁
:SELECT ... LOCK IN SHARE MODE; #或 SELECT ... FOR SHARE; #(8.O新增语法)
在普通的 SELECT 语句后边加
LOCK IN SHARE MODE
,如果当前事务执行了该语句,那么它会为读取到的记录加S锁
,这样允许别的事务继续获取这些记录的S锁
(比方说别的事务也使用SELECT ... LOCK IN SHARE MODE
语句来读取这些记录),但是不能获取这些记录的X锁
(比如使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁
释放掉 -
对读取的记录加
X锁
:SELECT ... FOR UPDATE;
在普通的 SELECT 语句后边加
FOR UPDATE
,如果当前事务执行了该语句,那么它会为读取到的记录加X锁
,这样既不允许别的事务获取这些记录的S锁
(比方说别的事务使用SELECT ... LOCK IN SHARE MODE
语句来读取这些记录),也不允许获取这些记录的X锁
(比如使用SELECT...FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁
或者X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁
释放掉
MySQL8.0新特性:
在5.7及之前的版本,SELECT ... FOR UPDATE
如果获取不到锁,会一直等待,直到 innodb_lock_wait_timeout
超时。在8.O版本中,SELECT ... FOR UPDATE
,SELECT ... FOR SHARE
添加 NOWAIT
、SKIP LOCKED
语法,跳过锁等待,或者跳过锁定
- 通过添加
NOWAIT
、SKIP LOCKED
语法,能够立即返回。如果查询的行已经加锁:- 那么
NOWAIT
会立即报错返回 - 而
SKIP LOCKED
也会立即返回,只是返回的结果中不包含被锁定的行
- 那么
(2)写操作
平常所用到的写操作
无非是DELETE
、UPDATE
、INSERT
这三种:
-
DELETE
:- 对一条记录做 DELETE 操作的过程其实是先在
B+
树中定位到这条记录的位置,然后获取这条记录的X锁
,再执行delete mark
操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁
的锁定读
- 对一条记录做 DELETE 操作的过程其实是先在
-
UPDATE
:在对一条记录做 UPDATE 操作时分为三种情况:- 未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化
则先在
B+
树中定位到这条记录的位置,然后再获取一下记录的X锁
,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
- 未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化
则先在
B+
树中定位到这条记录的位置,然后获取一下记录的X锁
,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
,新插入的记录由INSERT
操作提供的隐式锁
进行保护-
修改了该记录的键值
则相当于在原记录上做
DELETE
操作之后再来一次INSERT
操作,加锁操作就需要按照DELETE
和INSERT
的规则进行了
-
INSERT
:- 一般情况下,新插入一条记录的操作并不加锁,通过一种称之为
隐式锁
的结构来保护这条新插入的记录在本事务提交前不被别的事务访问
- 一般情况下,新插入一条记录的操作并不加锁,通过一种称之为
3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源
的事情(涉及获取、检查、释放锁等动作)。因此数据库系统需要在高并发响应
和系统性能
两方面进行平衡,这样就产生了“锁粒度(Lock granularity)
”的概念
对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别
进行加锁,自然就被称之为表级锁
或者表锁
,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁
(1)表锁(Table Lock)
该锁会锁定整张表,它是 MySQL 中最基本的锁策略,并不依赖于存储引擎
(不管你是 MySQL 的什么存储引擎对于表锁的策略都是一样的),并且表锁是开销最小
的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁
问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣
① 表级别的S锁、X锁
在对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,InnoDB 存储引擎是不会为这个表添加表级别的 S锁
或者 X锁
的。在对某个表执行一些诸如 ALTER TABLE
、DROP TABLE
这类的 DDL 语句时,其他事务对这个表并发执行诸如SELECT
、INSERT
、DELETE
、UPDATE
的语句会发生阻塞。同理,某个事务中对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,在其他会话中对这个表执行 DDL
语句也会发生阻塞。这个过程其实是通过在 server 层使用一种称之为 元数据锁
(英文名: Metadata Locks , 简称 MDL )结构来实现的
一般情况下,不会使用 InnoDB 存储引擎提供的表级别的 S锁
和 X锁
。只会在一些特殊情况下,比方说 崩溃恢复
过程中用到。比如,在系统变量 autocommit = 0,innodb_table_locks = 1
时, 手动获取 InnoDB 存储引擎提供的表t 的 S锁
或者 X锁
可以这么写:
-
LOCK TABLES t READ
:InnoDB存储引擎会对表 t 加表级别的S锁
-
LOCK TABLES t WRITE
:InnoDB存储引擎会对表 t 加表级别的X锁
不过尽量避免在使用 InnoDB 存储引擎的表上使用 LOCK TABLES
这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB 的厉害之处还是实现了更细粒度的 行锁
总结:
MyISAM 在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB 存储引擎是不会为这个表添加表级别的读锁和写锁的
MySQL 的表级锁有两种模式:(以 MyISAM 表进行操作的演示)
-
表共享读锁(Table Read Lock)
-
表独占写锁(Table Write Lock)
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
读锁 | 是 | 否 | 否 | 是 | 否,等 |
写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
② 意向锁 (intention lock)
InnoDB 支持 多粒度锁(multiple granularity locking)
,它允许 行级锁
与 表级锁
共存,而意向锁
就是其中的一种 表锁
- 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁和行锁)的锁并存
- 意向锁是一种
不与行级锁冲突的表级锁
,这一点非常重要 - 表明“某个事务已经持有了某些行的锁或该事务准备去持有锁”
意向锁分为两种:
-
意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 SELECT column FROM table ... LOCK IN SHARE MODE;
-
意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 SELECT column FROM table ... FOR UPDATE;
即:意向锁是由存储引擎 自己维护的
,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前, InooDB 会先获取该数据行 所在数据表的对应意向锁
- 意向锁要解决的问题
现在有两个事务,分别是 T1 和 T2 ,其中 T2 试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么 T2 就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由 T1 控制的表级别意向锁的阻塞
。T2 在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁
在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可
- 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上
添加意向共享锁
- 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上
添加意向排他锁
这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录
- 兼容性
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
即意向锁之间是互相兼容的
,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
共享锁(S) | 兼容 | 互斥 |
排他锁(X) | 互斥 | 互斥 |
注意这里的排他/共享锁指的都是表锁,意向锁不会与行级的共享 / 排他锁互斥
③ 自增锁(AUTO-INC锁)
MySQL 中采用了自增锁
的方式来实现,AUTO-INC 锁是当向使用含有 AUTO_INCREMENT 列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。也正因为此,其并发性显然并不高,当我们向一个有 AUTO_INCREMENT 关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所 innodb 通过 innodb_autoinc_lock_mode
的不同取值来提供不同的锁定机制,来显著提高 SQL 语句的可伸缩性和性能
以下是关于 innodb_autoinc_lock_mode
的三种取值及其特性的分点展示:
- TRADITIONAL(0)
- 锁定行为: 持有持久的自增锁,直到整个 SQL 语句完成
- 优点: 最安全,确保自增 ID 的连续性和一致性
- 缺点: 可能是最慢的模式,因为会在事务完成前锁定自增计数器
- CONSECUTIVE(1)
- 锁定行为: 只在需要时获取自增锁,并尽快释放
- 优点: 提供了更好的并发性能
- 缺点: 自增 ID 可能不连续
- INTERLEAVED(2)
- 锁定行为: 不会为 AUTO_INCREMENT 列获取专用的锁
- 优点: 提供了最好的并发性能
- 缺点: 自增 ID 可能非常不连续,在数据复制或恢复等操作中可能有问题
默认设置: 从 MySQL8.0 开始,默认值是 CONSECUTIVE(2),这个设置旨在提供最高的并发性能,但需要注意的是,这可能会导致自增 ID 非常不连续,并且在数据复制或恢复等操作中可能会出现问题
④ 元数据锁(MDL锁)
元数据锁(Metadata Lock,简称 MDL)是一种在数据库系统中用于同步对数据库对象(如表、视图、存储过程等)的访问的锁机制。这种锁主要用于确保在一个事务正在读取或修改某个数据库对象时,其他事务不能对该对象进行结构性的更改(如删除表或更改表结构)
主要特点:
- 防止结构更改: 当一个事务正在使用某个数据库对象时(例如,读取一个表),元数据锁确保其他事务不能删除或更改该对象的结构
- 兼容性: 元数据锁通常是兼容的,意味着多个事务可以同时持有读取相同对象的锁,但如果一个事务需要进行结构更改,则必须等待其他所有锁释放
- 动态: 元数据锁通常是动态获取和释放的,而不需要事务明确地请求或释放锁
- 短暂: 一旦事务完成其对数据库对象的操作,元数据锁通常会立即释放,以便其他事务可以进行结构更改
应用场景:
- 在执行
ALTER TABLE
、DROP TABLE
或其他会更改数据库结构的语句之前,需要获取元数据锁 - 在执行查询操作(如
SELECT
)时,也可能会暂时获取元数据锁以确保数据的一致性
(2)InnoDB 中的行锁
行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录 row)。需要注意的是,MySQL 服务器层并没有实现行锁机制,行级锁只在存储引擎层实现
优点:锁定力度小,发生锁冲突概率低
,可以实现的并发度高
缺点:对于锁的开销比较大
,加锁会比较慢,容易出现死锁
情况
InnoDB 与 MyISAM 的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁
① 记录锁(Record Locks)
记录锁也就是仅仅把一条记录锁住,官方的类型名称为:LOCK_REC_NOT_GAP
记录锁是有 S锁 和 X锁 之分的,称之为 S型记录锁
和 X型记录锁
- 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁
- 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁
② 间隙锁(Gap Locks)
MySQL
在 REPEATABLE READ
隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC
方案解决,也可以采用 加锁
方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些 幻影记录
加上 记录锁
。InnoDB 提出了一种称之为 Gap Locks
的锁,官方的类型名称为: LOCK_GAP
,我们可以简称为 gap锁
。比如,把id值为8的那条记录加一个gap锁的示意图如下:
图中id值为8的记录加了gap锁,意味着 不允许别的事务在id值为8的记录前边的间隙插入新记录
,其实就是id列的值(3,8)这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为4的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(3, 8)中的新记录才可以被插入
gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享gap锁
和独占gap锁
这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁
③ 临键锁(Next-Key Locks)
有时候我们既想 锁住某条记录
,又想阻止其他事务在该记录前边的间隙插入新记录,所以 InnoDB 就提出了一种称之为 Next-Key Locks
的锁,官方的类型名称为:LOCK_ORDINARY
,我们也可以简称为 next-key锁
。Next-Key Locks
是在存储引擎 InnoDB 、事务级别在可重复读 的情况下使用的数据库锁, InnoDB 默认的锁就是Next-Key locks
。比如,我们把id值为8的那条记录加一个next-key锁的示意图如下:
next-key锁
的本质就是一个记录锁
和一个gap锁
的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙
④ 插入意向锁(Insert Intention Locks)
我们说一个事务在插入一条记录时需要判断一下插入
位置是不是被别的事务加了gap锁
(next-key锁
也包含 gap锁
),如果有的话,插入操作需要等待,直到拥有gap锁
的那个事务提交。但是InnoDB 规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙
中插入
新记录,但是现在在等待。InnoDB 就把这种类型的锁命名为Insert Intention Locks
,官方的类型名称为:LOCK_INSERT_INTENTION
,我们称为插入意向锁
。插入意向锁是一种Gap锁
,不是意向锁,在 INSERT 操作时产生
插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁
。该锁用以表示插入意向,当多个事务在同一区间(gap)插入位置不同的多条数据时,事务之间不需要互相等待。假设存在两条值分别为4和7的记录,两个不同的事务分别试图插入值为5和6的两条记录,每个事务在获取插入行上独占的(排他)锁前,都会获取(4,7)之间的间隙锁,但是因为数据行之间并不冲突
,所以两个事务之间并不会产生冲突(阻塞等待)。总结来说,插入意向锁的特性可以分成两部分:
- 插入意向锁是一种
特殊的间隙锁
一一间隙锁可以锁定开区间内的部分记录 - 插入意向锁之间
互不排斥
,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待
虽然插入意向锁中含有意向锁三个字,但是它并不属于意向锁而属于间隙锁,因为意向锁是表锁而插入意向锁是
行锁
(3)页锁
页锁就是在 页的粒度
上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的
。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级
。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了
3.3 从对待锁的态度划分:乐观锁、悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待 数据并发的思维方式
。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的 设计思想
(1)悲观锁(Pessimistic Locking)
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞
直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现
(2)乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用 版本号机制
或者 CAS机制
实现。乐观锁适用于多读的应用类型, 这样可以提高吞吐量。在Java中 java.util.concurrent.atomic
包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
1. 乐观锁的版本号机制
在表中设计一个 版本字段 version
,第一次读的时候,会获取 version
字段的取值。然后对数据进行更新或删除操作时,会执行 UPDATE ... SET version=version+1 WHERE version=version
。此时 如果已经有事务对这条数据进行了更改,修改就不会成功
这种方式类似我们熟悉的SVN、CVS版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交
2. 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新
(3)两种锁的适用场景
从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:
乐观锁
适合读操作多
的场景,相对来说写的操作比较少。它的优点在于程序实现
,不存在死锁
问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作悲观锁
适合写操作多
的场景,因为写的操作具有排它性
。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写
和写 - 写
的冲突