什么是脏读、不可重复读和幻读?产生原因是什么?有什么解决办法?

首先要知道,脏读(Dirty Read)、不可重复读(Non-repeatable Read)和幻读(Phantom Read)出现在不同级别的事务隔离下事务的并发操作中,它们影响了数据的一致性和完整性

要想详细了解它们的原理和解决方法,首先要知道出现的原因

隔离等级

先来回顾一下事务的隔离等级:

MySQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到

  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到

  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的

  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行

读提交和可重复读相对难理解一点

简单来说,读提交隔离等级下,事务执行期间可以看到其他事务提交的结果

而可重复读隔离等级下,整个事务执行期间看到的所有数据都和开启事务时一致,无论这期间有没有其他事务对这些数据修改

脏读

脏读仅出现在 读未提交 隔离等级下,一个事务读到另外一个事务还没有提交的修改,称之为脏读。

看一个简单的示例,现在有表 userinfo ,表中有字段 id、age ,只有一行值(1, 20)

session Asession B
T1

start transaction;

select age from userinfo where id=1;

T2

start transaction;

update userinfo set age=10 where id=1;

T3select age from userinfo where id=1;

start transaction 语句会直接开启一个事务。顺带一提,使用 begin/commit 这种方式开启事务时,事务开始的时间是 执行 begin 语句之后再执行第一个语句,而不是 begin 语句执行后

分析一下上面的流程:

T1 时刻,session A 查询 id=1 的值,结果会是(1,20)

T2 时刻,session B 修改了 id=1 的值

T3 时刻,session A 查询到了 session B 还没有提交的修改,结果为(1,10)

假如后面 session B 因为某些原因意外中断,”把 id=1 这行的 age 值修改为 10“这行操作回滚了,session A 读到的数据依然是 session B 修改过的

如果 T3 时刻,session A 根据 T1 时刻查到的结果进行更新操作,随后提交。就会导致实际的结果和预期的结果不一致

那在 读未提交 隔离等级下,怎么解决脏读的问题呢?

只能手动加锁,比如使用 select ... for update 语句,手动通过当前读加锁,在读取一行数据时不允许其他事务对其修改。但是这样就本末倒置了,我都手动加锁了,那为什么不直接用更高的隔离等级呢?(更高的隔离等级也是通过加锁实现的)

那既然 读未提交 连脏读都解决不了,为什么还要存在这个隔离等级呢?

MySQL 是通过加锁和MVCC等实现隔离等级的,加锁势必会影响性能和并发度。读未提交 是最不安全的,但也是性能最好的

存在即合理,比如在一个只有读没有其他操作的场景,就适合使用 读未提交 隔离等级

不可重复读

不可重复读出现在 读未提交、读提交 隔离等级下,一个事务在多次读取同一数据行的过程中,由于其他事务的更新操作,导致事务在多次读取同一数据行时得到不同的结果,称为不可重复读

只看概念好像和脏读没什么区别,要注意区分。脏读是读取到其他事务没有提交的修改,然后基于这个未提交的数据进行操作,当另一个事务回滚时,导致脏数据的情况。不可重复读是读到其他事务已经提交的修改,导致前后查询同一行结果不一致

看一个示例,还是表 userinfo ,表中有字段 id、age ,只有一行值(1, 20)

session Asession B
T1

start transaction;

select age from userinfo where id=1;

T2

update userinfo set age=10 where id=1;

T3select age from userinfo where id=1;

这里 session B 没有用 start transaction 或者 begin 开启事务,在 T2 时刻,session B 执行 update 语句时系统就默认开启了一个事务,执行完后默认提交了

分析一下结果:

T1 时刻,session A 查到的值为(1, 20)

T2 时刻,session B 把 id=1 的 age 值改为了 10 并提交

T3 时刻,session A 再去查,结果会是(1, 10)

结果很容易分析,因为 session B 的修改提交了,所以在 session A当然能看到

MySQL 的默认隔离等级是 可重复读,但是还有 Oracle 等一些数据库系统,默认隔离等级是 读提交。实际上,现在使用 MySQL的项目中有很多使用 读提交 的隔离等级

那么为什么 MySQL 默认隔离等级是 可重复读?读提交 隔离等级下的 不可重复读现象有哪些影响?为什么现在项目中又开始使用 读提交 的隔离等级?

为了更好的说明这些问题,我需要在这里引入 binlog 日志

