[Mysql]MVCC

多版本并发控制 MVCC

MVCC 只在 读取已提交(Read Committed)和 可重复读(Repeatable Read)两个事务级别下有效,
依靠ReadView,undo log,记录的隐藏字段实现,
用一句话概括MVVC的原理就是,在创建事务或者select的时候生成readview快照,只能读取快照允许查看的数据,以及特定版本的数据,

记录的隐藏字段#

每行记录除了自定义的字段外,还有数据库隐式定义的 DB_TRX_ID, DB_ROLL_PTR,DB_ROW_ID 等字段
DB_TRX_ID,6 byte,最近修改(修改/插入)事务 ID:记录 创建这条记录 / 最后一次修改该记录 的 事务 ID
DB_ROLL_PTR,7 byte,回滚指针,指向这条记录 的 上一个版本(存储于 rollback segment 里)
DB_ROW_ID,6 byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以DB_ROW_ID产生一个聚簇索引
还有一个删除 flag 隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除 flag 变了

Undo log#

对MVCC有意义的是update undolog,当我们对一条数据进行更新时,不是直接覆盖旧的记录,而是把旧的记录写到undolog日志中,并把新的数据中的隐藏指针指向旧数据,这样根据新的数据就可以找到旧的数据。

ReadView#

readview是一个快照,当我们进行查询操作时,我们生成一个快照,这个快照可以告诉我们哪些数据不能读,哪些数据可以读

ReadView的组成:

up_limit_id:
The read should see all trx ids which are strictly smaller (<) than this value.
In other words, this is the low water mark".

low_limit_id:
The read should not see any transaction with trx id >= this value.
In other words, this is the “high water mark”.

m_ids:
Set of RW transactions that was active when this snapshot was taken.

up_limit_id:最先开始的事务,该SQL启动时,当前事务链表中最小的事务id编号,也就是当前系统中创建最早但还未提交的事务
low_limit_id:最后开始的事务,该SQL启动时,当前事务链表中最大的事务id编号,也就是最近创建的除自身以外最大事务编号
m_ids:当前活跃事务ID列表,所有事务链表中事务的id集合

m_ids: 指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。(* 每次生成新Read View时,是会更新的)

min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。

max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;

一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。

注:ID越小,事务开始的越早;ID越大,事务开始的越晚

OK,ReadVeiw核心数据结构如上所示,我们来解读一下两个核心字段low_limit_id与up_limit_id。

1、下面所说的db_trx_id,是来自于数据行中的db_trx_id字段,并非开启了一个事务分配的ID,分配的事务ID只有操作了数据行,才会更新数据行中的db_trx_id字段
2、ReadView是与SQL绑定的,而并不是事务,所以即使在同一个事务中,每次SQL启动时构造的ReadView的up_trx_id和low_trx_id也都是不一样的

up_limit_id表示“低水位”,即当时活跃事务列表的最小事务id(最早创建的事务),如果读取出来的数据行上的的db_trx_id小于up_limit_id,则说明这条记录的最后修改在ReadView创建之前,因此这条记录可以被看见。

if (trx_id < view->up_limit_id) {
	return(TRUE);
}

low_limit_id表示“高水位”,即当前活跃事务的最大id(最晚创建的事务),如果读取出来的数据行上的的db_trx_id大于low_limit_id,则说明这条记录的最后修改在ReadView创建之后,因此这条记录肯定不可以被看见。

if (trx_id > view->low_limit_id) {
	return(FALSE);
}

如果读取出来的数据行上的的db_trx_id在low_limit_id和up_limit_id之间,则查找该数据上的db_trx_id是否在ReadView的m_ids列表中:

  • 如果存在,则表示这条记录的最后修改是在ReadView创建之时,被另外一个活跃事务所修改,所以这条记录也不可以被看见。
  • 如果不存在,则表示这条记录的最后修改在ReadView创建之前,所以可以看到。

不同的事务隔离级别下,生成ReadView的时机则各不相同,下面我们分别来看看一下RR与RC的ReadView。

REPEATABLE READ下的ReadView生成

每个事务首次执行SELECT语句时,会将当前系统所有活跃事务拷贝到一个列表中生成ReadView。每个事务后续的SELECT操作复用其之前生成的ReadView。UPDATE,DELETE,INSERT对一致性读snapshot无影响。

示例:事务A,B同时操作同一行数据
若事务A的第一个SELECT在事务B提交之前进行,则即使事务B修改记录后先于事务A进行提交,事务A后续的SELECT操作也无法读到事务B修改后的数据。
若事务A的第一个SELECT在事务B修改数据并提交事务之后,则事务A能读到事务B的修改。
针对RR隔离级别,在第一次创建ReadView后,这个ReadView就会一直持续到事务结束,也就是说在事务执行过程中,数据的可见性不会变,所以在事务内部不会出现不一致的情况。

READ COMMITED下的ReadView生成

每次SELECT执行,都会重新将当前系统中的所有活跃事务拷贝到一个列表中生成ReadView。针对RC隔离级别,事务中的每个查询语句都单独构建一个ReadView,所以如果两个查询之间有事务提交了,两个查询读出来的结果就不一样。

