通俗易懂的MySQL事务及MVCC原理,我先收藏了!

一、事务简介与四大特性

事务指的是一组命令操作,在执行的过程中,要么全部成功,要么全部失败。

由引擎层支持事务,MyISAM就不支持事务,而InnoDB是支持事务的。

事务具有以下四大特性(ACID):

  • 原子性(Atomicity):指事务不可分割,要么全部成功,要么全部失败,不可能存在部分成功或部分失败的情况。如果执行某一条语句失败后,将会触发之前所有执行过的语句的回滚,因此靠的是undo log。
  • 一致性(Consistency):在事务执行前后,数据的完整性没有遭到破坏。一致性是mysql追求的最终目标,需要数据库层面与应用层面同时来维护。需要先满足原子性、隔离性与持久性,同时也需要应用层面做保障,即在应用层面对数据进行检验。
  • 隔离性(Isolation):事务之前是隔离的,并发执行的事务之间不存在互相影响,mysql通过锁以及MVCC来保证隔离性。
  • 持久性(Durability):事务一旦提交,那么对数据的操作就是永久性的,即使接下来数据库宕机也不会有影响。mysql是通过redo log来实现宕机恢复的,而binlog主要是用来误删恢复与主从复制的。

简单罗列了一下4种特性以及对应的实现方式,有关ACID详细的实现原理,会另开篇幅,小伙伴们记得关注我哈!


 二、脏读、不可重复读与幻读

当事务存在并发时,就会产生以下问题。

脏读

即读取到别的事务未提交的数据。

A事务读取B事务尚未提交的数据,此时如果B事务发生错误并执行回滚操作,那么A事务读取到的数据就是脏数据。

就好像原本的数据比较干净、纯粹,此时由于B事务更改了它,这个数据变得不再纯粹。

这个时候A事务立即读取了这个脏数据,但事务B良心发现,又用回滚把数据恢复成原来干净、纯粹的样子,而事务A却什么都不知道,最终结果就是事务A读取了此次的脏数据,称为脏读。

这种情况常发生于转账与取款操作中

时间顺序

转账事务

取款事务

1

开始事务

2

开始事务

3

查询账户余额为2000元

4

取款1000元,余额被更改为1000元

5

查询账户余额为1000元(产生脏读)

6

取款操作发生未知错误,事务回滚,余额变更为2000元

7

转入2000元,余额被更改为3000元(脏读的1000+2000)

8

提交事务

备注

按照正确逻辑,此时账户余额应该为4000元

不可重复读

即某个事务前后多次读取,数据内容不一致。

事务A在执行读取操作,由整个事务A比较大,前后读取同一条数据需要经历很长的时间 。

而在事务A第一次读取数据,比如此时读取了小明的年龄为20岁,事务B执行更改操作,将小明的年龄更改为30岁,此时事务A第二次读取到小明的年龄时,发现其年龄是30岁,和之前的数据不一样了,也就是数据不重复了,系统不可以读取到重复的数据,成为不可重复读。

时间顺序

事务A

事务B

1

开始事务

2

第一次查询,小明的年龄为20岁

3

开始事务

4

其他操作

5

更改小明的年龄为30岁

6

提交事务

7

第二次查询,小明的年龄为30岁

备注

按照正确逻辑,事务A前后两次读取到的数据应该一致

幻读

即某个事务前后多次读取,读到的数据总量不一致。

事务A在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务B执行了新增数据的操作并提交后,这个时候事务A读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,称为幻读。

时间顺序

事务A

事务B

1

开始事务

2

第一次查询,数据总量为100条

3

开始事务

4

其他操作

5

新增100条数据

6

提交事务

7

第二次查询,数据总量为200条

备注

按照正确逻辑,事务A前后两次读取到的数据总量应该一致


三、事务隔离级别

事务隔离级别,就是在不同程度上解决以上的问题。

有四种隔离级别,分别是

  • 读未提交(Read Uncommitted)
  • 读已提交(Read Committed)
  • 可重复读(Repeatable Read)
  • 串行化(Serializable)

读未提交

在这种隔离级别下,所有事务能够读取其他事务未提交的数据。

读取其他事务未提交的数据,会造成脏读。因此在该种隔离级别下,不能解决脏读、不可重复读和幻读。

读未提交可能会产生脏读的现象,那么怎么解决脏读呢?那就是使用读已提交。

读已提交

在这种隔离级别下,所有事务只能读取其他事务已经提交的内容。

能够彻底解决脏读的现象。但在这种隔离级别下,会出现一个事务的前后多次的查询中却返回了不同内容的数据的现象,也就是出现了不可重复读。

这是大多数数据库系统默认的隔离级别,例如Oracle和SQL Server,但mysql不是。

已提交可能会产生不可重复读的现象,我们可以使用可重复读。

