脏读、幻读,要想搞懂不容易!

数据库 MySQL
脏读、幻读、不可重复读、当前读、快照读,这些名词经常搞的让人头晕。因为一般人大脑的主线就是单线程的,并不能一次性处理多个事务。

[[394503]]

本文转载自微信公众号「小姐姐味道」,作者小姐姐养的狗02号。转载本文请联系小姐姐味道公众号。   

脏读、幻读、不可重复读、当前读、快照读,这些名词经常搞的让人头晕。因为一般人大脑的主线就是单线程的,并不能一次性处理多个事务。

要想记忆深刻,我们得借助几个实例。读完本文,你一定会豁然开朗,忍不住三连走起。

但在这之前,我们需要看一下当前的数据库隔离级别,到底是什么。比如MySQL。

  1. select @@tx_isolation; 

MySQL就包含4种隔离级别,隔离的当然是数据。要修改隔离级别的话,可以使用下面的SQL语句。

  1. set session transaction isolation level read uncommitted
  2. set session transaction isolation level read committed
  3. set session transaction isolation level repeatable read
  4. set session transaction isolation level serializable

ok,我们创建一张小小的测试表,来看一下并发环境下的魔幻效果。

  1. CREATE TABLE `xjjdog_tx` ( 
  2.  `id` INT(11) NOT NULL
  3.  `nameVARCHAR(50) NOT NULL COLLATE 'utf8_general_ci'
  4.  `money` BIGINT(20) NOT NULL DEFAULT '0'
  5.  PRIMARY KEY (`id`) USING BTREE 
  6. COLLATE='utf8_general_ci' 
  7. ENGINE=InnoDB 
  8. INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (2, 'xjjdog1', 100); 
  9. INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (1, 'xjjdog0', 100); 

1. 脏读

脏读,意思就是读出了脏数据。啥叫脏数据?就是另外一个事务还没有提交的数据。在read uncommitted隔离级别下,就会出现脏读。比如下面这个时序

  1. 事务 A:set session transaction isolation level read uncommitted
  2. 事务 B:set session transaction isolation level read uncommitted
  3. 事务 A:START TRANSACTION ; 
  4. 事务 B:START TRANSACTION ; 
  5. 事务 A:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0'
  6. 事务 B:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0'
  7. 事务 A:ROLLBACK ; 
  8. 事务 B:COMMIT ; 
  9. 事务 B:SELECT * FROM xjjdog_tx ; 

在这个场景下,money的原始值为100,分别在两个session中进行了加100的操作,然后回滚了其中的一个session事务。结果,经过查询,发现money的值保持100不变。也就是其中一次加100的操作被覆盖掉了。

所以脏读发生有几个条件。

  • 高并发场景,在一个事务A开始之后还没结束之前,有另外一个事务参与了事务A所涉及的数据行读写
  • 事务隔离级别处于最低的读未提交
  • 在你使用到这些数据之后,事务A回滚,造成你之前拿到的数据已经不再存在

解决方式,只需要设置成隔离级别比read uncommitted高即可。

2. 不可重复读

把隔离级别设置成read committed即可避免脏读,这其实非常好理解。脏读产生的根本原因就是在事务的执行期间有别的操作乱入,这个隔离级别要求事务A提交之后,修改后的值,才能被事务B读到,所以脏读是不可能会发生的,从根本上杜绝了。

但read commited会发生不可重复读的情况。

顾名思义,就是在一个事务周期内,对于一个值的读取,产生了两个结果。

不可重复读,证明了世界并不是总围绕着你转的。在你的事务执行期间,会有无数的其他事务执行,如果你的事务持续时间超过了这些事务,那么你就可能读到两个或者更多的值。

让我来给你讲一个故事。

从前,有一颗桃树,长了12棵桃子。有一只猴子,叫做xjjdog,它想吃上面的桃子,但桃子还不熟。

第二天去看的时候,它发现桃子少了一个,变成了11个,经过仔细打听,原来是被猴子A抢先吃掉一个。

第二天去看的时候,桃子又少了一个,变成了10个,原来是被馋嘴的猴子B吃掉一个。

如此这般,桃子一天天少了下去,只剩下最后的2个了,但桃子还是没熟。

再不摘桃子就没了,xjjdog摘下了最后的2个桃子,正打算大快朵颐,结果跳出一只猴子X,说我盯着这些桃子已经1年了...

在这故事中,猴子A、B的事务持续周期是1天;xjjdog的事务持续周期是直到桃子成熟;猴子X的持续周期更长,可能是一年。它们每天看到的桃子,并不总是12个。今天的桃子,可能被其他的猴子(事务)给吃掉了,造成了观测的结果是不一样的,这就是不可重复读的概念。

有时候,即使读到的值是一样的,也不能证明没问题。比如有财务挪用了2亿去炒股,然后在月底把2亿还了回来,虽然最终的金额都是一致的,但由于你的对账周期长,就发现不了这种差异。

如何解决不可重复读呢?先要看一下不可重复读是不是问题。

有的系统,要求的就是这样的逻辑,每次在事务中读取到不一样的值,它是可以忍受的。但如果你想要在桃子成熟之前,桃子的数量都在你的掌控之中,那不可重复读就是一种问题。

一种非常好的方式,就是xjjdog一直站在桃树地下。当有别的猴子想要摘桃,就把它赶走。这种方式可行,但在数据库中非常低效,这是serializable级别的做法。

MySQL有一个默认的事务隔离级别,叫做repeatable read,使用了MVCC的方式(innodb),要更轻量级一些。

3. 可重复读

这就是MVCC(Multi-Version Concurrency Control)的功劳了,它有三个特点。

每行数据都存在一个版本,每次数据更新时都更新该版本

修改时,拷贝一份,当前版本随意修改,事务之间无干扰

保存时比较版本号,如果成功commit覆盖原记录,失败则rollback

MVCC在InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。它的实现关键也有三项技术:

  1. 3个隐式字段:DB_TRX_ID,最近修改它的事务ID;DB_ROLL_PTR,回滚指针,指向上一个版本;DB_ROW_ID,隐藏主键
  2. undo日志:的对同一记录的修改,会生成针对此记录的版本变更链表
  3. read view:快照读操作的时候,产生的读视图。除了使用上面的额外信息,它也会维护一个活跃的事务ID集合

一切的关键,就在于快照这两个字上面。

比如事务A对某个记录进行了快照读,那么在快照读的这一刻,就生成了一个Read View。在这一刻,事务B和C,还没有commit,事务D和E,在建立ReadView那一刻之前,commit完成,那么这个Read View,就不能够读到B和C的修改。

但可惜的是,可重复读,只能解决快照读的不可重复读,快照读的时机,也会影响读取的准确程度。请看下面两种情况。

下面这种情况读到的是500。

事务A 事务B
开启事务 开启事务
快照读(无影响)查询金额为500 快照读查询金额为500
更新金额为400  
提交事务  
  select 快照读金额为500
  select lock in share mode当前读金额为400

下面这种情况读到的是400。

事务A 事务B
开启事务 开启事务
快照读(无影响)查询金额为500  
更新金额为400  
提交事务  
  select 快照读金额为400
  select lock in share mode当前读金额为400
 

(表格来自[SnailMann]的博客)。

4. 幻读

幻读,这个词本身就非常的迷幻。在RU、RC、RR级别下,都会出现幻读。

拿一个最简单的例子来说。让你select一条记录是否存在然后打算进行后续插入时,如果这条记录不存在,然后你执行了插入操作,但在实际执行插入操作的时候,结果却报错了,这条记录已经存在了,这就是幻读。

首先,确认目前时可重复读级别。如果不是,则修改之。

  1. SELECT @@tx_isolation 
  2. set session transaction isolation level repeatable read 

让我们来看一下这个灵异过程。

有5个步骤,我都给你标好了。下面一一介绍。

  1. 事务A使用begin开启一个事务,然后查询id为3的记录,此时不存在。但由于快照读开启了一个针对于id为3的记录的read view,所以在这个事务自始至终都不能够读到为3的记录。很好,这就是我们不可重复读所需要的
  2. 接下来,事务B插入了一条id为3的记录,并提交成功
  3. 事务A此时也想插入这条记录,于是执行了相同的插入操作,结果数据库报错,显示这条记录已经存在
  4. 事务A此时一脸懵逼,想看一下这条记录到底是啥,但当它再次执行select语句的时候,却查不到这条记录
  5. 但在其他事务中,是可以看到这条记录的,因为它已经正确提交

这就是幻读。

5. 如何解决幻读

幻读有错么?多数情况下没错,就是报错怪异了些。要防止幻读,需要开启FOR UPDATE这样高强度的锁定,实际情况是非常少用。

为什么上面的操作,insert能报错,但select却无法查到数据呢?这就不得不提一下数据库读的两种模式:

快照读:普通的select操作,是从read view中读取数据,读取的可能是历史数据

当前读:insert、update、delete、select..for update这种操作,读取的总是当前的最新数据

对于当前读,你读取的行,以及行的间隙都会被加锁,直到事务提交时才会释放,其他的事务无法进行修改,所以也不会出现不可重复读、幻读的情形。所以insert能够发现冲突,而普通select却不可以。要想解决幻读,就需要加X锁。在上面这种情况,就可以在事务A中执行:

  1. SELECT * FROM xjjdog_tx WHERE id=3 FOR UPDATE 

当这么做的时候,即使id为3的记录不存在,它也会创建锁(在背后可能根据记录的存在与否加行X锁或者next-key lock间隙x锁)。

6. 总结

下面简单总结一下。

脏读,就是一个事务读取到另一个事务还没有提交的记录。当其他事务发生回滚的时候,就会出现问题。

不可重复读,意思是在同一个事务里,读多次可能会获得不一致的结果。这是因为在事务执行期间,有别的事务修改了这些记录。

MySQL默认是可重复读,但会发生幻读的情况。幻读是由于快照读和当前读的差别产生的。

要想解决幻读,就需要加锁(X锁,Gap锁等),比如for update,全部改成当前读直到事务结束,自然没有问题。

所谓的最高级别serializable,不过是全部搞成了当前读而已,在高并发的环境下效率,可想而知。所以几乎没有用的。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,进一步交流。

 

责任编辑:武晓燕 来源: 小姐姐味道
相关推荐

2022-01-03 07:18:05

脏读幻读 MySQL

2019-03-21 09:06:00

数据库复读幻读

2022-04-27 07:32:02

脏读幻读不可重复读

2023-08-09 17:22:30

MVCCMySQL数据

2023-11-01 14:13:00

MySQL事务隔离级别

2020-06-09 08:19:25

微服务网站架构

2012-06-13 14:58:09

BYOD移动办公

2020-06-18 10:52:17

运维架构技术

2018-01-24 07:28:20

2013-09-22 09:16:25

码农程序员黑客

2017-04-27 13:30:14

AndroidWebView移动应用

2009-09-04 08:19:24

Windows 7优缺点

2009-02-12 17:25:21

Windows7试用下载

2022-06-30 08:00:00

MySQL关系数据库开发

2011-12-16 14:52:55

移动互联联想

2023-08-31 22:17:15

JavaMySQLB+树

2018-06-01 15:48:22

2018-07-10 11:18:31

私有云混合云迁移

2018-06-02 13:37:00

2013-12-30 10:10:50

Windows XP
点赞
收藏

51CTO技术栈公众号