简单来说,binlog 就是用来记录数据库更改的日志文件(可以去看我之前写的文章:MySQL中的三大日志系统-CSDN博客  简单了解 binlog 日志)

先来说为什么 MySQL 默认隔离等级是 可重复读。这是有历史原因的

binlog 有三种格式,分别是:

  • statement:记录每个执行的 SQL 语句
  • row:记录对表中行数据的修改,比如增删改操作
  • mixed:结合了 statement 和 row 两种格式的优点,MySQL 在执行每个 SQL 语句时会根据情况自动选择使用哪种格式来记录 binlog

在 mixed 格式下,对于可以安全使用 statement 格式的操作,MySQL 会选择使用 statement 格式记录 binlog。对于无法使用 statement 格式的操作,MySQL 会选择使用 row 格式记录 binlog

但MySQL 在 5.0 版本之前,binlog 只支持 statement 这种格式,而这种格式在 读提交 这个隔离等级下是有问题的,所以 MySQL 才将 可重复读 作为默认的隔离等级

具体看一下会出现哪些问题

现在数据库隔离等级为 读提交,建一个表 test,表中只有一个字段 b并作为主键。插入一行数据

create table test (
  b int primary key
);

insert into test values(1);

看一个简单的流程图

session Asession B
T1

start transaction;

delete from test where b<=5;

T2

start transaction;

insert into test values(3);

T3commit;
T4commit;

分析一下整个流程:

T1 时刻,session A 开启事务,删除掉表中所有 b<=5 的数据,会将表中 b=1 的数据删除

T2 时刻,session B 开启事务,插入 b=3 一行数据

T3 时刻,session B 提交,把插入操作提交,现在实际中的表有b=1、b=3 这两行数据

T4 时刻,session A 提交,把删除操作提交,删除掉 b=1 这一行数据(因为在执行删除语句是 b=3 这一行还没有出现),现在实际中的表只有 b=3 这一行数据

分析完成,整个过程并不难理解,也没什么问题。再来看 binlog 中的记录

binlog 是逻辑日志,只有事务提交后,binlog 才会记录对应的操作。所以 binlog 中的记录大概是:

insert into values(3);
delete from test where b<=5;

这时候就能发现问题了 ,执行顺序不一致。在数据库中,实际执行的是先删除后插入操作。而 binlog 中记录的确实先插入后删除。这样使用 binlog 归档的时候,就会出现数据不一致

而如果使用 row 格式,binlog 中记录的内容大概是这样:

insert into test values(3);
delete from test where b=1;

因为 row 格式下,会记录每一行数据的变化,在 session A 的删除语句中,它更改的数据只是 b=1 这一行,所以记录的只是删除 b=1 这一行,而不会记录整条语句

我在分析为什么 MySQL 默认隔离等级是 可重复读 时,实际上也已经把 不可重复读 的影响说明白了:MySQL 之前没有 row 格式的 binlog,不可重复读 现象会导致 binlog日志记录顺序出错,导致主从不一致

关于最后一个问题:为什么现在项目中又开始使用 读提交 的隔离等级,需要对比 可重复读 等级下的局限和性能等分析,所以要先了解完下面的知识后再来探讨

幻读

在 binlog 没有 row 格式时,为了解决 读提交  等级下的 不可重复读 现象造成的主从不一致,就要把隔离等级提高为 可重复读

可重复读 隔离级别通过MVCC(多版本并发控制),解决了 不可重复读 现象,通过间隙锁解决了数据不一致问题。在上面的例子中,如果是在 可重复读 等级下,session A 中 执行 delete 语句时,会通过间隙锁把 session B 中的插入操作锁住,只有等到 session A 提交后,session B 的插入操作才能执行,这样就保证了数据的一致性,我会在下面详细说间隙锁

可重复读 解决了 不可重复读(有点拗口),但是没有解决幻读

幻读是指,一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次没有看到的行

要注意和 不可重复读 的区分,幻读 是看到了原先不存在的,不可重复读 是原来是 A,现在看到的却是 B,不可重复读 针对的是同一行的变化

看一个示例,还是表 userinfo ,表中有字段 id、age ,只有一行值(1, 20)

session Asession B
T1

start transaction;

select * from userinfo;

T2

insert into userinfo values(2,20);

T3select * from userinfo;
T4update userinfo set age=10 where age=20;
T5

select * from userinfo;

commit;

分析一下结果:

T1 时刻,session A 开启事务,查表中所有数据,结果应该是(1,20)

