一文带你理解脏读,幻读,不可重复读与mysql的锁,事务隔离机制

·  阅读 1487

2021.09.14 更新

0 前言

本篇文章写于2019年,时隔两年,在2021年写下了这篇前言,修复了文章中的许多错误,并添加了更详细的步骤以及测试过程。

本文主要讲述了事务的四大特性与脏读、幻读、不可重复读、丢失更新,还有四个隔离级别,并在四个隔离级别下分别对脏读、幻读、不可重复读进行了测试,另外还提及了MySQL中的一部分锁(X锁、S锁、乐观锁、悲观锁)。

测试环境:

  • 数据库:本地使用Docker创建的MySQL
  • 版本:8.0.25
  • 引擎:InnoDB

1 ACID

事务的四大特性是ACID。

1.1 A:原子性(Atomicity)

原子性指的是事务要么完全执行,要么完全不执行。

1.2 C:一致性(Consistency)

事务完成时,数据必须处于一致的状态。若事务执行途中出错,会回滚到之前的事务没有执行前的状态,这样数据就处于一致的状态。若事务出错后没有回滚,部分修改的内容写入到了数据库中,这时数据就是不一致的状态。

1.3 I:隔离性(Isolation)

同时处理多个事务时,一个事务的执行不能被另一个事务所干扰,事务的内部操作与其他并发事务隔离。

1.4 D:持久性(Durability)

事务提交后,对数据的修改是永久性的。

2 MySQL的锁

MySQL的锁其实可以按很多种形式分类:

  • 按加锁机制分:可分为乐观锁、悲观锁
  • 按兼容性来分:可分为X锁、S锁
  • 按锁粒度分:可分为表锁、行锁、页锁
  • 按锁模式分:可分为记录锁、gap锁、next-key锁、意向锁、插入意向锁

这里主要讨论S锁、X锁、乐观锁与悲观锁。

2.1 S锁与X锁

S锁与X锁是InnoDB引擎实现的两种标准行锁机制。查看默认引擎可使用

show variables like '%storage_engine%';
复制代码

笔者的MySQL版本为8.0.17,结果如下:

在这里插入图片描述

建立测试表:

create table a
(
id int primary key auto_increment,
money int
);
复制代码

2.1.1 S锁

S锁也叫共享锁,读锁,数据只能被读取不能被修改。

先把表上S锁:

lock table a read;
复制代码

然后可以看到修改的时候提示上了读锁:

在这里插入图片描述

只能读而不能写。

2.1.2 X锁

X锁也叫排他锁,写锁,一个事务对表加锁后,其他事务就不能对其进行加锁与增删查改操作。

设置手动提交,开启事务,上X锁:

set autocmmmit=0;
start transaction;
lock table a write;
复制代码

在开启另一个事务,使用select

set autocommit=0;
start transaction;
select * from a;
复制代码

在这里插入图片描述

可以看到上了X锁后,另一个事务连读取都会被阻塞。这是因为开启X锁的那个事务阻塞了另一个事务的select操作。

可以使用

unlock table;
复制代码

释放锁,这样在另一个事务可以看到被锁住的时间:

在这里插入图片描述

2.2 乐观锁与悲观锁

2.2.1 乐观锁

乐观锁就是总是假设是最好的情况,每次去操作的时候都不会上锁,但在更新时会判断有没有其他操作去更新这个数据,是一种宽松的加锁机制。

MySQL本身没有提供乐观锁的支持,需要自己来实现,常用的方法有版本控制和时间戳控制两种:

  • 版本控制:版本控制就是为表增加一个version字段,读取数据时连同这个version字段一起读出来,之后进行更新操作,版本号加1,再将提交的数据的版本号与数据库中的版本号进行比较,若提交的数据的版本号大于数据库中的版本号才会进行更新
  • 时间戳控制:时间戳控制与版本控制差不多,把version字段改为timestamp字段

举个例子,假设此时:

  • version=1,事务A进行操作
  • 事务A更新数据后version=2,与此同时事务B也进行操作,更新数据后version=2
  • 事务A先完成操作,率先将数据库中的version设置为2
  • 事务A完成后,事务B提交自己的version,发现与数据库中的version一样,这样就不接受事务B的提交

还有一种实现方法叫CAS算法,有兴趣可以自行搜索。

2.2.2 悲观锁

悲观锁就是总是假设最坏的情况,在整个数据处理状态中数据处于锁定状态。悲观锁的实现往往依靠数据库的锁机制,每次在拿到数据前都会上锁。

MySQL在调用一些语句时会上悲观锁,如:

