1 可重复读的意义
众所周知,事务隔离级别分为读未提交、读已提交、可重复读、串行化,解决了提交丢失、脏读、不可重复读、幻读一系列问题。但心中一直有个疑问,为何要出一个可重复读隔离级别来解决不可重复读问题?读已提交所提供的“其他事务可读取到已提交的事务”,其实是一种比较合理的状态,既然成功提交了就给他读呗。那可重复读存在的意义是什么?
查阅资料外加一阵思考后,大概有点头绪了。我理解是为了维持一个相对稳定的状态,MySQL是一个关系型数据库,且具有极为重要的事务ACID四大特性。结合这几点来看,MySQL意图提供给用户稳定而完整的服务。
假设这样一个场景:
商户在进行今日货物清点,在商品信息表查询商品剩余数量、在订单信息表查询今日总交易量、在交易流水表查询今日总交易金额。在盘点期间商户店铺不冻结,可以正常进行交易操作。
使用读已提交级别:
- 事务开启后,查询商品剩余数量10件,商户算了算得出今日售出40件;第一步完成后,有顾客又来下了一单,那么此时商品剩余数量为9件、今日售出41件,但是第一步已经查剩余商品10件,这件事我们先按下不表。
- 第二步查询总交易量,此时由于刚刚顾客下了一单,总交易量已经变成了41件,这一步就已经和商户想得不太一样了。
- 到了最后一步,总交易额查询结果也是售出41件的交易额。
那么最后商户拿到的结果是:今日剩余10件商品,售出41件商品,卖出41件商品的金额。商户懵逼了,百思不得其解为何会这样,难道是上帝嘉奖了自己的勤奋,多给了一份商品的钱?但是商户刷新后,发现一切都是一场空,钱数原来是正确的,馅饼又飞回了天上。假设极端情况下,商户每次刷新之后的查询期间,都有一位顾客来下一单,那这种数据不一致的延迟就会维持到商户离开人世。
讲到这里,可重复读的意义出现了,上述例子的情况显然是一种非稳定状态,每次顾客的操作都会导致数据库记录的不稳定,这与MySQL的理念(我想象中的)是相悖的。可重复读完美地解决了上述场景的问题,在事务开启时针对涉及的表记录创建快照,此后所有读取到的记录都维持了同一时间下相对稳定的状态。
那在可重复读情况下,我们还有可能读到其他事务提交的记录吗?答案是有可能,更具体地说是有可能读到其他事务新增的记录,但不可能读到被其他事务修改后的记录;快照读下数据是定格的,完美解决了不可重复读问题,而使用当前读则能读到其他事务新增的记录,这就是另一个问题“幻读”。
2 幻读
在此之前先介绍一下MySQL的MVCC(Multi Version Concurrency Control,多版本并发控制)机制,熟悉这个的朋友们应该也对“DB_TRX_ID”(全局事务号)比较了解,MySQL利用全局事务号 + 当前版本号 + 删除版本号实现了快照读/当前读两种读取方式。大概意思就是,我们看到的一行数据,其实是由多行不同版本的数据组成的。让我们利用下面的例子,来关注id为1的记录版本号变化。
操作 | SQL语句 | 当前版本号 | 删除版本号 | 版本号变更记录 |
---|---|---|---|---|
事务1 插入记录 | INSERT INTO table (id, value) VALUES (1, 1) | 1 | 无 | 事务1插入记录,当前版本号为全局事务号1;记录未被删除或更改,因此无删除版本号。 |
事务2 查询记录 | SELECT * FROM table WHERE id = 1 | 1 | 无 | 事务2查询记录,查询当前版本号 ≤ 全局事务号、删除版本号不存在或 > 全局事务号对应记录。这是为了查到当前事务下,能看到的最新记录。 |
事务3 修改记录 | UPDATE table SET value = 2 WHERE id = 1 | 3 | 无 | 事务3修改记录,当前事务号为全局事务号3。 |
INSERT INTO table (id, value) VALUES (1, 1) | 1 | 3 | 隐藏行,为id = 1记录于事务1插入的历史版本,于事务3被删除过期,因此删除版本号为当前事务号3。 | |
事务4 查询记录 | SELECT * FROM table WHERE id = 1 | 3 | 无 | 事务4查询记录,全局事务号4 > 删除版本号3、全局事务号4 > 当前本版本号3,因此查询到value = 2的记录。 |
事务5 删除记录 | DELETE FROM table WHERE id = 1 | 3 | 5 | 事务5删除记录,将删除版本号设置为全局事务号5。 |
事务6 查询记录 | SELECT * FROM table WHERE id = 1 | 3 | 5 | 事务6查询记录,发现3个版本记录的删除记录号都小于全局事务号,所以搜索不到任何记录。 |
看完上面的流程,相信大家对MVCC已经有了基本认知。就是根据同一条记录的不同版本号,与开启事务时的全局事务号进行对比,得到能够读取的记录;类似于8点开始读取,别人9点进行的修改不可见,但7点50的修改可见。
再回到之前说的有可能读到其他事务提交的记录,其实就是基于快照读(普通SELECT)和当前读(排他锁FOR UPDATE、共享锁SHARE MODE)产生的差异,快照读是将时间定格在8点,对数据库状态拍张照拿去慢慢读,这种情况下是不可能读到其他事务提交的记录的(不论是修改、删除、插入),也就解决了幻读问题;而当前读顾名思义,永远将最新时间作为起点,读取最新时间下的最新修改。
看到这里可能有朋友就懵逼了,照这么说当前读也会造成不可重复读,永远读最新的记录,别人修改并提交你就读到最新的结果——但是不要忽略一点哈,排他锁会给查询的记录加上锁,其他事务对于加锁记录的修改操作会被阻塞,还会加上间隙锁(Next key + Gap),因此说完美地解决了不可重复读问题。
排他锁会阻塞修改操作,但无法阻塞插入操作,因为排他锁只会锁住id对应记录和周围几条,插入和这个范围内的记录并没有什么联系,除非锁表,但是没必要。
因此当前读并不能解决幻读问题,而快照读能够解决幻读问题,但他们都解决了不可重复读问题。