Mysql MVCC原理和幻读解决
reference:https://blog.csdn.net/weixin_43477531/article/details/121963884
reference:https://www.cnblogs.com/xuwc/p/13873293.html
1、MVCC全称(Multi-Version Concurrency Control),即多版本并发控制,主要是为了提高数据库的并发性能,解决幻读问题。
2、快照读、当前读
快照读:顾名思义,就是读取的是快照数据,不加锁的普通select都是快照读
当前读:就是读取最新数据,而不是历史数据,或者说不是快照数据,是加锁的select,或者对数据进行正删改都会进行当前读。
3、MVCC解决问题:
- 并发读-写场景:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作。
- 解决脏读、幻读、不可重复读等事务隔离问题。但不能解决并发写-写场景问题。
4、MVCC原理
实现原理主要是版本链。undo日志、ReadView来实现的。
InnoDB 存储引擎,表中的聚簇索引都包含三个隐藏列(row_id、trx_id、roll_pointer)。
row_id:创建的表中有主键或者非NULL的unique键时都不会包含row_id列。
trx_id:事务ID,每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
roll_pointer:回滚指针,每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
版本链:每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表。版本链的头节点就是当前记录最新的值。
5、undo日志
undo log 主要用于记录数据被修改之前
的日志,在表信息修改之前先会把数据拷贝到undo log
里。当事务
进行回滚时
可以通过 undo log 里的日志进行数据还原
。
用途:
- 保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用undo日志的数据进行恢复。
- 用于MVCC
快照读
的数据。在MVCC多版本控制中,通过读取undo日志的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。
类别:
- insert undo log
代表事务在insert新记录时产生的undo log , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
- update undo log
事务在进行 update 或 delete 时产生的 undo log, 不仅在事务回滚时需要,在快照读时也需要。所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。
6、ReadView
改动的记录都存在在 undo 日志中,那如果一个日志需要查询行记录,需要读取哪个版本的行记录呢?
-
对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
-
对于使用
SERIALIZABLE
隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录,不存在并发问题。 -
而对于使用
READ COMMITTED
和REPEATABLE READ
隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。
核心问题就是: READ COMMITTED
和 REPEATABLE READ
隔离级别在不可重复读和幻读上的区别在哪里?这两种隔离级别对应的不可重复读与幻读问题都是指同一个事务在两次读取记录时出现不一致的情况,这两种隔离级别关键是需要判断版本链中的哪个版本是当前事务可见的。
ReadView 就是用来解决这个问题的,可以帮助我们解决可见性问题。 事务进行快照读操作的时候就会产生 Read View,它保存了当前事务开启时所有活跃的事务列表(这里的活跃指的是未提交的事务。)
每一个事务在启动时,都会生成一个 ReadView,用来记录一些内容,ReadView 中主要包含 4 个比较重要的属性:
m_ids:事务id列表,生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。
min_trx_id:最小事务id,生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id 也就是 m_ids 中的最小值。
max_trx_id:下一个事务id,生成 ReadView 时系统中应该分配给下一个事务的 id 值。
creator_trx_id:当前ReadView所属事务id,生成该 ReadView 的事务的事务 id,指定当前的 ReadView 属于哪个事务。
其中,max_trx_id并不是指m_ids中的最大值,因为事务 id 是递增分配的,假如现在有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。
再有了 ReadView 之后,在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
-
trx_id = creator_trx_id
,可访问意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
-
trx_id < min_trx_id
,可访问表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
-
trx_id >= max_trx_id
,不可访问表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
-
min_trx_id <= trx_id < max_trx_id
,存在m_ids
列表中不可访问如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
-
某个版本的数据对当前事务不可见
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。
7、MVCC下的幻读
幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新记录,也就是说幻读是指新插入的行。
在 REPEATABLE READ 隔离级别下,事务 A 第一次执行普通的 SELECT 语句时生成了一个 ReadView(且在 RR 下只会生成一个 RV),之后事务 B 向 user 表中新插入一条记录并提交。
ReadView 并不能阻止事务 A 执行 UPDATE 或者 DELETE 语句来改动这个新插入的记录(由于事务 B 已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id。之后 A 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。
因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁止幻读。
8、解决幻读问题
我们知道数据库的读操作分为当前读和快照读,而在 RR 隔离级别下,MVCC 解决了在快照读的情况下的幻读,而在实际场景中,我们可能需要读取实时的数据,比如在银行业务等特殊场景下,必须是需要读取到实时的数据,此时就不能快照读。
毫无疑问,在并发场景下,我们可以通过加锁的方式来实现当前读,而在 MySQL 中则是通过Next-Key Locks
来解决幻读的问题。(关于 MySQL 中的锁的介绍可以看看这篇文章:一文了解 MySQL 中的锁)。
Next-Key Locks
包含两部分:记录锁(行锁,Record Lock),间隙锁(Gap Locks)。记录锁是加在索引上的锁,间隙锁是加在索引之间的。
- Record Lock
记录锁,单条索引记录上加锁。
Record Lock 锁住的永远是索引,不包括记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。
记录锁是有 S 锁和 X 锁之分的,当一个事务获取了一条记录的 S 型记录锁后,其他事务也可以继续获取该记录的 S 型记录锁,但不可以继续获取 X 型记录锁;当一个事务获取了一条记录的 X 型记录锁后,其他事务既不可以继续获取该记录的 S 型记录锁,也不可以继续获取 X 型记录锁。
- Gap Locks
间隙锁,对索引前后的间隙上锁,不对索引本身上锁。前开后开区间。
MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种。
-
可以使用 MVCC 方案解决
-
也可以采用加锁方案解决(间隙锁)。
但是在使用加锁方案解决时有问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。所以我们可以使用间隙锁对其上锁。
索引对间隙锁会产生什么影响?
-
对主键或唯一索引,如果当前读时,where 条件全部精确命中(=或in),这种场景本身就不会出现幻读,所以只会加行锁,也就是说间隙锁会退化为行锁(记录锁)。
-
非唯一索引列,如果 where 条件部分命中(>、<、like等)或者全未命中,则会加附近间隙锁。例如,某表数据如下,非唯一索引2,6,9,9,11,15。如下语句要操作非唯一索引列 9 的数据,间隙锁将会锁定的列是(6,11],该区间内无法插入数据。
-
对于没有索引的列,当前读操作时,会加全表间隙锁,生产环境要注意。
Next-Key Locks
next-key locks 是索引记录上的行锁和索引记录之前的间隙锁的组合,包括记录本身,每个 next-key locks 是前开后闭区间(同样说明锁住的范围更大,影响并发度),也就是说间隙锁只是锁的间隙,没有锁住记录行,next-key locks 就是间隙锁基础上锁住右边界行数据。
对于可重复读默认使用的就是next key lock,但是对于“唯一索引”,比如主键的索引,next key lock会降级成行锁,而不会锁住一个区间。因此,如果上面的事务1的update使用的是主键,事务2也使用主键进行插入,那么实际上事务2根本不会被阻塞,可以立即插入并返回。而对于非唯一索引,next key lock则不会降级。
9、 结论:
-
MySQL InnoDB的可重复读并不保证避免幻读,需要应用使用加锁读来保证。而这个加锁读使用到的机制就是next-key locks。
-
Read Committed隔离级别:每次select都生成一个快照读。
-
Read Repeatable隔离级别:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读。
-
在RR级别下,快照读是通过MVVC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。
-
在mysql中,提供了两种事务隔离技术,第一个是mvcc,第二个是next-key技术。这个在使用不同的语句的时候可以动态选择。不加lock inshare mode之类的快照读就使用mvcc。否则 当前读使用next-key。mvcc的优势是不加锁,并发性高。缺点是不是实时数据。next-key的优势是获取实时数据,但是需要加锁。
-
在rr级别下,mvcc完全解决了重复读,但并不能真正的完全避免幻读,只是在部分场景下利用历史数据规避了幻读
-
要完全避免幻读,需要手动加锁将快照读调整为当前读(mysql不会自动加锁),然后mysql使用next-key完全避免了幻读,比如rr下,锁1(0,2,3,4),另一个线程的insert 3即被阻塞,在rc下,另一个线程仍然可以大摇大摆的插入,如本线程再次查询比如count,则会不一致。