【51CTO.com原创稿件】我是幻读,听说有人认为我是 MVCC 解决的,为了让大家更全面地理解我,只能亲自来解释一下。
图片来自 Pexels
我是谁?
先给大家做一个简单的自我介绍,我就是事务并发时会产生的三大问题之一。
我的其他俩兄弟脏读、不可重复读被 MVCC 在上一个回合无情的干掉了,至于上个回合发生了什么可以去看剧情回顾。
我的由来就是因为主人在操作一组数据时还有很多人也在对这组数据进行操作。
举一个简单的案例:根据条件在对一组数据进行过滤返回的结果为 100 个,但是在主人操作的同时其他人又新增了符合条件的数据,然后主人再次进行查询时返回结果为 101。第二次返回的数据跟第一次返回数据不一致。
于是我诞生了,大家还给我起了个很好听的名字幻读。为什么会给我起这个名字呢!那是因为我给人们的现象好像出了幻觉一样。
为什么有人会认为我是被 MVCC 干掉的
为了演示方便,就直接使用之前的测试表来进行操作。
同时大家可以看到此表还有一些测试数据,一切从头开始,清空表。
清空表的命令:
- truncate table_name
执行这个命令会使表的数据清空,并且自增 ID 会从 1 开始。
从执行过程来看,truncate table 类似于 drop table 然后在 create table,这里的环境都是测试环境,千万不要在线上进行操作,因为它绕过了 DML 方法,是不能回滚的。
进行了一点小插曲,进入正题。
根据上图的执行步骤,预期来说左边事务的第一条 select 语句查询结果为空。第二个 select 查询结果为 1 条数据,包含右边事务提交的数据。
但在实际测试的情况下,第一次执行 select 和第二次执行 select 返回结果一致。
从这个案例中,可以得出结论确实在不可重复隔离级别下会解决幻读问题(在快照读的前提下)。
我真的是被 MVCC 解决的?
通过上述测试案例来看,貌似在 MySQL 中通过 MVCC 就解决我的引来的问题,那既然都解决了我的问题,为什么还有串行化的隔离级别呢!好疑惑啊!
带着这个疑问继续进行实验,为了方便就不再使用上边表结构了,建立一个简单的表结构。
再进入一个小插曲你知道在 MySQL 终端如何清屏吗?执行命令 system clear 即可。
接着开始新一轮的测试:
上图案例事务 1 几次查询数据都是空。此时事务 2 已经成功将数据插入并且提交。但当事务 1 几次查询数据为空之后进行数据插入时,提示主键重复。
再来看一个案例:
如上图:
- step1:事务 1 开启事务
- step2:事务 2 开启事务
- step3:事务 1 查询数据只有一条数据
- step4:事务 2 添加一条数据
- step5:事务 1 查询数据为一条
- step6:事务 2 提交事务
- step7:事务 1 查询数据为一条
- step8:事务 1 修改 name
- step9:猜想一下此时表内数据会发生什么改变
此案例中事务 1 始终读取数据都是一条数据,但是在修改数据时影响数据行数却是 2,再次进行查看数据时竟然出现了事务 2 添加的数据。这也可以看作是一种幻读。
小结:通过以上俩个案例得知在 MySQL 可重复读隔离级别中并没有完全解决幻读问题,而只是解决了快照读下的幻读问题。
而对于当前读的操作依然存在幻读问题,也就是说 MVCC 对于幻读的解决是不彻底的。
再聊当前读、快照读
在上一回合中快照读、当前读已经被消化了,为了防止消化不良这里再简单说明一下。
①当前读
所有操作都加了锁,并且锁之间除了共享锁都是互斥的,如果想要增、删、改、查时都需要等待锁释放才可以,所以读取的数据都是最新的记录。
简单来说,当前读就是加了锁的,增、删、改、查,不管锁是共享锁、排它锁均为当前读。
在 MySQL 的 Innodb 存储引擎下,增、删、改操作都会默认加上锁,所以增、删、改操作默认就为当前读。
②快照读
快照读的出现旨在提高事务并发性,实现基于我的敌人 MVCC,简单来说快照读就是不加锁的非阻塞读,即简单的 select 操作(select * from user)。
在 Innodb 存储引擎下执行简单的 select 操作时,会记录下当前的快照读数据,之后的 select 会沿用第一次快照读的数据,即使有其它事务提交也不会影响当前的 select 结果,这就解决了不可重复读问题。
快照读读取的数据虽然是一致的,但有可能不是最新的数据而是历史数据。
告诉你们吧!当前读的情况下,我是被 next-key locks 干掉的
第二小节中得知在快照读下由于我引发的问题已经被 MVCC 消灭了。但是在小节三进行案例测试发现在当前读下我又满血复活了。
我要是那么容易被干掉还怎么被称为打不死的小强,这不是闹笑话呢!说归说,闹归闹如果 MVCC 把它的小弟 next-key locks 带上那我就完了,就不再像灰太狼说经典语录“我一定会回来的”。
此时就要思考一个问题,在 Innodb 存储引擎下,是默认给快照读加 next-key locks,还是说需要手动加锁。
通过官方文档对于 next-key locks 的解释:
To prevent phantoms, InnoDB uses an algorithm called next-key locking that combines index-row locking with gap locking. InnoDB performs row-level locking in such a way that when it searches or scans a table index, it sets shared or exclusive locks on the index records it encounters. Thus, the row-level locks are actually index-record locks. In addition, a next-key lock on an index record also affects the “gap” before that index record. That is, a next-key lock is an index-record lock plus a gap lock on the gap preceding the index record. If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.
大致意思,为了防止幻读,Innodb 使用 next-key lock 算法,将行锁(record lock)和间隙锁(gap lock)结合在一起。
Innodb 行锁在搜索或者扫描表索引时,会在遇到的索引记录上设置共享锁或者排它锁,因此行锁实际是索引记录锁。
另外, 在索引记录上设置的锁同样会影响索引记录之前的“间隙(gap)”。即 next-key lock 是索引记录行加上索引记录之前的“gap”上的间隙锁定。
并且还给了一个案例:
- SELECT * FROM child WHERE id > 100 FOR UPDATE;
当 Innodb 扫描索引时,会将 id 大于 100 地上锁,阻止任何大于 100 的数据添加。
到这里就回答了上边问题,在 Innodb 下解决当前读产生的幻读问题需要手动加锁来解决。
再来看一个案例,下图为此时的数据情况:
下图的这个案例就解决了在第三节中第一个案例的幻读问题。
如上图:
- step事务1:开启事务
- step事务2:开启事务
- step事务1:查询 ID 为 4 的这条数据并且加上排它锁
- step事务2:添加 ID 为 4 的数据,并且等待事务 1 释放锁
- step事务1:添加 ID 为 4 的数据,添加成功
- step事务1:查询当前数据
- step事务1:提交事务
- step事务2:报错,返回主键重复问题
这个案例查询的索引列是主键并且是唯一的,此时 Innodb 引擎会对 next-key lock 做降级处理,也就是只锁定当前查询的索引记录行,而不是范围锁定。
案例二:还是使用上边的数据,但是这次我们进行一次范围查找。
此时的数据为 1,3,5,查找的范围为大于 3。从下图可以看出当事务 2 执行添加 ID 为 2 的是可以添加成功的。
但是当添加 ID 6 时需要等待。此时若事务 1 不提交事务,事务 2 添加 ID 为 6 的这条数据就执行不成功。
对于上述的 SQL 语句 select * from user where id > 3 for update;执行返回的只有 5 这一行数据。
此时锁定的范围为 (3,5],(5,∞),所以说 id 为 2 的可以插入,ID 为 4 或者大于 5 的都是插入不了的。
以上就是在 Innodb 中解决幻读问题最终方案。
幻读解决方案
为了方便大家直观了解幻读的解决方案,这里咔咔进行简单的总结。
通过 MVCC 解决了快照读下的幻读问题,为什么能解决?在第一次执行简单的 select 语句就生成了一个快照,并且在后边的 select 查询都是沿用第一次快照读的结果。所以说快照读查询到的数据有可能是历史数据。
通过 next-key lock 解决当前读的幻读问题,next-key lock 是 record lock 和 gap lock 的结合,锁定的是一个范围,如果查询数据为索引记录行,则只会锁定当前行,也就是说降级为 record lock。
若为范围查找时就会锁定一个范围,例如上例中 ID 为 1,3,5 查询大于 3 的数据,则会把 (3,5],(5,∞) 进行范围锁定,其它事务在锁未释放之前是无法插入的。
从官方文档还可得知如果需要验证数据唯一性只需要给查询加上共享锁即可,也就是给 select 语句加上 in lock share mode,如果返回结果为空,则可以进行插入,并且插入的这个值肯定是唯一的。
同样也可以添加 next key lock 防止其他人同时插入相同数据,小节 5 的所有案例就是使用的 next-key lock,从这一点可以得知 next-key lock 是可以锁定表内不存在的索引。
根据上述结论来看,如果想要检测数据唯一性使用共享锁,那么多个事务同时开启共享锁,又同时添加相同的数据怎么办,会不会出现问题呢?
明确地说明是不会的,如果多个事务同时插入相同数据只会有一个事务添加成功,其它事务会抛出错误,这个就是一个新的概念“死锁”。
扩展
事务 ID 是在何时分配的?在本文或者其它资料中都能得到一个信息就是当执行一条简单的 select 语句同时也会生成 read-view。
虽然快照读、read-view 都是基于事务启动的前提下,但是 read-veiw 是通过未提交事务 ID 组成的。
①那么到底是在何时分配事务 ID 的呢?
事务的启动方式有两种,分别为显示启动、另一种是设置 autocommit=0 后执行 select 就会启动事务。
在显示启动中最简单的就是以 begin 语句开始,也可以使用 start transaction 开启事务。
若使用 start trancaction 开启事务也可以选择开始只读事务还是读写事务。
看了很多资料都说当开启一个事务时会分配一个事务 ID,那么来验证一下是这个样子的吗?
通过上图可以看到当执行一个 begin 语句之后查询事务 ID 是空的,也就说当执行 begin 后并没有分配 trx_id。
那么当执行 begin 后在支持 DML 语句呢!
根据文档得知,执行 begin 命令并不是真正开启一个事务,仅仅是为当前线程设定标记,表示为显式开启的事务。
所以要明白对数据进行了增、删、改、查等操作后才算真正开启了一个事务,此时会去引擎层开启事务。
②为什么事务 ID 差异特别大?
上图中查询了当前活跃的事务 ID,但是两个事务 ID 的差异特别大。相信很多小伙伴都遇到过这个问题,有问题不害怕,害怕的是没有问题。
事实上在这两条数据中只有 20841 是真正的事务 ID,那么第二条数据中的 ID 是什么呢!
想知道这个数字是什么的前提是知道是怎么来的。
从上图可以看出,当执行 select 语句后会产生一个非常大的事务 ID,那能不能理解为这种差异非常大的事务 ID 是通过快照读的方式才会生成的。
接着再这个事务下面在执行一个 insert 语句,然后再查看一下事务 ID 的状态。
不可思议的是在事务中先执行 select 语句,然后执行 insert 语句,事务 ID 发生了变化,这是什么原因呢?
经过资料查询得知当执行一个简单的 select 语句时,被称之为只读事务,为了避免给只读事务分配 trx_id 带来不必要的开销就没有对其分配事务 ID。
只读事务没有分配 undo segment 也不会分配 LOCK 锁结构,本质上只读事务的 trx_id 的值就是 0。
但是为了执行 select * from information_schema.INNODB_TRX 或者 show engine innodb status 时。
就会通过 reinterpret_cast(trx) | (max_trx_id + 1) 将指针转换为一个 64 字节非负整数然后位或 (max_trx_id + 1) 就是这么个值。
关于这个值的生成过程就不用再去深究了,只需要知道在只读事务下是不会分配事务 ID,而查询出来的这个值只是为了显示而存在的没有实际意义。
但是当你执行 select * from information_schema.INNODB_TRX 查询出来的事务 ID,再通过 show engine innodb status 查询是查不到的。
在 Innodb 下如果事务为只读事务则不会在 Innodb 数据结构中显示,因此你是看不到的。
作者:咔咔
简介:坚持学习、坚持写博、坚持分享是咔咔从业以来一直所秉持的信念。希望在偌大互联网中咔咔的文章能带给你一丝丝帮助。我是咔咔,下期见。
编辑:陶家龙
征稿:有投稿、寻求报道意向技术人请添加小编微信 gordonlonglong
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】