文章目录
- 数据库中的锁
- 锁与索引的关系
- 释放锁的时机
- 乐观锁与悲观锁
- 行锁与表锁
- 共享锁与排它锁
- 意向锁
- 记录锁、间隙锁和临键锁
- 记录锁
- 间隙锁
- 临键锁
- 锁优化方案
- MVCC协议
- MySQL的隔离级别
- 脏读和幻读
- 快照读和当前读
- 版本链
- Read View
- Read View 与已提交读
- Read View 与可重复读
- m_up_limit_id 和 m_low_limit_id
数据库中的锁
数据库中为什么需要「锁」,顾名思义,锁就是为了解决并发问题的,就像是一个柜子加了一把锁,那么其他没有钥匙的人都打不开这个柜子(无法写入数据)。
在 MySQL 的 InnoDB 引擎里面,锁和索引、隔离级别都是有密切关系的,锁是依赖于索引来实现的。如果一个查询用了索引,那么会用行锁;如果没用到任何索引,那么就会用表锁。此外,在 MySQL 里面,间隙锁和临键锁是只工作在可重复读这个隔离级别下的。
从锁的范围来看,可以分成行锁和表锁。从排它性来看,可以分成排它锁和共享锁。还有意向锁,结合排它性,就分为排它意向锁和共享意向锁。记录锁,是指锁住某条记录;间隙锁,是指锁住两条记录之间的位置;临键锁可以看成是记录锁与间隙锁的组合情况。
锁与索引的关系
在 数据库中索引的底层原理和SQL优化 的内容中主要分析了数据库中索引的内容,类似的,和索引关联性较多的还有“锁”的用法。在 MySQL 的 InnoDB 引擎里面,锁是借助索引来实现的。或者说,加锁锁住的其实是索引项,更加具体地来说,就是锁住了 叶子节点。
比如这样一条SQL语句:SELECT * FROM table_name WHERE id = 31 FOR UPDATE
,查询 id=31 的数据,会锁住索引项 31。
一个表有很多索引,那么具体锁的是哪个索引呢?其实就是查询最终使用的那个索引。万一查询没有使用任何索引呢?那么锁住的就是整个表,也就是此时退化为表锁。
如果查询条件的值并不存在,例如:SELECT * FROM table_name WHERE id = 15 FOR UPDATE
,此时 id = 15 的值根本不存在,那么 InnoDB 引擎会利用最接近 15 的相邻的两个节点,构造一个临键锁。比如上面的数据中,会锁住(12,17]。此时如果别的事务想要插入一个 id=15 的记录,就不会成功。
对于范围查询,比如 SELECT * FROM table_name WHERE id > 33 FOR UPDATE
,InnoDB 引擎会构造一个(33,MAX] 的临键锁,锁住整个范围。这里的MAX可以理解为 MySQL 认为的一个虚拟的最大值。
释放锁的时机
思考一个问题:锁是在语句执行完毕之后就立刻释放掉了吗?
答案是:并不是,事务内的锁加上就会一直持有,直到事务结束。数据库的锁是在整个事务结束之后才释放的,当一个事务内部给数据加上锁之后, 只有在执行 Rollback 或者 Commit 的时候,锁才会被释放掉。
乐观锁与悲观锁
乐观锁和悲观锁实际上是一种逻辑概念,它们是并发控制中常用的两种锁机制。
- 乐观锁是最终要修改数据的时候,才检测数据是否已经被别人修改过。
- 悲观锁是在初始时刻就直接加锁保护好临界资源。
乐观锁一般的使用形态就是下面这样的。
SELECT * FROM table_name WHERE id = 1; // 在这里拿到了 a = 1
// 一大堆的业务操作...
UPDATE table_name SET a = 3, b = 4 WHERE id = 1 AND a =1
在上面的这个语句里面,预期数据库中 a 的值为 1 才会进行更新。如果此时数据库中的值已经被修改了,那么这个 UPDATE 语句就会失败。业务方通过检测受影响的行数是否为 0,来判断更新是否成功。
乐观锁的两种情况分析如下:
悲观锁不管读写都要直接加锁。还拿上面这个例子来说吧,就是从最开始的 SELECT 语句就直接加上了锁。
SELECT * FROM table_name WHERE id = 1 FOR UPDATE; // 在这里拿到了 a = 1
// 一大堆的业务操作...
UPDATE table_name SET a = 3, b = 4 WHERE id = 1
在加上锁之后,就可以直接更新了,这个时候不需要担心别人可以在 SELECT 和 UPDATE 之间将 a 更新为别的值。
在使用乐观锁和悲观锁时,需要考虑数据一致性和并发性的问题。乐观锁适用于读多写少的场景,互联网中大部分应用都属于这一类。而悲观锁则适用于写多读少的场景,比如在金融领域里面对金额的操作就是以写为主。 相比之下,乐观锁的性能要比悲观锁好很多。
行锁与表锁
行锁与表锁都是根据锁的范围来划分的。
- 行锁是指锁住行,可能是锁住一行,也可能是锁住多行。
- 表锁则是直接将整个表都锁住。
在 MySQL 里面,InnoDB 引擎同时支持行锁和表锁。但是行锁是借助索引来实现的,也就是说,如果你的查询没有命中任何的索引,那么 InnoDB 引擎是用不了行锁的,只能使用表锁。当然,如果用的是 MySQL的其他引擎(比如 MyISAM 引擎),那么只能使用表锁,因为这些引擎不支持行锁。
共享锁与排它锁
共享锁和排它锁是在互斥的角度上看待锁的。
- 共享锁(也叫做读锁):是指一个线程加锁之后,其他线程还是可以继续加同类型的锁。
- 排它锁(也叫做写锁):是指一个线程加锁之后,其他线程就不能再加锁了。
意向锁
意向锁相当于一个信号,就是告诉别人我要加锁了,所以意向锁并不是一个真正物理意义上的锁。
意向锁和共享锁、排它锁相结合,就有了意向共享锁和意向排它锁。
- 意向共享锁:是指你希望获得一个共享锁。
- 意向排它锁:是指你希望获得一个排它锁。
注意,意向锁的意向强调的就是你想要拿到这个锁,但是你最终能否拿到这个锁,是不确定的。
在 MySQL 里面,使用意向锁的典型场景是在增删改查的时候,对表结构定义加一个意向共享锁,防止在查询的时候有人修改表结构。而在修改表结构的时候,则会加一个意向排它锁。这也就是修改表结构的时候会直接阻塞掉所有的增删改查语句的原因。 使用意向锁能够提高数据库的并发性能,并且避免死锁问题。
记录锁、间隙锁和临键锁
记录锁
记录锁是指锁住了特定的某一条记录的锁。例如这样一条语句:SELECT * FROM table_name WHERE id = 31 FOR UPDATE
在你使用了主键作为查询条件,并且是相等条件下,如果命中了一条记录,这一条记录就会被加上记录锁。
但是如果查询条件没有命中任何记录,那么就不会使用记录锁,而是使用间隙锁。又或者你使用了唯一索引作为条件,比如说在数据表里面的 email 列上有一个唯一索引,那么 SELECT * FROM table_name WHERE email='your_email' FOR UPDATE
这条查询语句此时也是使用了记录锁。类似地,如果 email='your_email'
这条记录不存在,那么会变成一个间隙锁。
再举个例子,如果数据库中只有 id 为(1,4,7)的三条记录,也就是 id= 3 这个条件没有命中任何数据,那么这条语句会在(1,4)加上间隙锁。所以你可以看到, 在生产环境里面遇到了未命中索引的情况,对性能影响极大。
实际上MySQL本身是加上临键锁的,但是临键锁本身是由间隙锁和记录锁合并组成的。
间隙锁
间隙锁是锁住了某一段记录的锁,直观来说就是锁住了一个范围的记录。比如在查询的时候使用了 <、<=、BETWEEN
之类的范围查询条件,就会使用间隙锁。
比如 SELECT * FROM table_name WHERE id BETWEEN 50 AND 100 FOR UPDATE
这条语句,间隙锁会锁住 (50,100) 之间的数据,而 50 和 100 本身会被记录锁锁住。类似地,<= 这种查询你也可以认为 = 的那个值会被记录锁锁住。
如果你的表里面没有 50,那么数据库就会一直向左,找到第一个存在的数据,比如说 40;如果你的表里面没有 100,那么数据库就会一直向右,找到第一个存在的数据,比如说 120。那么使用的间隙锁就是 (40,120)。如果此时有人想要插入一个主键为 70 的行,是无法插入的,它需要等这个 SELECT 语句释放掉间隙锁。
一般情况下,间隙锁的两边都是开的,即端点是没有被间隙锁锁住的。 记录锁和记录锁是排它的,但是间隙锁和间隙锁不是排它的。也就是说两个间隙锁之间即便重叠了,也还是可以加锁成功的。
临键锁
临键锁(Next-Key Locks)可以看做是一个记录锁和间隙锁的组合,也就是说 临键锁不仅仅是会用记录锁锁住命中的记录,也会用间隙锁锁住记录之间的空隙。临键锁和数据库隔离级别的联系最为紧密,它可以解决在可重复读隔离级别之下的幻读问题。
间隙锁是左开右开,而临键锁是 左开右闭。还是用前面的例子来说明。如果 id 只有(1,4,7)三条记录,那么临键锁就将(1,4]锁住。
所谓next-key的意思就是下一个索引的意思,也就是锁住查询结果中的最大索引值与下一个索引值之间的区域。这就意味着,临键锁锁住的最后的那个区间,是当前命中的索引最大值到下一个索引的区间,如果没有下一个索引,那就是锁住了剩下所有区间。
间隙锁和临键锁是在可重复读的隔离级别下才有效果的。
判断使用的是记录锁、间隙锁还是临键锁。
- 遇事不决临键锁。你可以认为,全部都是加临键锁的,除了下面两个子句提到的例外情况。
- 右边缺省间隙锁。例如你的值只有(1,4,7)三个,但是你查询的条件是 WHERE id < 5,那么加的其实是间隙锁,因为 7 本身不在你的条件范围内。
- 等值查询记录锁。这个其实针对的是主键和唯一索引,普通索引只适用上面两条。
锁优化方案
MySQL 的锁是依赖索引机制来实现的,如果查询没有使用任何索引,就会使用表锁。那么最简单的方案就是给这种查询创建一个索引,避免使用表锁。
假设有这么一个很常见的业务场景:先从数据库中查询数据并锁住,如果这个数据不存在,那么就执行业务逻辑1,然后执行插入操作;如果这个数据存在,那么就执行业务逻辑2,然后执行更新操作。
对于查询的数据不存在的情况,插入新数据的操作,用伪代码来描述大概是下面这样的:
BEGIN;
SELECT * FROM biz WHERE id = ? FOR UPDATE
// 中间有很多业务操作...
INSERT INTO biz(id, data) VALUE(?, ?);
COMMIT;
上面的语句表面上看起来没有任何问题,但实际上这个地方会引起 死锁。假设现在数据库中 ID 最大的值是 78,如果此时两个业务并发的同时执行这个逻辑,一个准备插入 id=79 的数据,一个准备插入 id = 80 的数据。如果它们的执行时序如下图,那么就会产生一个死锁错误。
针对以上问题,解决方案一般有三种。
- 不管有没有数据,先插入一个默认的数据。如果没有数据,那么会插入成功;如果有数据,那么会出现主键冲突或者唯一索引冲突,插入失败。那么在插入成功的时候,执行以前数据不存在的逻辑,但是因为此时数据库中有数据,所以不会使用间隙锁,而是使用行锁,从而规避了死锁问题。
- 调整数据库的隔离级别,降低为已提交读,那么就没有间隙锁了。
- 放弃悲观锁,使用乐观锁。
很多人为了省事,在开发的时候直接使用悲观锁,最为典型的例子就是在事务里面存在 SELECT ... FOR UPDATE
的语句,而后会紧跟着一个 UPDATE 语句。
// 开启事务
BEGIN;
// 查询到已有的数据
SELECT * FROM xxx WHERE id = 1 FOR UPDATE
// 其他业务逻辑操作...
// 将新数据写回去数据库
UPDATE xxx SET data = newData WHERE id =1
COMMIT;
MVCC协议
MVCC(Multi-Version Concurrency Control)中文叫做多版本并发控制协议,是 MySQL InnoDB 引擎用于控制数据并发访问的协议。通过上面的内容,可以知道「锁」本身就是用于并发控制的,那么为什么 InnoDB 还需要引入 MVCC,读写都加锁不就可以控制住并发吗?
实际上,锁确实可以实现并发控制,但是性能太差。如果是纯粹的锁,那么写和写、读和写、读和读之间都是互斥的。如果是读写锁,那么写和写、读和写之间依旧是互斥的。
数据库相比于一般的应用有一个很大的区别,就是 数据库即便是读,也不能被写阻塞住。 假设有一个线程准备执行 UPDATE语句修改数据,如果这时候阻塞住了所有的 SELECT 语句,那么这个性能肯定是不能接受的。所以数据库要有一种机制,避免读写阻塞。因此在MySQL中,MVCC必不可少。
要深入了解MVCC机制,需要先知道一个和MVCC紧密关联的概念: 隔离级别。
MySQL的隔离级别
数据库的隔离级别是 一组规则, 用来控制并发访问数据库时如何分配、保护和共享资源。不同的隔离级别在不同的并发控制策略之间进行调整,从而提供了不同的读写隔离级别和安全性。简单来说,隔离级别代表了一个事务是否了解别的事务以及了解程度怎么样。
MySQL 的隔离级别有四个。
-
读未提交(Read Uncommitted)是指一个事务可以看到另外一个事务尚未提交的修改。
-
读已提交(Read Committed,简写 RC)是指一个事务只能看到已经提交的事务的修改。这意味着 如果在事务执行过程中有别的事务提交了,那么事务还是能够看到别的事务最新提交的修改。
-
可重复读(Repeatable Read,简写 RR)是指在这一个事务内部读同一个数据多次,读到的结果都是同一个。这意味着即便 在事务执行过程中有别的事务提交,这个事务依旧看不到别的事务提交的修改。这是 MySQL 默认的隔离级别。
-
串行化(Serializable)是指事务对数据的读写都是串行化的。
以上四种隔离级别,从上到下,隔离性变强但是性能变差。所以一个提升 MySQL 性能最简单的方式,就是将隔离级别降低。隔离级别越低,意味着系统吞吐量(并发程度)越大,但同时也意味着出现异常问题的可能性会更大。 在实际使用过程中我们往往需要在性能和正确性上进行权衡和取舍,没有完美的解决方案,只有适合与否。
脏读和幻读
和隔离级别密切相关的概念是「脏读、幻读、不可重复读」这三个读异常。
- 脏读 是指读到了别的事务还没有提交的数据。之所以叫做“脏”读,就是因为未提交数据可能会被回滚掉。
- 不可重复读 是指在一个事务执行过程中,对同一行数据读到的结果不同。
- 幻读 是指在事务执行过程中,别的事务插入了新的数据并且提交了,然后事务在后续步骤中读到了这个新的数据。
隔离级别与读异常 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 可能 | 可能 | 可能 |
读已提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 不可能(理论上可能) |
串行化 | 不可能 | 不可能 | 不可能 |
这里尤其要注意一点,就是理论上来说可重复读是没有解决幻读的,但是 MySQL 因为使用了临键锁,因此它的可重复读隔离级别已经解决了幻读问题。
【问题】将事务的隔离级别调整为
读已提交
之后,万一需要可重复读的特性了,应该怎么办?
【分析】首先你要理解在什么样的场景下你才会需要可重复读这个隔离级别。
- 你需要在事务中发起两次同样的查询,并且你希望两次得到的结果是一样的。
- 你需要避开幻读,也就是事务开始之后,即便有别的事务插入了数据并且提交了,你也不希望读到这个新数据。
仔细想想,你真的需要使用
可重复读
这个隔离级别吗?答案是几乎没有。【回答】关键词是 改造业务。
- 正常来说是不推荐使用可重复读的,因为可以通过缓存第一次查询的数据来避免第二次查询,但是这种改造一般是避不开幻读的。不过在业务上幻读一般不是问题,因为事务提交了往往代表业务已经结束,那么就算发生幻读了,业务依旧是正常的。比如说事务 A 读到了事务 B 新插入的数据,但是事务 B 本身已经提交了,那么事务 A 就认为事务 B 所在的业务已经完结了,那么读到了就读到了,并不会出什么问题。
- 万一不能改造业务,那么还有一个方法,就是直接在特殊的业务场景下创建事务的时候指定隔离级别。
快照读和当前读
还有两个相似的概念:快照读和当前读。
- 快照读:在事务开始的时候创建了一个数据的快照,在整个事务过程中都读这个快照。一般不加锁的 SELECT 都属于快照读,比如:
SELECT * FROM users WHERE id=1;
- 当前读:每次都去读最新数据,而不是历史版本的数据。加锁的 SELECT,或者对数据进行增删改以及DML操作都属于当前读,比如:
SELECT * FROM users LOCK IN SHARE MODE; SELECT * FROM users FOR UPDATE; INSERT INTO users values ... DELETE FROM users WHERE ... UPDATE users SET ...
MySQL 在可重复读这个隔离级别下,查询的执行效果和快照读非常接近。
其它参考:深入理解MySQL中的事务和锁
版本链
为了实现 MVCC,InnoDB 引擎给每一行都加了两个额外的字段 trx_id 和 roll_ptr。
- trx_id:事务ID,也叫做事务版本号,MVCC 里面的 V 指的就是这个数字。每一个事务在开始的时候就会获得一个 ID,然后这个事务内操作的行的事务 ID,都会被修改为这个事务的 ID。
- roll_ptr:回滚指针,InnoDB 通过 roll_ptr 把每一行的历史版本串联在一起。
实际上,InnoDB 引擎还隐式地插入了另外一个列 row_id,如果你没有设置任何主键,那么这个列就会被当成主键来使用。但是它其实和 MVCC 没太大的关系。
下面用一个例子来说明 MVCC 是如何利用 trx_id 和 roll_ptr 这两个列的。
假设最开始插入了一行数据,插入数据的事务ID 是 100,这个时候数据行是这样的:
然后有一个事务 A 拿到了 ID 101,然后把 x 的值修改为 15,事务 A 修改后的 roll_ptr 会指向初始状态的数据:
假如现在再来一个事务 B 拿到 ID 102 ,要把数据 x 修改成 20,就会变成下面这样:
这样的链条就是「版本链」,这个版本链存储在 undolog 里面,undolog 里面存放着历史版本的数据。(关于 undolog 后面再讨论)
现在问题来了,假如这个时候我有一个新的事务 C,我要读 x 的值,那么我该读取 trx_id 为哪个的数据呢?这就涉及到了另外一个和 MVCC 紧密相关的概念:Read View。
Read View
Read View 可以理解成一种可见性规则。由于 undolog 里面存放着历史版本的数据,当事务内部要读取数据的时候,Read View 就被用来控制这个事务应该读取哪个版本的数据。
Read View 最关键的字段叫做 m_ids
,它代表的是当前已经开始,但是还没有结束的事务的 ID
,也叫做活跃事务 ID。
Read View 只用于已提交读和可重复读 两个隔离级别,不同点就在于 什么时候生成 Read View。
- 已提交读:事务每次发起查询的时候,都会重新创建一个新的 Read View。
- 可重复读:事务开始的时候,只创建一次 Read View,后续每次发起查询都用这个 Read View。
Read View 与已提交读
在已提交读
的隔离级别下,每一次查询语句都会重新生成一个 Read View,这意味着在事务执行过程中,Read View 是在不断变动的。来看一个例子,假如说现在已经有三个事务了,状态分别是已提交、未提交、未提交。
假如现在新开了一个事务A (trx_id=4),如果这个时候 事务A 开始查询 x 的值,那么 MySQL 会创建一个新的 Read View,其中 m_ids = 2,3
。事务A 发现最后一个已经提交的是事务 trx_id = 1
,对应的 x 的值是 1,于是事务A 读到 x = 1
。
如果这个时候事务2 提交了,事务A 再次读取 x,这个时候 MySQL 又会生成一个新的 Read View :m_ids=3
。因此事务 A 会读取到 x = 4。
Read View 与可重复读
在可重复读的隔离级别下,数据库会在事务开始的时候生成一个 Read View,然后这个 Read View 在事务执行过程中都是不变的。还是用前面的例子来说明,就是在事务 A 开始的时候就会创建出来一个 Read View m_ids=2,3
。
如果这时候事务 A 去读 x 的数据,读出来的是 x=1。
如果这时候事务2 提交了,然后事务 A 想要再去读 x 的值,Read View 不会发生变化,还是 m_ids = 2,3
。因此可以看到,虽然事务2 提交了,但是事务 A 完全不知道这回事,因此它还是读到 x=1。
万一这时候有一个新事务 ID = 5 开始了,并且也提交了。那么事务 A 并不会读取这个新事务的数据,因为新事务 ID 已经大于事务 A 的 ID 了(5 > 4),事务 A 知道这是一个比它还要晚的事务,所以会忽略新的事务的修改。
m_up_limit_id 和 m_low_limit_id
实际上和 Read View 相关的概念一共有四个:
- m_ids:当前已经开始,但是还没有结束的事务的 ID。
- m_up_limit_id:m_ids 中的最小值。
- m_low_limit_id:下一个分配的事务 ID。
- m_creator_trx_id:当前事务 ID。
它们之间的可见性如下图所示:
【总结】
- 单纯使用锁的时候,并发性能会比较差。即便是在读写锁这种机制下,读和写依旧是互斥的。而数据库是一个性能非常关键的中间件,如果某个线程修改某条数据就让其他线程都不能读这条数据,这种性能损耗是无法接受的。所以 InnoDB 引擎引入了 MVCC,就是为了减少读写阻塞。
- MVCC 是 MySQL InnoDB 引擎用于控制数据并发访问的协议,MVCC 主要是借助于版本链来实现的。在 InnoDB 引擎里面,每一行都有两个额外的列,一个是 trx_id,代表的是修改这一行数据的事务 ID;另外一个是 roll_ptr,代表的是回滚指针。InnoDB 引擎通过回滚指针,将数据的不同版本串联在一起,也就是版本链。这些串联起来的历史版本,被放到了 undolog 里面,当某一个事务发起查询的时候,MVCC 会根据事务的隔离级别来生成不同的 Read View,从而控制事务查询最终得到的结果。
- 四个隔离级别和三个读异常:在 MySQL 的 InnoDB 引擎里面,使用了临键锁来解决幻读的问题,所以实际上 MySQL InnoDB 引擎的可重复读隔离级别也没有幻读的问题。一般来说,隔离级别越高,性能越差。
- 如果你的业务场景很少利用可重复读的特性(比如说几乎全部事务内部对某一个数据都是只读一次的), 那么为了提高查询性能,可以考虑将隔离级别降低为
读已提交
。并且,可重复读
比读已提交
更加容易引起死锁的问题。