最近在看关于mvcc的知识,看了《高性能mysql》不得不吐槽里面的相关讲解不靠谱,在查找了N多文章后,整理如下

MVCC(多版本并发控制)

MVCC (Multiversion Concurrency Control) 中文全程叫多版本并发控制,是现代数据库(包括 MySQL、Oracle、PostgreSQL 等)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能。

先回顾一下事务的隔离级别

1 未提交读(READ UNCOMMITTED)
在这个隔离级别下,事务的修改即使没有提交,对其他事务也是可见的。事务可以读取未提交的数据,这也被称之为脏读。这个级别会带来很多问题,从性能上来说,READ UNCOMMITTED不会比其他的级别好太多,但是却会带来很多问题,除非真的有非常必要的理由,在实际应用中一般很少使用。

2 提交读(REDA COMMITED)
大多数数据系统的默认隔离级别都是REDA COMMITED(MySql不是,mysql默认可重复读),REDA COMMITED满足前面提到的隔离性的简单定义:一个事务开始时,只能看到已经提交的事务所做的修改。
换句话说,一个事物从开始直到提交前,所做的修改对其他事务不可见。这个级别有时候也叫做不可重复读,因为执行两次相同的查询可能会得到不同的结果。不可重复度强调对同一条数据的两次读取

3 可重复读(REPEATABLE READ)
REPEATABLE READ解决了脏读以及不可重复度的问题。该级别保证了同一个事务多次读取同样记录的结果是一致的。但是理论上,可重复度还是无法解决另外一个幻读的问题。

4 可串行化(SERIALIZABLE)
SERIALIZABLE是最高的隔离级别,它通过强制事务串行执行,避免前面说的幻读。简单来说SERIALIZABLE会在读取的每一行数据上都加锁,所以可能会导致大量的超时和锁争用的问题。实际应用中也很少使用这个隔离级别,只有在非常需要确保数据一致性而且可以接受没有并发的情况下,才考虑此级别。

InnoDB 相比 MyISAM 有两大特点,一是支持事务二是支持行级锁,事务的引入带来了一些新的挑战。

相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持可以支持更多的用户。

但并发事务处理也会带来一些问题,主要包括以下几种情况:

1 更新丢失(Lost Update):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题 —— 最后的更新覆盖了其他事务所做的更新。如何避免这个问题呢,最好在一个事务对数据进行更改但还未提交时,其他事务不能访问修改同一个数据。
2 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务未提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些尚未提交的脏数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做 “脏读”。
3 不可重复读(Non-Repeatable Reads):一个事务在读多次读取某些数据期间,这些数据已经发生了改变、或某些记录已经被别的事务删除了!这种现象叫做“不可重复读”。
4 幻读(Phantom Reads):所谓幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,就会产生幻行
可重复读跟幻读的区别在于,「前者是数据发生了变化,后者是数据的行数发生了变化」。

实现隔离机制的方法主要有两种: 加读写锁
一致性快照读,即 MVCC

快照读:简单的select操作(不包括 select … lock in share mode, select … for update),根据生成的readView(下文介绍)保证数据可见性

当前读:select … lock in share mode、select … for update、insert、update、delete 都是当前读,当你执行这几个操作的时候默认会执行当前读,也就是会读取最新的记录,当前读的实现方式:next-key锁(行记录锁+Gap间隙锁)

间隙锁:只有在Read Repeatable、Serializable隔离级别才有,就是锁定范围空间的数据,假设id有3,4,5,锁定id>3的数据,是指的4,5及后面的数字都会被锁定,因为此时如果不锁定没有的数据,例如当加入了新的数据id=6,就会出现幻读,间隙锁避免了幻读。

  1. 对主键或唯一索引,如果当前读时,where条件全部精确命中(=或者in),这种场景本身就不会出现幻读,所以只会加行记录锁(行锁,属于排它锁)。
  2. 没有索引的列,当前读操作时,会加全表gap锁,生产环境要注意。
  3. 非唯一索引列,如果where条件部分命中(>、<、like等)或者全未命中,则会加附近Gap间隙锁。例如,某表数据如下,非唯一索引2,6,9,9,11,15。如下语句要操作非唯一索引列9的数据,gap锁将会锁定的列是(6,11],该区间内无法插入数据。

mvcc原理分析

