1、锁问题

1.1、脏读

事务T1修改了一行数据,事务T2在事务T1提交之前读到了该行数据。

1.2、不可重复读

事务T1读取了一行数据。 事务T2接着修改或者删除了改行数据,当T1再次读取同一行数据的时候,读到的数据是修改之后的或者发现已经被删除。

1.3、幻读

事务T1读取了满足某条件的一个数据集,事务T2插入了一行或者多行数据满足了T1的选择条件,导致事务T1再次使用同样的选择条件读取的时候,得到了比第一次读取更多的数据集。

1.4、脏读和不可重复读的区别

脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,其违反了数据库事务一致性的要求。

1.5、不可重复读和幻读的区别

(1)关注点不同

不可重复读关注点在于update和delete,而幻读的关注点在于insert。

(2)通过不同的锁机制来解决他们产生的问题

如果使用锁机制来实现这两种隔离级别,在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。

上面说的,是使用悲观锁机制来处理这两种问题,但是MySQL、ORACLE、PostgreSQL等成熟的数据库,出于性能考虑,都是使用了以乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题。

2、不同隔离级别对锁问题的处理

在数据库操作中,为了有效保证并发读取数据的正确性,提出的事务隔离级别。数据库锁,也是为了构建这些隔离级别存在的。

隔离级别

脏读(Dirty Read)

不可重复读(NonRepeatable Read)

幻读(Phantom Read)

未提交读(Read uncommitted)

可能

可能

可能

已提交读(Read committed)

不可能

可能

可能

可重复读(Repeatable read)

不可能

不可能

可能

可串行化(Serializable )

不可能

不可能

不可能

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

3、两阶段锁协议

数据库遵循的是两段锁协议,将事务分成两个阶段,加锁阶段和解锁阶段(所以叫两段锁)

  • 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
  • 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。

4、MySQL中锁的种类

MySQL中锁的种类很多,有常见的表锁和行锁,也有新加入的Metadata Lock等等。

表锁是对一整张表加锁,虽然可分为读锁和写锁,但毕竟是锁住整张表,会导致并发能力下降,一般是做ddl处理时使用。

行锁则是锁住数据行,这种加锁方法比较复杂,但是由于只锁住有限的数据,对于其它数据不加限制,所以并发能力强,MySQL一般都是用行锁来处理并发事务。

5、详解临键锁( next-key lock)

Next-Key锁是行锁和GAP(间隙锁)的合并。

行锁防止别的事务修改或删除,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在写数据时的幻读问题。

加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。

原则 2:查找过程中访问到的对象才会加锁。

优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。

优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

Repeatable-Read隔离级别加锁分析

本文把间隙锁记为开区间,把 next-key lock 记为前开后闭区间。

以下实例数据库版本为MySQL5.7。

分析使用的表Schema和数据如下:


create table m(c1 int primary key, c2 int, c3 int, c4 int, unique index i_c2(c2), index i_c3(c3)); insert into m values (10, 11, 12, 13), (20, 21, 22, 23), (30, 31, 32, 33), (40, 41, 42, 43);


案例一:等值查询间隙锁

mysql 页码最后一页 mysql的页锁_MySQL

SessionA

SessionB

SessionC

begin;

select * from m where c1 = 15 for update;

begin;

insert into m values(16,16,16,16);

(blocked)


begin;

update m set c4=15 where c1=20

(ok)

判断过程:

根据加锁规则判断如下:

根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (10,20];

同时根据优化 2,这是一个等值查询 (c1=15),而 c1=20 不满足查询条件,next-key lock 退化成间隙锁,最终加锁的范围是 (10,20)。

所以,session B 要往这个间隙里面插入 c1=16 的记录会被锁住,但是 session C 修改 c1=20 这行是可以的。

案例二:非唯一索引等值锁

mysql 页码最后一页 mysql的页锁_MySQL

SessionA

SessionB

SessionC

begin;

select c1 from m where c3=32 lock in share mode;


begin;

update m set c4=15 where c1=30

(ok)

begin;

insert into m values(23,19,40,10);

(blocked)

判断过程:

这里 session A 要给索引 c3上 c3=32 的这一行加上读锁。

根据原则 1,加锁单位是 next-key lock,因此会给 (12,32]加上 next-key lock。要注意 c3 是普通索引,因此仅访问 c3=32 这一条记录是不能马上停下来的,需要向右遍历,查到 c3=42 才放弃。

根据原则 2,访问到的都要加锁,因此要给 (12,42]加 next-key lock。但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c3=42 这个等值条件,因此退化成间隙锁 (12,42)。

根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。

但 session C 要插入一个 (23,19,30,10) 的记录,就会被 session A 的间隙锁 (12,42) 锁住。需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。

SessionA

SessionB

SessionC

begin;

select c4 from m where c3=32 lock for update;


begin;

update m set c4=15 where c1=30

(blocked)

begin;

insert into m values(23,19,40,10);

(blocked)

判断过程:

执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select c4 from t where c3=32 lock in share mode。

案例三:主键索引范围锁

mysql 页码最后一页 mysql的页锁_MySQL