可重复读

在这种隔离级别下,所有事务前后多次的读取到的数据内容是不变的。

也就是某个事务在执行的过程中,不允许其他事务进行update操作,但允许其他事务进行add操作,造成某个事务前后多次读取到的数据总量不一致的现象,从而产生幻读。

这才是mysql的默认事务隔离级别

可重复读依然会产生幻读的现象,此时我们可以使用串行化来解决。

串行化

在这种隔离级别下,所有的事务顺序执行,所以他们之间不存在冲突,从而能有效地解决脏读、不可重复读和幻读的现象。

但是安全和效率不能兼得,串行化会大大降低数据库的性能,一般不使用这种级别。

下面用一张表格来表示他们能够解决的问题,x代表未解决,√代表能够解决。

隔离级别

脏读

不可重复读

幻读

读未提交(Read Uncommitted)

×

×

×

读已提交(Read Committed)

×

×

可重复读(Repeatable Read)

×

可串行化(Serializable)

当然,以上所说的隔离级别及当前级别存在的问题只是一种规范,不同的数据库厂商可以有不同的实现。

例如在mysql的可重复读的级别上,使用临键锁的方式就已经解决了幻读的问题。


四、MVCC

mysql为了实现以上隔离级别,提出了LBCC(Lock-Based Concurrent Control,基于锁的并发控制)与MVCC(Multi-Version Concurrent Control,基于多版本的并发控制)。

在LBCC中,读写冲突,会使用诸如记录锁、间隙锁与临键锁等锁来实现数据的并发安全,因此读写性能不高。关于锁的分类,可以参考我的另外一篇文章谈谈锁的类型

在MVCC中,读写不冲突,记录每一行的多个版本,来避免在多个事务之间的竞争。以空间换时间的思路,极大地提高了读写性能。

MVCC主要靠undo log版本链与ReadView来实现。

先对undo log有一个基本的认识

Undo log

undo log主要用于事务回滚时恢复原来的数据

mysql在执行sql语句时,会将一条逻辑相反的日志保存到undo log中。因此,undo log中记录的也是逻辑日志。

当sql语句为insert时,会在undo log中记录本次插入的主键id。等事务回滚时,delete此id即可。

当sql语句为update时,会在undo log中记录修改前的数据。等事务回滚时,再执行一次update,得到原来的数据。

当sql语句为delete时,会在undo log中记录删除前的数据。等事务回滚时,insert原来的数据即可。

数据库事务四大特性中的原子性,即事务具有不可分割性,要么全部成功,要么全部失败,其底层就靠undo log实现。在某一步执行失败时,会对之前事务的语句进行回滚。

对数据库中的日志完全不熟悉的话,可以看我的另外一篇文章数据库日志——binlog、redo log、undo log扫盲


行的隐藏列

在数据库中的每一行上,除了存放真实的数据以外,还存在着3个隐藏列——row_id、trx_id与roll_pointer。

row_id,行号

如果当前表有整数类型的主键,则row_id就是主键的值。

如果没有整数类型的主键,则mysql会按照字段顺序选择一个非空的整数类型的唯一索引作为row_id。

如果mysql没有找到,则会自动生成一个自动增长的整数作为row_id。

那row_id和今天的MVCC有什么关系呢?

不能说一点没有吧,只能说毫无关系。

trx_id,事务号

当一个事务开始执前,mysql会为这个事务分配一个全局自增的事务id。

之后该事务对当前行进行的增、删、改操作时,都会将自己的事务id记录到trx_id中。

roll_pointer,回滚指针

事务对当前行进行改动时,会将旧数据写入进undo log中,再将新数据写入当前行,且当前行的roll_pointer指向刚才那个undo log,因此可以通过roll_pointer找到该行的前一个版本。

当一直有事务对该行改动时,就会一直生成undo log,最终将会形成undo log版本链


Undo log版本链

