本文转载自微信公众号「小姐姐味道」,作者小姐姐养的狗02号。转载本文请联系小姐姐味道公众号。
脏读、幻读、不可重复读、当前读、快照读,这些名词经常搞的让人头晕。因为一般人大脑的主线就是单线程的,并不能一次性处理多个事务。
要想记忆深刻,我们得借助几个实例。读完本文,你一定会豁然开朗,忍不住三连走起。
但在这之前,我们需要看一下当前的数据库隔离级别,到底是什么。比如MySQL。
- select @@tx_isolation;
MySQL就包含4种隔离级别,隔离的当然是数据。要修改隔离级别的话,可以使用下面的SQL语句。
- set session transaction isolation level read uncommitted;
- set session transaction isolation level read committed;
- set session transaction isolation level repeatable read;
- set session transaction isolation level serializable;
ok,我们创建一张小小的测试表,来看一下并发环境下的魔幻效果。
- CREATE TABLE `xjjdog_tx` (
- `id` INT(11) NOT NULL,
- `name` VARCHAR(50) NOT NULL COLLATE 'utf8_general_ci',
- `money` BIGINT(20) NOT NULL DEFAULT '0',
- PRIMARY KEY (`id`) USING BTREE
- )
- COLLATE='utf8_general_ci'
- ENGINE=InnoDB
- ;
- INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (2, 'xjjdog1', 100);
- INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (1, 'xjjdog0', 100);
1. 脏读
脏读,意思就是读出了脏数据。啥叫脏数据?就是另外一个事务还没有提交的数据。在read uncommitted隔离级别下,就会出现脏读。比如下面这个时序
- 事务 A:set session transaction isolation level read uncommitted;
- 事务 B:set session transaction isolation level read uncommitted;
- 事务 A:START TRANSACTION ;
- 事务 B:START TRANSACTION ;
- 事务 A:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0';
- 事务 B:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0';
- 事务 A:ROLLBACK ;
- 事务 B:COMMIT ;
- 事务 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中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。它的实现关键也有三项技术:
- 3个隐式字段:DB_TRX_ID,最近修改它的事务ID;DB_ROLL_PTR,回滚指针,指向上一个版本;DB_ROW_ID,隐藏主键
- undo日志:的对同一记录的修改,会生成针对此记录的版本变更链表
- 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一条记录是否存在然后打算进行后续插入时,如果这条记录不存在,然后你执行了插入操作,但在实际执行插入操作的时候,结果却报错了,这条记录已经存在了,这就是幻读。
首先,确认目前时可重复读级别。如果不是,则修改之。
- SELECT @@tx_isolation
- # set session transaction isolation level repeatable read
让我们来看一下这个灵异过程。
有5个步骤,我都给你标好了。下面一一介绍。
- 事务A使用begin开启一个事务,然后查询id为3的记录,此时不存在。但由于快照读开启了一个针对于id为3的记录的read view,所以在这个事务自始至终都不能够读到为3的记录。很好,这就是我们不可重复读所需要的
- 接下来,事务B插入了一条id为3的记录,并提交成功
- 事务A此时也想插入这条记录,于是执行了相同的插入操作,结果数据库报错,显示这条记录已经存在
- 事务A此时一脸懵逼,想看一下这条记录到底是啥,但当它再次执行select语句的时候,却查不到这条记录
- 但在其他事务中,是可以看到这条记录的,因为它已经正确提交
这就是幻读。
5. 如何解决幻读
幻读有错么?多数情况下没错,就是报错怪异了些。要防止幻读,需要开启FOR UPDATE这样高强度的锁定,实际情况是非常少用。
为什么上面的操作,insert能报错,但select却无法查到数据呢?这就不得不提一下数据库读的两种模式:
快照读:普通的select操作,是从read view中读取数据,读取的可能是历史数据
当前读:insert、update、delete、select..for update这种操作,读取的总是当前的最新数据
对于当前读,你读取的行,以及行的间隙都会被加锁,直到事务提交时才会释放,其他的事务无法进行修改,所以也不会出现不可重复读、幻读的情形。所以insert能够发现冲突,而普通select却不可以。要想解决幻读,就需要加X锁。在上面这种情况,就可以在事务A中执行:
- 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,欢迎添加好友,进一步交流。