SessionA

SessionB

SessionC

begin;

select * from m where c1=20 for update;


begin;

insert into m values(16,16,16,16);

(ok)

insert into m values(25,25,25,25);

(ok)

begin;

update m set c4=15 where c1=20

(blocked)

判断过程:

开始执行的时候,要找到第一个 c1=20 的行,因此本该是 next-key lock(10,20]。

根据优化 1, 主键 c1 上的等值条件,退化成行锁,只加了 c1=20 这一行的行锁。

SessionA

SessionB

SessionC

begin;

select * from m where c1>=20 and c1<30 for update;


begin;

insert into m values(16,16,16,16);

(ok)

insert into m values(25,25,25,25);

(blocked)

begin;

update m set c4=15 where c1=30

(blocked)

判断过程:

根据加锁规则,开始执行的时候,要找到第一个 c1=20 的行,因此本该是 next-key lock(10,20]。

根据优化 1, 主键 c1 上的等值条件,退化成行锁,只加了 c1=20 这一行的行锁。

范围查找就往后继续找,找到 c1=20 这一行停下来,因此需要加 next-key lock(20,30]。所以,session A 这时候锁的范围就是主键索引上,行锁 c1=20 和 next-key lock(20,30]。这样,session B 和 session C 的结果你就能理解了。这里你需要注意一点,首次 session A 定位查找 c1=20 的行的时候,是当做等值查询来判断的,而向右扫描到 c1=30 的时候,用的是范围查询判断。

案例四:非唯一索引范围锁

mysql 页码最后一页 mysql的页锁_MySQL

SessionA

SessionB

SessionC

begin;

select * from m where c3>=22 and c3<31 for update;


begin;

insert into m values(16,16,16,16);

(blocked)

insert into m values(25,25,25,25);

(blocked)

begin;

update m set c4=15 where c3=32

(blocked)

判断过程:

这次 session A 用字段 c3 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c3=22 定位记录的时候,索引 c3 上加了 (12,22]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c3 上的 (12,22] 和 (22,32] 这两个 next-key lock。所以从结果上来看,sesson B 要插入(16,16,16,16) 的这个 insert 语句时就被堵住了。这里需要扫描到 c3=32 才停止扫描,是合理的,因为 InnoDB 要扫到 c3=32,才知道不需要继续往后找了。

案例五:唯一索引范围锁 bug

mysql 页码最后一页 mysql的页锁_MySQL

SessionA

SessionB

SessionC

begin;

select * from m where c1>20 and c1<=30 for update;


begin;

update m set c4=31 where c1=30

(blocked)

begin;

insert into m values(36,36,36,36);

(blocked)

update m set c4=31 where c1=40

(blocked)

判断过程:

session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (20,30]这个 next-key lock,并且因为 c1 是唯一键,所以循环判断到 c1=30 这一行就应该停止了。但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 c1=40。而且由于这是个范围扫描,因此索引 c1 上的 (30,40]这个 next-key lock 也会被锁上。

所以你看到了,session B 要更新 c1=20 这一行,是会被锁住的。同样地,session C 要插入 c1=36 的一行,也会被锁住。照理说,这里锁住 c1=40 这一行的行为,其实是没有必要的。因为扫描到 c1=30,就可以确定不用往后再找了。但实现上还是这么做了,因此我认为这是个 bug。

目前该问题已在高版本修复。

案例六:非唯一索引上存在"等值"的例子

给表 m插入一条新记录。


insert into m values(33,22,22,22);


mysql 页码最后一页 mysql的页锁_MySQL_06

判断过程:

新插入的这一行 c3=22,也就是说现在表里有两个 c3=22 的行。那么,这时候索引 c3 上的间隙是什么状态了呢?你要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。

这次我们用 delete 语句来验证。注意,delete 语句加锁的逻辑,其实跟 select ... for update 是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

SessionA

SessionB

SessionC

begin;

delete from m where c3=22


begin;

insert into m values(16,16,16,16);

(blocked)

insert into m values(29,29,29,29);

(blocked)

begin;

update m set c4=31 where c3=32

(ok)

判断过程:

这时,session A 在遍历的时候,先访问第一个 c3=22 的记录。同样地,根据原则 1,这里加的是 (c3=12,c1=10) 到 (c3=22,c1=20) 这个 next-key lock。然后,session A 向右查找,直到碰到 (c3=32,c1=30) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c3=22,c1=33) 到 (c3=32,1=30) 的间隙锁。即 (c3=12,c1=10) 和 (c3=32,c1=30) 这两行上都没有锁。

案例七:limit 语句加锁

mysql 页码最后一页 mysql的页锁_MySQL_06

SessionA

SessionB

begin;

delete from m where c3=22 limit 2


begin;

insert into m values(16,16,16,16);

(blocked)

insert into m values(29,29,29,29);

(ok)

判断过程:

这个例子里,session A 的 delete 语句加了 limit 2。你知道表 m里 c3=22 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c3=22, c1=33) 这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引 c 上的加锁范围就变成了从(c3=12,c1=10) 到(c3=22,c1=33) 这个前开后闭区间.

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。