一开始,我们使用以下语句创建一个stduent表

  1. CREATE TABLE `student` (
  2. `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
  3. `name` VARCHAR ( 255 ) NOT NULL,
  4. `age` INT ( 11 ) NOT NULL,
  5. PRIMARY KEY ( `id` ) USING BTREE
  6. ) ENGINE = INNODB;

现在开启第1个事务,事务id为1,执行以下插入语句。

INSERT INTO student VALUES ( 1, "a", 24 );

那么当前行的一个示意图如下:

 因为该数据是新插入的,因此它的roll_pointer指向的undo log为空。

接着开启第2个事务,分配的事务id是2,执行以下修改命令。

UPDATE student SET NAME = 'b' WHERE id = 1;

现在的示意图变为:

当开启第3个事务,分配到事务id是3,执行以下修改命令。

UPDATE student SET age = 25 WHERE id = 1;

示意图变为:

 每一个事务对该行改动时,都会生成一个undo log,用于保存之前的版本,之后再将新版本的roll_pointer指向刚才生成的undo log。

因此roll_pointer可以将这些不同版本的undo log串联起来,形成undo log版本链。


ReadView

首先需要理解一下快照读与当前读

快照读:简单的select查询,即不包括 select ... lock in share mode, select ... for update,可能会读到数据的历史版本。

当前读:以下语句都是当前读,总是读取最新版本,会对读取的最新版本加锁。

  • select ... lock in share mode
  • select ... for update
  • insert
  • update
  • delete

在事务执行每一个快照读或事务初次执行快照读时,会生成一致性视图,即ReadView。

ReadView的作用是,判断undo log版本链中的哪些数据对当前事务可见。

ReadView包含以下几个重要的参数:

m_ids

在创建ReadView的那一刻,mysql中所有未提交的事务id集合。

min_trx_id

m_ids中的最小值

max_trx_id

mysql即将为下一个事务分配的事务id,并不是m_ids中的最大值。

creator_trx_id

即创建此ReadView的事务id

简要的示意图如下:

那么事务在执行快照读时,可以通过以下的规则来确定undo log版本链上的哪个版本数据可见。

如果当前undo log的版本的trx_id<min_trx_id,说明该版本对应的事务在生成ReadView之前就已经提交了,因此是可见的。

如果当前undo log的版本的trx_id≥max_trx_id,说明该版本对应的事务在生成ReadView之后才开始的,因此是不可见的。

如果当前undo log的版本的trx_id∈[min_trx_id,max_trx_id),如果在这个范围里,还要判断trx_id是否在m_ids中:

        在m_ids中,说明版本对应的事务未提交,因此是不可见的。

        不在m_ids中,说明版本对应的事务已经提交,因此是可见的。

如果当前undo log的版本的trx_id=creator_trxt_id,说明事务正在访问自己修改的数据,因此是可见的。

当undo log版本链表的头结点数据被判定为不可见时,则利用roll_pointer找到上一个版本,再进行判断。如果整个链表中都没有找到可见的数据,则代表当前的查询找不到数据。


MVCC在四种隔离级别下的区别

在Read Uncommitted级别下,事务总是读取到最新的数据,因此根本用不到历史版本,所以MVCC不在该级别下工作。

在Serializable级别下,事务总是顺序执行。写会加写锁,读会加读锁,完全用不到MVCC,所以MVCC也不在该级别下工作。

真正和MVCC兼容的隔离级别是Read Committed(RC)与Repeatable Read(RR)

MVCC在RC与RR级别下的区别,在于生成ReadView的频率不同。

在RC级别下,当前事务总是希望读取到别的事务已经提交的数据,因此当前事务事务会在执行每一次快照读的情况下都会去生成ReadView,实时更新m_ids,及时发现那些已经提交的事务。

在RR级别下,当前事务当然也能够读取到别的事务已经提交的数据,但为了避免不可重复读,因此只会在执行第一次快照读的情况下去生成ReadView,之后的快照读会一直沿用该ReadView。

举个栗子🌰:

在RC级别下

一开始,事务id为1的事务往表里插入了一条数据,版本链如下:

 这个时候,开启事务id为2的事务,关闭自动提交模式。先执行一次select *查询,生成的ReadView如下 

m_ids={2},min_trx_id=2,max_trx_id=3,creator_trx_id=2

由于该条数据的trx_id<min_trx_id,说明该版本对应的事务在生成ReadView之前就已经提交了,因此是可见的。

因此,事务2能直接查到该数据。

现在开启事务3,事务id为3,将该条数据的name改为b,并自动提交,版本链如下:

这个时候,事务2再次select *查询,由于处于RC级别下,会再次生成ReadView,此时的ReadView如下:

m_ids={2},min_trx_id=2,max_trx_id=4,creator_trx_id=2

由于最新版本的trx_id∈[2,4)且trx_id不在m_ids中,说明该版本的数据已经提交,因此是可见的,所以事务2能查到最新的数据。

而处于RR级别下:

事务2再次select *查询时,不会生成ReadView,而是沿用第一次生成的ReadView:

m_ids={2},min_trx_id=2,max_trx_id=3,creator_trx_id=2

由于最新版本的trx_id≥max_trx_id,说明该版本对应的事务在生成ReadView之后才开始的,因此是不可见的。

所以沿着roll_pointer找到上一个版本,上一个版本的trx_id<min_trx_id,说明该版本对应的事务在生成ReadView之前就已经提交了,因此是可见的。

所以,事务2只能查询到旧版本的数据,两次的查询一致,避免了不可重复读。

posted @ 2023-01-04 12:08  DiligentCoder  阅读(182)  评论(0编辑  收藏  举报