InnoDB中事务隔离级别和锁的关系

1.锁的分类(一次封锁或两段锁)

a.一次封锁

就是在方法的开始阶段,已经预先知道要用到那些数据,在方法开始之前就将这些数据用锁锁住,在方法执行完成之后,再全部解锁。这种方式可以有效的避免死锁,但是这种封锁方式在数据库中并不可用,因为在事务开始阶段,并不会提前预知要使用数据库中的哪些数据。因此数据库遵循两段锁协议。
b. 两段锁

就是将事务分为加锁阶段和解锁阶段两个阶段。

加锁阶断:在这个阶段,可以进行加锁操作。在对事务进行"读"的操作之前,需要申请S锁(共享锁),对事务进行"写"的操作之前,需要申请X锁(排它锁)。在这里要注意的是,如果事务加上S锁,其他事务可以继续加共享锁,但不能加排它锁;但是如果事务加上X锁,其他事务则不能加其他任何类型的锁。如果事务加锁不成功,则事务进入wait状态,直至枷锁成功为止。
   

解锁阶断:当事务提交之后,就会释放锁,此时事务进入解锁阶断,在这个阶段只能进行解锁操作,不能再进行枷锁操作。
    两段锁无法避免死锁,但是却可以保证事务的并发操作是串行化进行的。

2.Mysql中锁的种类

a.表锁:对整张表进行加锁
b.行锁:锁住数据行,对有限的数据行进行加锁,对其他数据不限制。
在这里要注意的是如果查询条件中存在没有建索引的字段,Mysql则会给数据库中整张表的所有数据行加行锁,这时存储引擎会将所有的数据记录加锁返回给MysqlServer层进行过滤,在MysqlServer层过滤的时候,如果发现有不满足过滤条件的记录,则会调用unlock_row方法,将不满足条件的记录所加的锁释放掉,当然这种做法是违背了"两段锁协议"的。

3.事务的隔离级别

隔离级别

脏读

不可重复读

幻读

读未提交(ReadUncommitted)

可能

可能

可能

读已提交(ReadCommitted)

不可能

可能

可能

可重复读( Repeateable Read)

不可能

不可能

可能

可串行化(Serializable)

不可能

不可能

不可能

