前面已经说过了事务,隔离性和MySQL的锁,涵盖了大部分并发控制的场景。那么接下来更深入的来了解下MySQL的并发控制。
首先,我们来回顾下事务,隔离级别和锁。
1.回顾事务,隔离级别和锁
1.1.事务和事务的特性
数据库事务是访问并可能操作各种数据项的一个数据操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之前执行的全部数据库操作组成。
事务的特性:
- 原子性(Atomicity),原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生;
- 一致性(Consistency),事务必须使数据库从一个一致性状态变换到另外一个一致性状态;
- 隔离性(Isolation),事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发中其他事务是隔离的,并发执行的各个事务之间不能互相干扰;
- 持久性(Durability),事务一旦被提交,数据库中数据的改变就是永久性的,不会被回滚。
1.2.隔离级别
MySQL实现了标准的隔离级别:
- 读未提交(read_uncommited),允许其他事务读取当前事务未提交的修改数据;
- 读已提交(read_commited),允许其他事务读取当前事务提交的修改;
- 可重复读(repeatable_read),确保事务可以多次从一个字段中读取到相同的值,即当前事务未提交前,不允许其他事务进行修改;
- 串行化(serializable),确保事务在可以多次从表中读取到相同的数据,在当前事务执行期间,不允许其他事务对该表进行修改(新增,修改和删除)操作。
数据库并发中存在的问题:
- 脏读(Dirty Read),事务中的修改,即使没有提交,对其他事务也是可见的。
- 不可重复读(Non-Repeatable Read),事务可以读取到已经提交的修改,这会导致,如果事务中存在多次查询,前后查询会出现差异;
- 幻读(Phantom Read),事务在读取某个范围内的记录时,其他事务在该范围内插入了新的记录,当事务再次读取该范围内的记录时,会产生幻行(Phantom Row)。
串行化可以解决并发操作中的所有问题,但是同一时间只能进行一个更新操作(新增,修改,删除)。
可重复读解决不可重复读的问题(有点奇怪,hhh)。
读已提交解决了脏读的问题,读未提交什么都解决不了。
1.3.MVCC
MVCC,多版本并发控制,是为了解决不可重复读问题的关键技术。通过当前时点上全库快照(可以简单理解为快照事务ID)和undo log来实现,当开启某个事务时,记录下事务ID,随后如果数据被其他事务更新,就可以利用当前数据,通过undo log回推事务开启时的数据,这称为“一致性读“。
“一致性读“适用于查询语句(该事物中没有更新数据)。
相反的还有“当前读“,针对于事务中存在更新语句的场景,在更新语句前,先进行查询(读已提交类型的查询),然后进行更新操作,这么做的主要目的是为了避免其他已经提交的事务修改的数据被覆盖。
1.4.表锁和行锁
表锁和行锁是MySQL中最基础的两种锁(注意,行锁由存储引擎自己实现,部分存储引擎不支持,如MyISAM)。表锁和行锁,根据字面意思就很好理解了,不多做解释。根据读写特性,可以细分为4种锁:
| 表锁 | 行锁 | |
|---|---|---|
| 读锁(共享锁) | 共享-表锁 | 共享-行锁 |
| 写锁(排它锁) | 排它-表锁 | 排它-行锁 |
排它锁会排斥任何类型的锁(霸道总裁型),因此共享锁也就只能共享给共享锁(绕口令啊!!!)。
| 读锁(共享锁) | 写锁(排它锁) | |
|---|---|---|
| 读锁(共享锁) | √ | × |
| 写锁(排它锁) | × | × |
1.5.死锁
死锁是指两个或多个事务在同一资源上相互占有,并请求锁定对方的资源,从而导致恶性循环的现象。
造成死锁的场景一般是把持住手里的资源不释放,同时又互相抢夺资源。
2.幻读
好了,回顾了前面3篇的大致内容,你应该都想起来了吧(我也水了1600多字)。那么进入这次的正题,幻读,间隙锁和Next-Key Lock。首先我们来看幻读。
幻读的概念已经说过了,如果不理解没关系,我们来看一个例子,假设我们有如下表,并插入如下数据:
CREATE TABLE `test` (
`a` int NOT NULL,
`b` int DEFAULT NULL,
`c` int DEFAULT NULL,
PRIMARY KEY (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `MySQL_45`.`test` (`a`, `b`, `c`) VALUES (1, 1, 1);
INSERT INTO `MySQL_45`.`test` (`a`, `b`, `c`) VALUES (3, 3, 3);
INSERT INTO `MySQL_45`.`test` (`a`, `b`, `c`) VALUES (5, 5, 5);
INSERT INTO `MySQL_45`.`test` (`a`, `b`, `c`) VALUES (7, 7, 7);
INSERT INTO `MySQL_45`.`test` (`a`, `b`, `c`) VALUES (9, 9, 9);
复制代码
现在我们开启两个事务A和事务B,时序如下(假设当前只有表锁和行锁):
- T1时间,开启A,B两个事务;
- T2时间,事务A查询 3 <= c <= 5;
- T3时间,事务B插入一条a,b,c均为6的数据;
- T4时间,提交事务B;
- T5时间,事务A查询 3 <= c <= 5;
- T6时间,提交事务A。
在之前的《MVCC和可重复读》的内容中,我们说到,在可重复读的隔离级别下,使用查询语句时,MySQL会创建快照,按照一致性读的规则去查询数据,在update语句时,会按照当前读的规则去修改数据。
所以,在事务A中,T5时间,查询到结果是2条,T6时间,查询到结果是3条,T6时间查询到的结果比预期多了一条,这就是幻读。
至于真实的执行情况,你可以试一试。
我们期望,事务A中,没有更新语句的话,查询到的数据是一致的,可是幻读缺破坏了这种数据一致性的场景,那么我们如何解决这种问题呢?
先来看看这个问题,本质上是事务A和事务B并发执行,造成的问题,针对于并发问题,我们最容易想到的就是加锁,可是这个锁该怎么加?
- 加行锁,锁定 3 <= c <= 5 的所有数据,那也就是给c in (3, 5)的两条数据上锁,可是不影响插入一条c = 6的数据;
- 加表锁,能够解决这个问题,可是代价呢(来自古尔丹的拷问),并发性能大大下降。
那么MySQL是怎么解决的呢?
3.间隙锁和Next-Key Lock
间隙锁
InnoDB引入了新的锁,叫做间隙锁(Gap Locks),先来看下官方文档是怎么定义的:
A gap lock is a lock on a gap between index records, or a lock on the gap before the first or after the last index record. -- 《MySQL 5.7 官方手册》
简单说就是对索引记录的间隙锁定,或者是在第一个索引记录之前或最后一个索引记录之后的间隙上的锁。也就是说,间隙锁,锁定的是一个空间,间隙锁是一个前开后开的区间。
Next-Key Lock
Next-Key Locks,它是由行锁+间隙锁组成的,是一个前开后闭的区间。
A next-key lock is a combination of a record lock on the index record and a gap lock on the gap before the index record.-- 《MySQL 5.7官方手册》
4.案例
范围查询
无索引情况
在这个例子中,事务B,无论是修改,或者是插入,即便不在 3 <= c <= 5这个范围内,都无法执行。
说明,在无索引的情况下,加的是全表间隙锁。这块在官方手册中也有印证:
If id is not indexed or has a nonunique index, the statement does lock the preceding gap. --《MySQL 5.7 官方手册》
这里需要提到的一点是,在RC隔离级别下,使用非索引字段进行加锁,如:
select * from test where b = 5 for update;
update test set b = 6 where b = 5;
复制代码
这种情况下,会全表扫描,并对每一行加锁,不符合条件的会及时释放掉锁。
但是在RR隔离级别下,为了保证binlog的记录顺序,这种情况会锁住全表,且事务结束前不会释放。
普通索引(非唯一)
我们修改下test表:
alter table `test` add index `c`(`c`);
复制代码
在这个例子中,事务B在T3,T4时刻,可以正常执行,在T5,T6时刻,被阻塞(测试时,这两处分开执行),我们画出来加锁的范围:
实际上这个锁定的范围就很大了,锁定范围是(1, 7](注意前开后闭的区间),说明是一个Next-Key Lock。
主键和唯一索引
我们接着来修改这张表:
alter table `test` drop index `c`, add unique index `c`(`c`) using btree;
复制代码
继续执行普通索引中的测试内容,可以发现结论是相同的,锁定范围是(1,7],同样的前开后闭区间。
等值查询
上面的测试都是基于范围查询的,那么等值查询是什么情况呢?我们接着来做测试,因为无索引的情况下是全表扫描加锁,所以在等值查询的过程中,我们跳过无索引的情况。
普通索引(非唯一)
先将数据恢复到初始的情况,并且给c加上普通索引。
执行下来的情况是:
T3时刻可以正常执行,说明锁定范围在(3, 5);
T4时刻被阻塞,说明存在间隙锁(3,5]和[5,7);
普通索引下,加锁的情况是两个间隙锁(3,5]和[5,7),另外还有这条a = 5的数据行的行锁。
主键和唯一索引
接着我们将c升级成唯一索引。继续执行上面的例子,发现T4时刻可以正常执行了,那么间隙锁(3,5]和[5,7)消失了,只剩下c = 5的数据行的行锁了。这也是InnoDB的优化的结果,将Next-Key Lock从(3,7]退化成c = 5的行锁。
未命中数据
上面的例子都是命中了数据的情况,如果是未命中数据呢?如果我们锁住c = 4的数据呢?
根据前面的例子,很容易想到,在普通索引的情况下,加的是一把间隙锁(3,5)。
那么在唯一索引的情况下呢?
根据之前的例子,如果命中,则会退化成一把行锁,那么此处没有命中数据,这里怎么加锁呢?
实际上,这里也是退化成了间隙锁隙锁(3,5)。你可以把c的类型改成double来试一试。
总结下来:
- 唯一索引,命中数据,Next-Key Lock退化成行锁;
- 唯一索引,未命中数据,Next-Key Lock退化成间隙锁。
间隙锁的冲突
林晓斌老师在《MySQL实战45讲中》提到过一句:
跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。 -- 林晓斌,《MySQL实战45讲》
这个结论在《MySQL 5.7官方手册》中也提到过:
Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function. -- 《MySQL 5.7官方手册》
简单来说,间隙锁是可以共存的,间隙锁之间并不会产生冲突,会和间隙锁产生冲突的是,往这个间隙中插入数据。
5.一点建议
之前我已经提到过两条关于死锁问题的优化建议:
- 逻辑上的优化,将可能产生锁的语句尽可能的置于事务的后半部分,减少锁的持有时间;
- 数据设计上的优化,针对部分数据,可以拆散数据,每次使用一条最近最少的一条数据(可以看下04.MySQL的表锁和行锁)。
下面我说下关于隔离级别的建议和where的条件语句。
选择什么隔离级别?
如果你们有DBA的话,这个就听DBA的就好了,如果没有的话,那么就视公司的业务情况就定。
如果是阿里腾讯这种拥有高并发的大厂,通常会选择RC,同时设置binlog_format=row,前提是业务代码保证避免不可重复读的情况出现。
如果是中小厂,业务并发量并不大,那么RR并不会影响到太多的性能,但是有更高的可靠性。
之前所在的一家中小型企业,一到方案研讨环节,每个人必喊高并发,分布式的性能问题和数据安全问题,当时我们的架构说了一句,咱们现在每天就2W笔交易左右,即便是都集中到8点到9点这一个小时,每秒平均下来不到6笔,这点并发都承受不了吗?即便是2年后业务量翻番,十几笔交易也没什么问题吧?
怎么写where条件
这里只考虑到并发加锁的问题,关于查询速度的问题,会在索引的内容中提供建议。
答案其实很明显了,where后的条件一定要使用索引,最优的选择是主键索引和唯一索引,无论是RR还是RC的隔离级别,这点在业务代码上是可以完全避免的(目前我经手的项目上还没有遇到过不能使用索引的情况)。




近期评论