mysql锁机制

前言

在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+索引来表示,则叶子节点应该是:

1572449289961

图1 next-key lock

如图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官方文档

美团技术团队MySQl与锁

《高性能mysql》第三版