前言
在mysql数据库InnoDB的使用过程中,事务、事务隔离级别、读锁、写锁等都是绕不开的话题。本文针对事务的特性、隔离级别以及事务隔离级别与锁相关的问题做一些探讨。
事务隔离级别
事务ACID特性
首先事务拥有ACID四大特征,即:
- 原子性Atomic:事务不可被分割,同一个事务内的多个数据库操作要么都成功,要么都失败
- 一致性Consisency:事务操作成功后,数据库所处的状态和它的业务业务规则是一致的,即数据不会被破坏(Spring4.x,p356)
- 隔离性Isolation:不同事务并发访问数据库时,都有自己的数据空间,互不干扰。具体干扰程度由事务隔离级别决定
- 持久性Durability:事务操作成功之后,所有数据操作都必须被持久到数据库中
事务隔离级别
在对数据库进行并发操作时会发生各种各样的问题。常见的有三类:脏读、不可重复度、幻读。其中脏读比较容易理解,当前事务读取到其他事务操作的中间数据,但是其他事务最终撤回。那么当前事务读到的是一条脏数据,即发生了脏读。
不可重复度与幻读比较容易混淆,搞明白一点就可以区分不可重复度与幻读,不可重复读指读同一条数据而言,幻读指的是读新插入的数据。在当前事务读取一条数据时,由于其他事务的修改,导致当前事务读取这条数据时与数据修改前读取的结果不一致。而幻读是指当前事务读取数据时,读取到其他已提交事务插入的数据。
针对以上三种情况有四种隔离级别。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(READ UNCOMMITTED) | 允许 | 允许 | 允许 |
读已提交(READ COMMITTED) | 不允许 | 允许 | 允许 |
可重复读(REPEATABLE READ) | 不允许 | 不允许 | 允许 |
序列化(SERIALIZABLE) | 不允许 | 不允许 | 不允许 |
从上至下,隔离级别越来越高,并发性也越来越差。RN(读未提交)会造成脏读问题,所以实际开发中基本不会有人使用RN隔离级别的事务。RC(读已提交)、RR(可重复读)则比较常用。刚开始接触事务隔离级别时,很不容易区分这几种的区别与界限。后来发现根据隔离级别的名称以及加锁粒度上非常好区分,RN读其他事务未提交的数据,RC读其他事务已提交的数据,RR相当于对读取数据加行锁(InnoDB不是这样实现),序列化读相当于加表锁。以如下表为例(后面很多论述都是以此表为例),分析四种事务隔离级别。
id | gmt_create | create_by | teacher_id | teacher_name | class_name | student_num | del_flag |
---|---|---|---|---|---|---|---|
1 | 2019/10/27 10:36:12 | TruXu | 3 | 张三 | 初三二班 | 50 | 0 |
2 | 2019/10/27 10:38:26 | TruXu | 5 | 王二 | 初三四班 | 50 | 0 |
3 | 2019/10/27 10:46:44 | TruXu | 10 | 李四 | 高一二班 | 50 | 0 |
4 | 2019/10/27 10:46:54 | TruXu | 10 | 李四 | 高一三班 | 50 | 0 |
- RN读未提交示例:
事务A | 事务B |
---|---|
start transaction | start transaction |
select student_num from teacher_info where id = 1; | |
结果为:50 | |
UPDATE teacher_info set student_num = 49 where id = 1; | |
update teacher_info set student_num = student_num + 1 where id = 1; | |
rollback; | |
select student_num from teacher_info where id = 1; | |
结果为:50(正确应为51) |
RN,读未提交。即A、B两个事务,B事务中对某一行记录做了修改但未提交事务,此时A事务可以读到B事务修改之后但未提交的内容。那么如果此时B事务回滚,刚才事务A读到的肯定是垃圾数据,也就发生了脏读。所以RN就是本事务读到其他事务修改但未提交的内容,那么极容易可能发生脏读。
- RC读已提交示例
事务A | 事务B |
---|---|
start transaction | start transaction |
select student_num from teacher_info where id = 1; | |
结果为:50 | |
UPDATE teacher_info set student_num = 49 where id = 1; | |
commit; | |
select student_num from teacher_info where id = 1; | |
结果为:49(同一事务内前后结果不一致) |
RC,读已提交。即A、B两个事务,B事务中对某一行记录做了修改但未提交事务,此时A事务读不到B事务修改但未提交的内容。只有在B事务提交之后,A事务才能读到B已经提交的数据,所以RC隔离级别不可能出现脏读。但是可能出现不可重复读,在同一个事务中读同一条数据,前后结果不一致。
- RR 可重复读示例
事务A | 事务B |
---|---|
start transaction | start transaction |
select student_num from teacher_info where id = 1; | |
结果为:50 | |
UPDATE teacher_info set student_num = 49 where id = 1; | |
commit; | |
select student_num from teacher_info where id = 1; | |
结果为:50(InnoDB是读快照) |
RR 可重复读;读已提交可以保证没有脏读,那么如何保证可重复读呢?在当前事务读某行数据时,对数据加上排它锁,这样其他事务就无法修改这些数据,那么这样就可以实现可重复读。(InnoDB引擎为保证高并发并没有加锁,而是通过快照读来实现假的可重复读,并通过MVCC来保证数据的一致性)。
- 序列化, 串行读。可重复读无法解决幻读问题,即读数据时,其他事务新增数据,那么当前事务就可能会读到之前不存在的数据。那么如何解决幻读问题呢?最直接的想法就是锁表,当前事务操作时进行锁表,那么其他事务就无法在表中插入新数据,也就不会出现幻读的问题。序列化隔离级别就是锁表读,可避免幻读。
以上四种隔离级别,从上到下隔离级别越来越高,同时并发性也越来越差。MySQL数据库InnoDB的默认隔离级别是RR。
锁粒度
mysql数据库InnoDB引擎的锁粒度支持到行锁,常用的锁有行锁、间隙锁、next-key锁、表锁等。按照读写锁可分为共享锁(读锁)、排它锁(insert、update、delete)、意向共享锁(select …lock in share mode)、意向排他锁(select …for update)。(IX锁与IX锁为何是兼容的?我在可重复读隔离级别下没有发现两个锁可以共享,当一个事务获取意向排它锁后,其他事务将不能再获取这个意向锁。)
X | IX | S | IS | |
---|---|---|---|---|
X | conflict | conflict | conflict | conflict |
IX | conflict | compatible(?) | conflict | compatible |
S | conflict | conflict | compatible | compatible |
IS | conflict | compatible | compatible | compatible |
行锁(Record Locks)
行锁,即record locks,锁的粒度到行级别。例如:
1 | select * from teacher_info where id = 1 for update; |
上面是一个意向排他锁,由于Id是主键,所以只对id = 1这条行记录加上意向排他锁,此时不允许其他事务对id=1这条数据加排他锁(插入、更修、删除)、共享锁。但允许增加意向共享锁。
并且行锁只对where查询条件中是索引的字段有效。如果where条件没有索引,例如
1 | select * from teacher_info where teacher_name = '张三' for update; |
teacher_name字段不是索引,那么此时当前事务拥有的是锁整张表的next-key锁(间隙的大小是整张表,故是表锁)。
间隙锁(Gap Locks)
间隙锁,即当前事务锁的数据库记录是一个范围的。例如:
1 | select * from teacher_info where id between 1 and 3 for update; |
此时是一个间隙锁,间隙锁只对索引字段有效。上面语句会将id = 1,2,3的行都锁住,其他事务无法对这三行数据进行修改。
在myql中间隙锁是不建议使用的。它的作用仅仅是组织其他事务在间隙行中进行插入操作。有两种方式可以关闭间隙锁,一种是将隔离级别设置成RC(读已提交)级别,或者enable 系统变量innodb_locks_unsafe_for_binlog
,这两种方法都可以关闭间隙锁。
next-key锁
next-key锁是间隙锁和行锁的集合。主要用于非主键的索引字段。例如上表中,我们对teacher_id加上索引,表中有id=3、5、10几种值。主键用B+索引来表示,则叶子节点应该是:
如图1所示,蓝色为字段teacher_id B+索引树叶子节点,以链表形式顺序存储。橙色的就是由索引teacher_id划分的区间,如下所示。对橙色区间加的锁就是gap锁,对蓝色数据行加的锁就是行锁,行锁与间隙锁一起组成了next-key锁。
- (-∞,3]
- (3,5]
- (5,10]
- (10,∞)
RR级别时,开启一个事务并执行如下语句:
1 | update teacher_info set del_flag = 1 where teacher_id = 3; |
上述语句会对数据行teacher_id=3上排他锁时,(-∞,3]、(3,5]区间内不允许其他事务加insert、delete排他锁(但除teacher_id = 3外,可以对其他行加update排它锁,很奇怪),那么其他事物就无法在区间内插入、删除数据。RR也是利用next-key锁这个特点来避免幻读。但是若teacher_id上面不是索引字段只是普通字段,那么上面语句就会锁住整张表,个人理解因为没有索引,数据只有一个区间(-∞,∞ ),故相当于对整张表加上排他锁。
MVCC
MVCC(mutiple version concurrent control)多版本并发控制。上面提到过,为了保证并发性能,MySQL的InnoDB的RR隔离级别下读取数据时并没有对数据行加排他锁,那么InnoDB是如何保证可重复读的呢?
查阅官方文档可以发现,InnoDB的可重复读是“假的”可重复读。因为RR级别下的读属于快照读,采用的是乐观锁的思想。乐观锁,CAS的底层就是基于版本来实现数据的正确性的。MVCC也一样通过版本控制来保证读取数据的正确性、有效性。在InnoDB中,读数据的冲突主要体现在不同的事务上,所以利用事务的版本号来表示数据的版本号。主要遵循下面几条规则:
- SELECT时, 当前事务的版本号 <= 读取数据的创建版本号 &&( 删除版本号为空||当前事务的版本号 <= 数据的删除版本号)
- INSERT时,将当前的事务版本号保存为数据行的创建版本号
- DELETE时,将当前的事务版本号保存为数据行的删除版本号
- UPDATE时,插入一条新的数据行记录,将当前的事务版本号保存为数据行的创建版本号。并将将当前的事务版本号保存为删除的数据行的删除版本号
MVCC操作需要为数据行维护一个版本信息,并且在增删改查时需要加上额外的逻辑判断,但是当冲突较少时,由于MVCC不需要加锁,故可以大大提高引擎的效率。
参考链接
《高性能mysql》第三版