总结一下:

  • RC的本质:每一条SELECT都可以看到其他已经提交的事务对数据的修改,只要事务提交,其结果都可见,与事务开始的先后顺序无关。
  • RR的本质:第一条SELECT生成ReadView前,已经提交的事务的修改可见。

从这里可以看出,在InnoDB中,RR隔离级别的效率是比RC隔离级别的高。

此外,针对RU隔离级别,由于不会去检查可见性,所以在一条SQL中也会读到不一致的数据。
针对串行化隔离级别,InnoDB是通过锁机制来实现的,而不是通过多版本控制的机制,所以性能很差。

参考资料
参考资料

幻读#

官方对幻读的定义

The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times.
For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
By default, InnoDB operates in REPEATABLE READ transaction isolation level.
In this case, InnoDB uses next-key locks for searches and index scans, which prevents phantom rows (see Section 15.7.4, “Phantom Rows”).

MVVC能防止幻读吗#

让我们直接来看实验

image

事务A 事务B
查表,发现有四条数据
插入一条本不存在的数据,成功
查表,仍然是四条数据
插入一条和右边相同的数据,发现不能插入,提示已经有这个数据了

左边先查表,发现有四条数据,右边插入一条本不存在的数据,成功,右边事务提交
左边再查表,仍然是四条数据,这是不是说明没有幻读呢?并不是
左边我们再插入一条和右边相同的数据,发现不能插入,提示已经有这个数据了,说明还是读到了这个本不应该存在的数据

再来看另一个实验
image

事务A 事务B
查表,发现有五条数据
插入一条本不存在的数据,成功
再查表,仍然是五条数据
更新右边插入的这条数据,发现成功更新

为什么会这样呢?

MVVC本身有一定的缺陷,MVVC的原理是创建一个可见范围的视图,我只去读这个视图里的内容,但是有些情况下我们不能只看这个视图里的内容,比如说,我想插入一个数据,我不能只看我的视图里有没有这条数据,我必须看最新的数据里有没有这条数据,这样就破坏了MVVC的原则,为了弥补MVVC本身的缺陷,我们想出了一些弥补措施,非常简单粗暴,我们禁止出现一些操作,例如下面这种情况
image

事务A 事务B
查看表,有六条数据
插入一条数据,但是发现被阻塞,无法插入

这就是因为在左边查看表的命令中,我们使用了当前读,加上了间隙锁,这时表里的数据之间间隙无法再进行插入操作,直接禁止这种操作,可谓是简单粗暴了,请注意这里我演示的时候是直接查看了整张表,所以全部的间隙都加上了锁,下面我们演示更精细的情况

image

事务A 事务B
以当前读操作查看了处于(10,15)之间一条本不存在的数据11
插入处于(10,15)之间一条本不存在的数据12,失败

左边以当前读操作查看了处于(10,15)之间一条本不存在的数据11,这时(10,15)之间的就上了锁,阻塞了任何处于(10,15)之间数据的插入。

上面我们读取的都是本不存在的数据,下面我们看给已经存在的数据加间隙锁的效果。

image

事务A 事务B
查看数据,有五条
查看Id为10的数据,并上共享锁
间隙插入操作并没有被阻塞

难道说共享锁并没有变成间隙锁?

继续研究

image

  • 左边对id大于10的数据进行当前读,右边想要插入ID等于11的数据被阻塞
  • 左边再次当前读,没有发生幻读

继续研究

image

  • 左边对id为10的数据上了for update, next-key lock,结果发现右边对(6,10)(10,19)的插入,以及10本身的更新操作都被阻塞。

继续研究

image

左边上了for update,但是右边依旧可以在间隙之间插入数据,我怀疑没有加间隙锁

因为ReadView只保证了快照读,也就是select ...操作的正确性,而没有保证insert,update,delete,以及强制读取最新数据的select ... lock in share modeselect ... for update这几个当前读操作的正确性

next-key lock

A next-key lock is a combination of a record lock on the index record 
and a gap lock on the gap before the index record.

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. 
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.

快照读#

当前读#

顾名思义,当前读就是读的是当前时刻已提交的数据,快照读就是读的是快照生成时候的数据。

间隙锁#

MySQL的大多数事务型存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。

可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。

MVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。如果之前没有这方面的概念,这句话听起来就有点迷惑。熟悉了以后会发现,这句话其实还是很容易理解的。

前面说到不同存储引擎的MVCC实现是不同的,典型的有乐观(optimistic)并发控制悲观(pessimistic)并发控制。下面我们通过InnoDB的简化版行为来说明MVCC是如何工作的。

InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看一下在REPEATABLE READ 隔离级别下,MVCC具体是如何操作的。

SELECT
InnoDB会根据以下两个条件检查每行记录:

  • InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
  • 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
    只有符合上述两个条件的记录,才能返回作为查询结果。

INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号。

DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除标识。

UPDATE
InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。

保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。

MVCC只在REPEATABLE READ 和READ COMMITTED 两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容 (4) ,因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁。

作者:Esofar

出处:https://www.cnblogs.com/DCFV/p/18285735

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Duancf  阅读(20)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
more_horiz
keyboard_arrow_up light_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示