a.读未提交(RU):就是可能会读取到其他会话中未提交事务的数据。
RU级别中,无论是读的操作还是写的操作,都不会进行加锁操作
b.读已提交(RC):只能读到已经提交事务的数据。Oracle默认的隔离级别为该级别。
RC级别中,数据的读取操作不会进行加锁,但是数据的写入、修改和删除操作是需要加锁的。
c.可重复读(RR):指同一个事务的多个实例在并发读取数据时看到的数据行是一致的。InnoDB引擎默认为该级别。
RR级别中, 数据的读取操作不会进行加锁,但是数据的写入、修改和删除操作是需要加锁的。
d.可串行化:每次读取的时候事务都要获取表级别的S锁,每次写入的时候都会加写锁(X锁),读写操作会相互阻塞。
下面我们通过事例来说明隔离级别和表锁的关系:
(1)创建数据库表branch,并插入三条测试数据,如下所示
CREATE TABLE `branch` (
  `id` tinyint(4) NOT NULL AUTO_INCREMENT,
  `branch_name` varchar(20) NOT NULL,
  `director_id` smallint(4) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
– ----------------------------
– Records of branch
– ----------------------------
INSERT INTO `branch` VALUES ('1', '软件一部', '7135');
INSERT INTO `branch` VALUES ('2', '软件二部', '7136');
INSERT INTO `branch` VALUES ('3', '软件三部', '7137');
(2)查询表中的数据,如下所示:

(3)RU级别
由于Mysql默认是提交事务的,在这里,我们需要将Mysql设置为不自动提交事务,如下图所示:

由于Mysql中InnoDB默认的级别是RR级别,我们需要将session开启成RU级别,如下图所示:

接着,我们开启两个事务,左边为事务A右边为事务B, 如下图所示:

由上图可以看出,由于RU级别中对读和写操作均不加锁,事务B可以查询出事务A未提交的数据,造成"脏读"现象。
(4)RC级别
接着我们将session开启成RC级别,如下图所示:

接着我们和(3)一样,开启两个事务, 左边为事务A右边为事务B ,再次执行(3)的操作,结果如下图所示:

     从上图中可以看出RC级别中,虽然读的操作不加锁,但是对数据的写入操作加行锁,因此在事务B执行insert操作提交事务之前,事务A是不会查询出事务B未提交的那一行数据的,不会产生RU级别的"脏读"现象。
     但事务B会出现不可重复读的现象,接着我们展现RC模式,如下图所示:

从上图中可以看出,事务A两次读取到的结果是不一样的,这时由于在RC级别中,读的操作不加锁,因此在事务A没有提交之前,事务B可以进行写的操作。在表中id是主键,事务B中会对id=5的记录加X锁,在事务B提交之后,事务A再次读取数据,就会和第一次查询的结果不同,造成了"不可重复读"的现象。
(5)RR级别
我们先将session开启成RR级别,如下图所示:

接着我们和(4)一样,开启两个事务,左边为事务A右边为事务B ,再次执行(4)的操作,结果如下图所示:

从上图中可以看出,事务A两次读取到的结果是相同的。
如果使用锁机制来解决幻读的话,就是由于在RR级别中,id为主键,读的操作也会加行锁,因此在事务A没有提交之前,其他事务没法修改这些数据直到事务A提交成功之后,其他事务才可以修改这些数据,因此RR级别可以避免"不可重复读"的现象。
但在Mysql的InnoDB中,是通过多版本并发控制(MVCC)机制来避免幻读,相当于我们读取到的数据是历史数据,我们将这种读取历史数据的方式称为"快照读"。
我们再展现 在RR级别"幻读"模式,如下图所示:

在这里,并没有出现预期的现象,事务A在提交之前并没有读取到事务B插入的数据,并没有出现"幻读"现象,可见在Mysql的RR级别中是解决了"幻读"这个问题的,至于怎么解决的我们后面再摸索。
下面我们先简单了解一下"不可重复读"和"幻读"的区别。

4.不可重复读和幻读的区别

不可重复读是指如果事务A读取了某一行数据,事务A未提交,如果事务B修改了这一行数据并且提交,事务A再去重复读取这条记录时,会发现两次读取的同一行数据记录发生了变化。
幻读是指事务A读取了某一个范围内的数据,事务A未提交,事务B在这个范围内插入一条记录,事务A再去重复读取这个范围内的数据时,发现再次读取到的数据记录数比之前多。
不可重复读的重点在于update和delete操作,而幻读的重点在于insert操作。就拿上面的例子来说,如果只是纯粹的用锁机制来实现这两种隔离级别的话,在sql执行事务A的select操作之后,就对这些数据进行加锁,在此期间,其他事务拿不到该行锁,因此对于事务B中的update操作,只能等待事务A将锁释放之后才能对相应的数据行加锁。这样就可以实现可重复读,但是这种方法却无法锁住即将要insert的数据,因此就会出现"幻读"的现象。
因此,"幻读"现象是不能通过行锁来避免的,必须通过Serializable隔离级别来避免,也就是读的操作用"读锁",写的操作用"写锁","读锁"和"写锁"互为互斥锁,这么做就可以有效地避免"脏读"、"不可重复读"、"幻读"等问题,但是会极大地降低数据库的并发能力。
前面所说的都是使用悲观锁来解决"不可重复读"和"幻读" 这两个问题的,但是Mysql、Oracle、Postgresql等一些成熟的关系型数据库为了性能上的考虑,都会采用以乐观锁为基础的多版本并发控制(MVCC)来避免这两个问题。
接下来,我们来简单的了解一下悲观锁和乐观锁。

5.悲观锁和乐观锁

a.悲观锁:在整个数据的处理过程中,将数据处于锁定的状态,悲观锁的实现,是依靠数据库提供的锁机制,以保证操作上最的程度的独占性。在悲观锁的情况下,为了保证事务的隔离性,就需要一致性锁定读,在读取数据时对数据加锁使得其他事务无法对其进行修改操作;在修改数据时,也要对其进行加锁操作,使得其他事务无法读取这些数据。这样随之而来的就是数据库性能上的大量开销,尤其是对于长事务而言,这种开销是无法承受的。
b.乐观锁:基于数据库版本记录机制来实现的,也就是说为数据增加一个版本控制的标识。在基于数据库表的版本控制实现机制中,一般是通过为数据库表增加一个"version"字段,在读取时,将此版本号一起读出,在对数据进行修改操作时,"version"字段的值加1。在提交数据时,将要提交数据的版本号和数据库表中对应数据的version字段的值进行比较,如果要提交的数据版本号大于当前数据库表中的当前版本号,则对数据予以更新;否则,就认为要提交的数据为过期数据。
对于乐观锁的实现,上述方法只是一个举例,每种关系型数据库都会有不同的实现方式,在这里我们主要看看InnoDB中乐观锁的实现。

6.InnoDB中乐观锁的实现

在InnoDB中,会在每行数据后面添加两个隐藏的属性来实现多版本并发控制。一个属性记录这行数据的创建时间,另外一个属性记录当前数据的过期时间(被删除时间)。但在实际的操作中,存储的值并非是时间,而是事务的版本号,每开启一个新的事务,事务的版本号就会递增。RR事务隔离级别下,
select:读取出来的创建的版本号<=当前事务的版本号,以确保事务读取的行要么是在事务开始之前已经存在的,要么是事务自身插入或者修改过的;而删除的版本号为空或>当前事务的版本号,以保证事务所读取到的行在事务开始之前未被删除。
insert:保存当前事务的版本号为行的创建版本号。
update:插入一条新纪录,保存当前事务的版本号为行的创建版本号,同时保存当前事务的版本号到原来删除的行。
delete:保存当前事务的版本号为行的删除版本号。
通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。在这里要注意的是,MVCC只在RC和RR两种隔离级别下工作。

7.快照读和当前读

a.快照读:读取的记录是可见版本(可能是历史版本),不用加锁。
  如:select * from table where ?
b.当前读:读取的记录是最新记录,并且会返回当前记录,会进行加锁操作,以确保其他并发事务不会再修改这些记录。如:
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (......);
update table set ? where ?;
delete from table where ?;
在上述语句中,除过第一条语句需要加S锁,其他语句都需要加X锁。

8.Mysql的加锁处理

  Mysql的加锁情况取决于以下前提条件:
a.当前系统会话的隔离级别;
b.id列是否为主键;
c.如果id列不为主键,id列上是否有索引;
d.id列上如果有二级索引,那么这个索引是否是唯一索引;
e.两个SQL的执行计划。
根据以上前提条件,可以将加锁的情况分为如下几种:
(1)RC隔离级别,id列为主键;
对于这种情况,id是主键,此时Mysql会为id列自动创建主键索引。因此,当执行"update branch set director_id=7137 where id=3"这条语句时,会为id=3的这条数据记录加上X锁。加锁情况如下图所示:
      
(2)RC隔离级别,id列上没有索引;
在这种情况下,id列上没有索引,id=3这个过滤条件无法通过索引去过滤,因此只能走聚簇索引,进行全表扫描,因此当执行"update branch set director_id=7137 where id=3 "这条语句时,表中的所有记录行都会被加X锁,在这里所然是对全表记录进行加锁,但是加的是"行锁"不是"表锁"。
加锁情况如下图所示:

(3)RC隔离级别,id列是二级唯一索引;
这种情况,id列上没然没有主键索引,但是却有二级唯一索引。因此,当执行"update branch set director_id=7137 where id=3 "这条语句时,由于id是唯一索引列,update语句选择走id列的索引进行条件过滤,在找到id=2的记录后,首先会将唯一索引上的id=2的索引记录加上X锁。同时会根据读取到的数据列,再对相应的主键索引项加上X锁。加锁情况如下图所示:

(4)RC隔离级别,id列是二级非唯一索引;
和第(3)种情况相比,加锁的策略是相同的。唯一不同的是(3)最多只有一个满足等值条件查询的记录,而这种情况将会为满足条件的所有记录加X锁。加锁情况如下图所示:
  
(5)RR隔离级别,id列为主键;
这种情况的加锁情况和(1)相同。
(6)RR隔离级别,id列上没有索引;
RR级别和RC级别最大的区别就是RR级别不会出现幻读。所谓不出现幻读就是连续两次当前读,这两次当前读所返回的数据量和数据记录是相同的。要做到这样,就必须保证在第一次当前读和第二次当前读执行的期间,其他的事务不会插入新的满足条件的记录。
为满足这些要求,在执行"select * from branch where id=3 for update;"语句时,在使用X锁的同时,还要使用GAP锁对数据记录区间进行加锁。
由于此时id列上没有索引,因此只能进行全表扫描,需要为表中每条数据记录加上X锁,并在每个GAP上加GAP锁。加锁情况如下图所示:

(7)RR隔离级别,id列是二级唯一索引;
加锁情况和(3)相同。
(8)RR隔离级别,id列是二级非唯一索引;
在这里,id列上有一个非唯一索引,数据库的索引是使用B+树存储的,B+树索引是有序的。在执行"select * from branch where id=3 for update;"这条语句时,首先需要通过id索引定位到第一条满足查询条件的记录,为这个记录加上记录上的X锁的同时还要加上GAP上的GAP锁,然后再在对应的主键索引上的记录加X锁,然后返回。接着读取下一条数据,重复上述的加锁操作,直到进行到不满足查询条件的第一条记录为止,此时不需要加上记录的X锁,但需要加上GAP锁。
根据查询条件可以看出来[2,软件二部,7136]和[3,软件三部,7138]之间可以插入数据[3,a,7100]等,[3,软件三部,7138 ]和[3,软件五部,7140]之间可以插入数据[3,软件t,7123]等数据,[3,软件五部,7140] 和[4,软件四部,7139] 之间可以插入数据[3,z,20]等数据,而[2,软件二部,7136 ]之前和[4,软件四部,7139 ]之后不会插入id为3的数据,因此需要对索引区间(2,3)、[3,3]和(3,4)加GAP锁。加锁情况如下图所示:
      
Serializable隔离级别下,不存在快照读,所有的操作都是当前读,读加读锁,写加写锁,读锁和写锁互斥。
注意:在RC级别和RR级别下,select操作均不会进行加锁操作。
在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件。Index Key确定的范围,需要加上GAP锁;Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记录,不加X锁,否则需要X锁;Table Filter过滤条件,无论是否满足,都需要加X锁。
通过加锁情况我们可以了解到,在RR级别下使用X锁防止其他并发事务对记录的更改/删除操作,使用GAP锁实现其他并发事务对区间段内记录的插入操作,因此Mysql的InnoDB在RR级别已经实现了幻读的避免。因此,在上文我们发现事务的隔离级别设置为RR时,并未出现幻读的现象。

 

猜你喜欢

转载自asialee.iteye.com/blog/2353254