InnoDB 中的 MVCC本文聚焦于 MySQL 中的 MVCC 实现,从 《高性能 MySQL》一书中对 MVCC 的介绍可知:
MySQL 中 InnoDB 引擎支持 MVCC应对高并发事务, MVCC 比单纯的加行锁更有效, 开销更小MVCC 在读已提交(Read Committed)和可重复读(Repeatable Read)隔离级别下起作用MVCC 既可以基于乐观锁又可以基于悲观锁来实现InnoDB

MVCC 实现原理InnoDB 中 MVCC 的实现方式为:每一行记录都有两个隐藏列:DATA_TRX_ID、DATA_ROLL_PTR(如果没有主键,则还会多一个隐藏的主键列)。

DATA_TRX_ID记录最近更新这条行记录的事务 ID,大小为 6 个字节

当一个事务对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:

对于只读事务来说,只有在它第一次对某个用户创建的「临时表执行增、删、改操作」时才会为这个事务分配一个事务id,否则的话是不分配事务id的。

对于读写事务来说,只有在它「第一次对某个表(包括用户创建的临时表)执行增、删、改操作」时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id。

DATA_ROLL_PTR表示指向该行回滚段(rollback segment)的指针,大小为 7 个字节,InnoDB 便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在 undo 中都通过链表的形式组织。

DB_ROW_ID行标识(隐藏单调自增 ID),大小为 6 字节,MySQL会优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。也就是说只有在表中既没有定义主键,也没有申明唯一索引的情况MySQL才会添加这个隐藏列。

另外,每条记录的头信息(record header)里都有一个专门的 bit(deleted_flag)来表示当前记录是否已经被删除。

ReadView(快照)

对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;
对于使用SERIALIZABLE隔离级别的事务来说,MySQL规定使用加锁的方式来访问记录;

对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:「需要判断一下版本链中的哪个版本是当前事务可见的」。

为了解决这个问题,MySQL提出了一个ReadView(快照)的概念,
在Select操作前会为当前事务生成一个快照,注意是select之前,而不是开启事务的时候,然后根据快照中记录的信息来判断当前记录是否对事务是可见的,如果不可见那么沿着版本链继续往上找,直至找到一个可见的记录。

已提交读和可重复读的区别就在于它们生成ReadView的策略不同:
在读已提交下,一致读快照(Read View)是在每次SELECT后都会生成最新的Read View,即每次SELECT都能读取到已COMMIT的数据,就会存在不可重复读、幻读 现象,而可重复读第一次SELECT发起时建立,之后不会再发生变化,所以是可重复读

ReadView(快照)中包含了下面几个关键属性:

m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。注意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
creator_trx_id:表示生成该ReadView的事务的事务id。我们前边说过,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0,当生成快照后,会通过下面这个流程来判断该记录对当前事务是否可见
在这里插入图片描述

在根据当前数据库中运行中的读写事务id,会去生成一个ReadView。然后根据要读取的数据记录中的事务id(方便区别,记为r_trx_id)跟ReadView中保存的几个属性做如下判断

1 如果被访问版本的r_trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

2 如果被访问版本的r_trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。

3 如果被访问版本的r_trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。

4 如果被访问版本的r_trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下r_trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

5 如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

常见误点

1 ReadView是第一次查询之前生成的,并不是开始事务的时候,在读已提交下,一致读快照(Read View)是在每次SELECT后都会生成最新的Read View,即每次SELECT都能读取到已COMMIT的数据,就会存在不可重复读、幻读 现象,而可重复读第一次SELECT发起时建立,之后不会再发生变化,所以是可重复读
2 只读事务是没有事务id的,很多文章都是用只读事务来做例子,显然是错误的
3 只有在「第一次对某个表(包括用户创建的临时表)执行增、删、改操作」时才会为这个事务分配一个事务id
4 max_trx_id表示生成ReadView时系统中应该分配给下一个事务的id,而不是trx_ids中最大值
5.排它锁是不允许别的事务在相同记录添加任何的其他锁,但是普通的select还是可以读取被锁住的数据的
6.现在你已经对mvcc和当前读有了更加深刻的认识,我们再次回顾一下可重复读跟幻读的区别在于:「前者是数据本身发生了变化(通过rr模式ReadView的生成规则来保证每次读取的是同一时刻的快照),后者是数据的行数发生了变化(通常是执行增删改等当前读的时候才会感知到这个事情,如果没有采用间隙锁就会感知到快照以外的增删改变化)」。

推荐阅读

https://mp.weixin.qq.com/s/JB8PrMRlj2OJOdtarYqCdg
https://blog.csdn.net/u014494148/article/details/137713701

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