T2 时刻,session B 开启事务,插入一条数据(2,20),然后提交

T3 时刻,session A 再次查表中所有数据,隔离级别为可重复读,当然查不到 session B插入的数据,所以结果还是(1,20)

T4 时刻,session A 要执行更新操作,把age=20的行改为age=10,这时候有默认的“当前读”操作(下面说为什么)

T5 时刻,session A 再次查表中的数据,结果从一条变为了两条,结果为(1,10)、(2,10)

T1 -- T3 时刻,session A 和 session B互不干扰,就算 session B 插入了一条数据,根据 可重复读 隔离级别 的规则,和 session A 也没有关系,session A 中的数据应该与事务开启时保持一致的

但是执行到 T4 时刻时,执行语句时会显示影响的行数为 2 行,T5时刻也查到了 session B 插入的数据

T1、T3、T5 查询范围一样,但是结果却不一样,T5 “凭空多出来了一行”,这就是幻读

如果你了解 MVCC(多版本并发控制),以及相关的“快照读”和“当前读”,上面的示例能很轻松的看懂

这里我尽量简单易懂的说明:在 可重复读 隔离级别下,每个事务开启时会生成一个事务快照视图,这个视图会保存事务开启时所有数据的快照状态。之所以说快照状态,是因为视图中存的不是真正的物理值,而是经过回退等操作计算处理的,如果存物理值,假如数据库有 100G,每开一个事务都生成一个 100G 的视图,显然不可能

各个事务开启的视图互不干扰,一个事务中的修改不会涉及到另一个事务。这个功能的实现原理就是MVCC,简单说就是一个值在数据库中是存在多个版本的,每个事务中用它各自的版本。上面也说了,事务的视图记录的是快照,即每个值的版本,而每个版本的值通过当前值执行 undo log (回滚日志)中对应的记录进行回退得到

再来看“快照读”和“当前读”,快照读就是读自己事务内的版本对应数据,当前读就是读事务执行期间最新版本的数据。要知道,每个事务更新完数据提交后都会更新数据的版本。假如在一个事务执行期间,另一个事务改了数据并提交了,用“快照读”是看不到的,只能看到自己事务内的数据,而用“当前读”就能看到,因为数据的最新版本已经被另一个事务修改了

再来说为什么 update 语句会默认使用“当前读”。更新数据之前需要先查一次,查到要改的数据后才能改,这是必须的,没什么疑问。上面也说了,事务更新完数据提交后会更新数据的版本,假如现在表中 age 字段值为 10。看一个例子:

事务 A 先开启,在事务 A 执行期间,事务 B 提交了一个更新操作,把 age 值从10 改成了 15。这时候表里的值是 15,因为事务 B已经完成了更新操作,更新了 age 字段的版本,现在age的最新版本对应的值为 15。此时事务 A 还没有结束,它在事务 B 提交后要开始执行更新操作,要把 age值加 1,如果它不用“当前读”,那读到的数据就还是 10,执行完更新操作后,age 值变为了 11。这样事务 A 提交后,事务 B 已经完成的更新操作就丢失了,事务 A 更新了 age 字段的值和版本,现在 age 字段最新版本对应的值变成了 11,而正确的值应该为 16,因为事务 B 已经完成的更新操作要保留。所以,如果更新操作不使用”当前读“,就会造成数据不一致

我又花了些篇幅介绍了MVCC、当前读和快照读。因为想要深入理解幻读,这些是必要的前置知识

幻读的影响

先来建表,在这个表 t 中, 有 id、d 字段,id 作为主键。建表后又插入了 5 行数据

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
) ENGINE=InnoDB;
 
insert into t values(0,0),(5,5),
(10,10),(15,15),(20,20);

现在来看一个执行过程

session Asession Bsession C
T1

start transaction;

update t set d=100 where d=5;

T2

update t set d=5 where id=0;

T3select * from t where d=5 for update;
T4

insert into t values(1,5);

T5

select * from t where d=5 for update;

T6commit;

要看懂这个示例,你需要先知道:

查找没有建立索引的字段,会走全表扫描,即一行一行找

update 语句在执行前默认使用的“当前读”,会把扫描到的所有行都加锁

其他事务中对加锁的行进行更新等操作会被阻塞

来用现在已了解的知识分析一下整个流程:

