浅谈MySQL的隔离级别

前言

说道数据库就避免不了两个内容,索引与隔离级别。以下内容为两篇介绍这个内容比较好的文章:
美团点评团队:Innodb中的事务隔离级别和锁的关系
张洋先生的:MySQL索引背后的数据结构及算法原理

关于MySQL的隔离级别,介绍的文章有许多,但是多数都是止于介绍,所以每次看完都只是有个概念上的认识,但是具体的理解还需要个人去体会。所以写下这篇博文来记录下点滴与理解。

说明:为了能够更加形象的去理解以下的内容,特别推荐去亲自进行尝试。比如开两个MySQL的客户端应该是最简单的方式,然后利用BEGIN,COMMINT等方式进行模拟

四种隔离级别

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read) 说明
未提交读(Read uncommitted/RU) 可能 可能 可能 允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
已提交读(Read committed/RC) 不可能 可能 可能 只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
可重复读(Repeatable read/RR) 不可能 不可能 可能 可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
可串行化(Serializable) 不可能 不可能 不可能 完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻

为了理解上面的内容,我们首先需要理解下脏读不可重复读幻读的概念。

  • 脏读
    脏读概念的关键为,脏我们可以理解为假的,脏数据。也就是两个事务A和事务B。事务B在事务中修改了数据,此时事务A是能够读到事务B中修改的数据,而此时如果事务B中发生了异常进行了回滚,则事务A中读到的数据实际是脏数据,这就是所谓的脏读。根据以上的描述我们也不难理解为什么RU(未提交读)无法避免脏读的问题。
  • 不可重复读:
    不可重读读指的是事务A和事务B,比如在事务A中进行id = 1的数据的读取,而事务B之后对id = 2的数据进行了update并提交。此时在事务A中如果此时在进行id = 1的读取的话,会发现读取的内容与上次的内容不同。这就是所谓的不可重复读,对应的是隔离级别的RC。
  • 幻读
    幻读的概念需要好好理解下。实际想要理解幻读首先需要理解MySQL中的两种读的区别:
    快照读(snapshot read)当前读(current read)。产生这两种读的区别主要是由于MySQL所采用的MVCC的版本控制来进行乐观锁机制。
    我们下面摘取Innodb中的事务隔离级别和锁的关系来进行下解释:

可能有读者会疑惑,事务的隔离级别其实都是对于读数据的定义,但到了这里,就被拆成了读和写两个模块来讲解。这主要是因为MySQL中的读,和事务隔离级别中的读,是不一样的。
我们且看,在RR级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。
对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:
快照读:就是select

select * from table ....;

当前读:特殊的读操作,插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。

select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;
事务A 事务B
BEGIN BEGIN
SELECT * FROM tb_task;
INSERT INTO tb_task(id, name) VALUES(100, '111');
COMMIT;
UPDATE tb_task SET name = '123';
COMMIT

而理解以上的概念对于理解幻读较为重要。实际幻读产生的原因就是由于当前读造成的,所以往往产生幻读发生在Insert, Update, Delete等操作。
比如有两个事务A和B:

事务A 事务B
BEGIN BEGIN
SELECT * FROM tb_task;
INSERT INTO tb_task(id, name) VALUES(100, '111');
COMMIT;
UPDATE tb_task SET name = '123';
COMMIT

也就是在事务A中我们来SELECT时发现只有一条数据,但是我们在进行更新的时候却会更新两条数据(由于事务B插入了一条新的数据并进行了提交)。
在许多时候我们可能会遇到幻读的问题,比如我们在事务A中对于id=1的数据进行更新操作,但是在之前事务B对于id=1的数据进行了删除。则此时事务A将会更新0条数据而不是我们预期的1条数据。

Innodb的默认隔离级别是RR,是通过MVCC和行锁来进行锁定的。
MVCC主要是针对的读,而锁主要是为了写产生冲突。

一段锁或两段锁

事务A 事务B
begin; begin;
update class_teacher set class_name='初三二班' where teacher_id=1; update class_teacher set class_name='初三三班' where teacher_id=1;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
commit;

因为有大量的并发访问,为了预防死锁,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用到哪些数据,然后全部锁住,在方法运行之后,再全部解锁。这种方式可以有效的避免循环死锁,但在数据库中却不适用,因为在事务开始阶段,数据库并不知道会用到哪些数据。
数据库遵循的是两段锁协议,将事务分成两个阶段,加锁阶段和解锁阶段(所以叫两段锁)。
我们拿以下的例子进行介绍(出自博客Innodb中的事务隔离级别和锁的关系):

事务A 事务B
begin; begin;
update class_teacher set class_name='初三二班' where teacher_id=1; update class_teacher set class_name='初三三班' where teacher_id=1;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
commit;

为了防止并发过程中的修改冲突,事务A中MySQL给teacher_id=1的数据行加锁,并一直不commit(释放锁),那么事务B也就一直拿不到该行锁,wait直到超时。

这时我们要注意到,teacher_id是有索引的,如果是没有索引的class_name呢?update class_teacher set teacher_id=3 where class_name = '初三一班';
那么MySQL会给整张表的所有数据行的加行锁。这里听起来有点不可思议,但是当sql运行的过程中,MySQL并不知道哪些数据行是 class_name = '初三一班'的(没有索引嘛),如果一个条件无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由MySQL Server层进行过滤。

但在实际使用过程当中,MySQL做了一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录释放锁 (违背了二段锁协议的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。可见即使是MySQL,为了效率也是会违反规范的。(参见《高性能MySQL》中文第三版p181。

实际除了行锁外,为了防止这种事务之间写入造成的冲突产生,还引入了GAP间隙锁。更多的内容可以参见《高性能MySQL》。

你可能感兴趣的:(浅谈MySQL的隔离级别)