select * from a where xxx for update;
复制代码

先把两个事务关闭自动提交:

set autocommit=0;
start transaction;
复制代码

测试如下:

在这里插入图片描述

select语句会被阻塞,直到上锁的那个事务提交(解开悲观锁),可以看到阻塞时间:

在这里插入图片描述

3 脏读,幻读,不可重复读与两类丢失更新

3.1 脏读

脏读是指一个事务读取到了另一事务未提交的数据,造成select前后数据不一致。

比如事务A修改了一些数据,但没有提交,此时事务B却读取了到了事务A修改但没有提交的数据,这时事务B就形成了脏读。一般事务A的后续操作是回滚,事务B读取到了临时数值。

示意图如下:

在这里插入图片描述

3.2 幻读

幻读着重点是数量不一样,比如事务A读取一个表共有10行,然后事务B插入几行并提交,然后事务A再次读取,发现增加了几行,select前后数据的行数改变,就像产生了幻觉一样。

示意图如下:

在这里插入图片描述

3.3 不可重复读

不可重复读指一个事务读取到了另一事务已提交的数据,造成select前后数据不一致。

比如事务A修改了一些数据并且提交了,此时事务B却读取了,这时事务B就形成了不可重复读。

示意图如下:

在这里插入图片描述

3.4 第一类丢失更新

第一类丢失更新就是两个事务同时更新一个数据,一个事务更新完毕并提交后,另一个事务回滚,造成提交的更新丢失。

示意图如下:

在这里插入图片描述

3.5 第二类丢失更新

第二类丢失更新就是两个事务同时更新一个数据,先更新的事务提交的数据会被后更新的事务提交的数据覆盖,即先更新的事务提交的数据丢失。

示意图如下:

在这里插入图片描述

4 封锁协议与隔离级别

封锁协议就是在用X锁或S锁时制定的一些规则,比如锁的持续时间,锁的加锁时间等。不同的封锁协议对应不同的隔离级别。事务的隔离级别一共有4种,由低到高分别是Read uncommittedRead committedRepeatable readSerializable,分别对应的相应的封锁协议等级。

4.1 一级封锁协议

一级封锁协议对应的是Read uncommitted隔离级别,Read uncommitted,读未提交,一个事务可以读取另一个事务未提交的数据,这是最低的级别。一级封锁协议本质上是在事务修改数据之前加上X锁,直到事务结束后才释放,事务结束包括正常结束(事务正常提交)与非正常结束(事务回滚)。

一级封锁协议不会造成更新丢失,但可能引发脏读,幻读和不可重复读。

下面进行测试,关闭自动提交,并设置隔离级别后开启事务:

set autocommit=0;
set session transaction isolation level read uncommitted;
start transaction;
复制代码

4.1.1 引发脏读

一个事务可以读取到另外一个事务没有提交的数据。尽管Read uncommited加上了X锁,但是其他事务的select并不需要获取锁,因此可以直接读取到未提交的数据,也就是造成了脏读。

测试步骤:

  • 事务A、B分别设置read uncommited隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=100
  • 事务A修改money=300
  • 事务B再次selectmoney=300

测试截图:

在这里插入图片描述

4.1.2 引发幻读

测试步骤:

  • 事务A、B分别设置read uncommited隔离级别,关闭自动提交并开启事务
  • 事务Bselect count(*)统计行数,只有一条记录
  • 事务A插入一条记录并提交
  • 事务B再次select count(*)统计行数,变成了两条记录

测试截图:

在这里插入图片描述

4.1.3 引发不可重复读

测试步骤:

  • 事务A、B分别设置read uncommited隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=300
  • 事务A修改money=100并提交
  • 事务B再次select数据,money=100

测试截图:

在这里插入图片描述

4.2 二级封锁协议

二级封锁协议本质上在一级协议的基础(在修改数据时加X锁),在读数据时加上S锁,读完后立即释放S锁,可以避免脏读,但有可能出现不可重复读与幻读。二级封锁协议对应的是Read committedRepeatable Read隔离级别。

下面进行测试,同样关闭自动提交,并设置隔离级别后开启事务:

set autocommit=0;
set session transaction isolation level read committed;
#set session transaction isolation level repeatable read;
start transaction;
复制代码

4.2.1 Read committed

也就是读提交,读提交可以避免脏读,但可能出现幻读与不可重复读。

4.2.1.1 避免脏读

测试步骤:

  • 事务A、B分别设置read commited隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=100
  • 事务A修改money=300
  • 事务B再次select,数据不变,money=100

测试截图:

在这里插入图片描述