T1 时刻:session A 执行更新语句,更新之前会默认使用“当前读”,把扫描到的所有行都加锁。因为 d 上没有索引,要进行全表逐行查询,所以会把(0,0)、(5,5)这两行加锁。随后执行更新操作,把(5,5)更新为(5,100)

T2 时刻:session B 要执行更新操作,id=0这一行(0,0)已经在 T1 时刻被 session A 加锁了,所以 session B 会阻塞,等到 session A 提交后才能执行

T3 时刻:session B 的更新操作被阻塞,所以 session A 找 d=5 这一行的结果会是空,因为 d=5 这一行在 T1 时刻已经被改成了(5,100)

T4 时刻:session C 要执行插入操作,没有锁能阻止这个插入操作,所以这个插入操作能完成

T5 时刻:由于 T4 时刻 session C 的插入操作,session A 此时读到的结果会是(1,5),这里出现了幻读,但是看起来也没什么影响

T6 时刻:session A 提交,提交后,session B 阻塞的事务终于可以执行,session B 把 id=0 这一行的 d 值改成了 5

所以,一整个流程下来,数据库中的数据应该有:(0,5)、(1,5)、(5,100)。这看起来并没有什么问题

再来看 binlog 中的记录

binlog 会在一个事务提交后把对应语句记录下来,所以记录的内容应该是:

/* session C 先提交 */
insert into t values(1,5);

/* session A */
update t set d=100 where d=5;

/* session B */
update t set d=5 where id=0;

session C 先提交,所以先记录,随后是 session A、session B

可以看到,按照 binlog 中的执行顺序, session A 的更新操作把 session C 已经提交的数据给修改了!id=1 这一行,在数据库中的结果是(1,5),而根据 binlog 的执行结果却是(1,100)

分析一下出错的原因。很简单,执行顺序错了。在 T1 和 T3 时刻加锁时,id=1 这一行记录还不存在,T5 时刻再加已经晚了。行锁只能锁住行,但是插入操作发生在行记录之间的“间隙”,要想解决这个问题,就只能引入新的锁,也即间隙锁

幻读的解决

现在你应该知道了,产生幻读的原因是:行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。为了解决这个问题,InnoDB 引入了间隙锁

顾名思义,间隙锁就是锁住两个值之间空隙的锁,在上面的表 t 中,初始化插入了 5 个记录,就产生了 6 个间隙

id 值有0、5、10、15、20。5 个值产生了 6 个间隙

当执行 select * from t where d=5 for update 时,不止会把(0,0)和(5,5)这两行记录锁住,还会把中间的所有间隙给加上锁,这样就阻止了范围内的更新操作。

所以对于上面的例子,session C 因为间隙锁,必须等到 session A 提交之后才能执行。这样就保证了数据的一致性

间隙锁和行锁合称 next-key lock,它解决了幻读产生的问题,但是同时会导致语句锁住更大的范围,影响了并发度

为什么又能使用 读提交 隔离级别

在介绍不可重复读的最后遗留了一个问题:为什么现在项目中又开始使用 读提交 的隔离等级?

现在你已经知道,在 binlog 没有 row 格式之前,使用 可重复读 的隔离等级才能保证数据的一致性

而 可重复读 隔离等级是比 读提交 等级高的,隔离等级越高,限制越多,并发速度越低

在某些情况下,可重复读 会白白浪费很多时间去等待锁的释放

当然,把 binlog 的格式设置为 row,也是会浪费性能的,所以现在 MySQL 中 binlog 的默认格式是 mixed,根据情况自动选择使用 statement 还是 row 格式

可重复读 和 读提交 用哪个要具体业务具体分析,没有固定的配套方案(虽然现在用 读提交 的在变多)

总结

这篇文章中,我结合示例介绍了脏读、不可重复读、幻读的出现原因和影响。文章中我还稍微扩展了一些其他的知识,因为MySQL 中的知识大多都是相互联系的,很难单独提出一块来讲。在阅读过程中遇到不太明白的地方要及时查资料,也欢迎在评论区提问

在业务中,能使用的隔离等级只有 读提交 和  可重复读。读未提交 连脏读都没有解决,肯定不能用,而 串行化 又严重影响了并发,效率很低

读提交 和 可重复读 分别在什么场景下适合用,是一个有趣的问题,可以去研究一下

最后,学艺不精、能力有限。如果文章中有问题,请在评论区或者私信我提出,我看到后会及时修改

最后的最后,感谢阅读。长文写作不易,如果这篇文章对你有帮助,请务必务必点赞收藏!

  • 17
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值