注意,事实上脏读在read committed隔离级别下是不被允许的,但是MySQL不会阻塞查询,而是返回未修改之前数据的备份,这种机制叫MVCC(多版本并发控制)机制。

4.2.1.2 引发幻读

测试步骤:

  • 事务A、B分别设置read commited隔离级别,关闭自动提交并开启事务
  • 事务Bselect count(*)统计行数,只有一条记录
  • 事务A插入一条记录并提交
  • 事务B再次select count(*)统计行数,变成了两条记录

测试截图:

在这里插入图片描述

4.2.2.3 引发不可重复读

测试步骤:

  • 事务A、B分别设置read commited隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=100
  • 事务A修改money=100并提交
  • 事务B再次select数据,money=300

测试截图:

在这里插入图片描述

4.2.3 Repeatable read

可重复读比读提交严格一点,是MySQL的默认级别,读取过程更多地受到MVCC影响,可防止不可重复读与脏读,但仍有可能出现幻读。

4.2.3.1 避免脏读

测试步骤:

  • 事务A、B分别设置repeatable read隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=300
  • 事务A修改money=100
  • 事务B再次select,数据不变,money=300

测试截图:

在这里插入图片描述

4.2.3.2 避免不可重复读

测试步骤:

  • 事务A、B分别设置repeatable read隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=300
  • 事务A修改money=100并提交
  • 事务B再次select,数据不变,money=300

测试截图:

在这里插入图片描述

4.2.3.3 引发幻读

测试步骤:

  • 事务A、B分别设置repeatable read隔离级别,关闭自动提交并开启事务
  • 事务Bselect count(*)统计行数,只有一条记录
  • 事务A插入一条记录并提交
  • 事务B再次select count(*)统计行数,还是只有一条记录
  • 事务B对表进行更新,update set money=money+20
  • 事务B在更新之前,select了只有一行,所以update的时候理应是更新一行,也就是应该提示1 rows affected
  • 事务B更新之后,提示的是2 rows affected
  • 事务B再次select,发现两条记录

测试截图:

在这里插入图片描述

那么为什么事务A提交之后事务B并不能马上看到有两行呢?这不就是避免了幻读了?

别急,看下面的一个小节。

4.2.3.4 MySQL在可重复读级别下的幻读问题

可重复读是MySQL的默认级别,不同数据库的实现repeatable read有不同的方法,无论什么实现方法,都能避免不可重复读的问题,但是有一些实现方法还能避免幻读和丢失更新问题。

从上面的结果看到,可重复读级别下select并不会出现幻读,那么MySQL实现的可重复读级别到底能不能避免幻读?

答案就是:避免了,但没有完全避免。

MySQL实现可重复读级别的方式是采用快照读的方式实现,每个事务开始之前先把之前的数据做一次快照,每个事务读到的数据:

  • 要么就是快照的内容,也就是事务开始之前的内容
  • 要么就是事务自己更新之后的内容

换句话说,如果事务没有更新,那么读取到的就是快照的内容,因此上面的测试结果中,事务A插入之后,在事务B中还是显示一条数据。

但是为什么事务Bupdate之后,“隐藏”的那一行就出来了呢?

这是因为MySQL在可重复读级别下,只有只读事务才能避免幻读,也就是只有select才能读取快照,而其他指令,看到的就不是快照,也是已经被提交的数据,update的时候已经知道表有两条数据而不是一条,因此重新select的时候,就出现两条数据了。

而在可重复读级别下同样采用快照读的PostgreSQL,上面的情况就不会发生,因为在PostgreSQL中所有的语句都是读取快照,就不会出现在MySQL中的幻读情况。

4.3 三级封锁协议

三级封锁协议,在一级封锁协议的基础上(修改时加X锁),读数据时加上S锁(与二级类似),但是直到事务结束后才释放S锁,可以避免幻读,脏读与不可重复读。三级封锁协议对应的隔离级别是Serializable

4.3.1 避免脏读

测试步骤:

  • 事务A、B分别设置``隔离级别,关闭自动提交并开启事务
  • 事务Bselect数据,money=100
  • 事务A修改money=300,事务A阻塞
  • 事务B提交,提交的同时事务Aupdate成功

测试截图:

在这里插入图片描述

因为串行化级别下update都会阻塞,所以避免了脏读。

4.3.2 避免幻读和不可重复读

在脏读的例子中可以知道,在串行化级别下,插入与更新操作都会直接阻塞,因此也避免了幻读和不可重复读。

5 参考链接

分类:
阅读
标签:
分类:
阅读
标签: