深入分析MySQL:事务+MVCC的实现原理!

3,675 阅读16分钟

前言

之前,我们分析了MySQL中索引的相关知识以及explain执行计划分析,想必大家对索引已经有了基本的认识,那么这一篇,我将为大家介绍一下MySQL中事务以及MVCC相关知识

什么是事务

事务(Transaction)是由一系列对数据库中的数据进行访问与更新的操作所组成的一个程序执行单元。

在同一个事务中所进行的操作,要么都成功,要么就什么都不做。理想中的事务必须满足四大特性,这就是大名鼎鼎的ACID。

事务的ACID特性

并不是所有的事务都满足ACID特性,比如:对于Oracle和SQL Server数据库,其默认隔离级别是Read COMMITTED,就不满足I(隔离性)的要求;对于MySQL的NDB Cluster引擎来说,不满足D(持久性)的要求。

A(Atomicity)-原子性

原子性指的是数据库事务是不可分割的一部分,只有一个事务中的所有操作都成功,这个事务才算执行成功,一旦有一个操作失败,那么其他成功的操作也必须回滚。
以转账1000元场景为例,一个转账过程就是一个事务,这个事务主要包括以下两步:
1、从A账户扣除1000元
2、将B账户中增加1000元
试想,如果第一步成功了,那么第二步失败了,那就等于A的1000元钱直接消失了,相信这是任何人都不能接受的事项,所以数据库事务才需要保证原子性。

C(Consistent)-一致性

指的是在事务开始之前和事务结束之后,数据库的完整性约束都没有被破坏,事务执行的前后都是合法的数据状态。

比如我们有一张表中有一个字段name建立了一个唯一约束,那么当我们进行事务提交或者事务回滚之后,这个name必须依然保证唯一。

I(Isolation)-隔离性

隔离性就是说每个事务之间的操作应该相互隔离,互不干扰。比如说一个事务提交之前对另一个事务不可见。

隔离是一个相对抽象而复杂的概念,比如说事务之间的隔离性我们到底要隔离到哪种程度呢?所以,针对隔离,SQL92标准定义了4种隔离级别,这个放在后面事务的隔离级别中介绍。

D(Durable)-持久性

持久性这个概念就比较容易理解了,就是说事务一旦提交成功了,那么就应该是持久的,即使是数据库重启,服务器宕机等情况发生,数据都不会丢失(当然这个不能包括因为地震等自然灾害导致的存储数据的硬盘损发生不可逆的损坏)。

事务的管理

可能很多人会说自己都感知不到MySQL的事务,其实这是因为MySQL事务是默认开启了自动提交的,因此,如果要感知到事务,我们需要关闭自动提交或者显示开启事务。

事务的自动提交

查看自动提交语句:

SHOW VARIABLES LIKE 'autocommit';-- ON表示开启了自动提交
SELECT @@autocommit;-- 1表示开启了自动提交

执行如下语句关闭自动提交:

SET autocommit='OFF';
SET @@autocommit = 0;

不过需要注意的是,这种修改方式只是在当前会话窗口生效,对其他会话窗口是不生效的,MySQL几乎所有变量设置都会分成两个级别,session(会话)和global(全局)级别,默认就是session级别。

常用的事务控制语句

  • START TRANSACTION或者BEGIN:显示的开启事务。需要注意的是在存储过程中只能用START TRANSACTION开启事务,因为存储过程本来有BEGIN…END语法,两者会冲突。
  • COMMIT:提交事务。也可以写成COMMIT WORK。
  • ROLLBACK:回滚事务。也可以写成ROLLBACK WORK。
  • SAVEPOINT identifier:自定义保存点,适用于长事务,可以回滚到我们自定义的位置。
  • RELEASE SAVEPOINT identifier:删除一定保存点,如果没有保存点的时候,会报错
  • ROLLBACK TO[SAVEPOINT] identifier:回滚到指定保存点。

COMMIT和COMMIT WORK的区别

这两个都能提交一个事务,区别就在于提交事务之后的操作,同样的还有ROLLBACK和ROLLBACK WORK,主要是通过一个变量来控制:completion_type,可以执行下面的sql来查看结果:

SHOW VARIABLES LIKE '%completion_type%';

completion_type有如下三种结果:

描述
NO_CHAIN或者0默认值,此时commit和rollback等价于commit work和rollback work
CHAIN或者1此时commit work和rollback work等价于commit and chain和rollback and chain,在提交或者回滚事务之后会立刻启动一个相同隔离级别的新事务
RELEASE或者2此时commit work和rollback work等价于commit release和rollback release,在提交或者回滚事务之后会断开当前数据库连接连接

举个栗子1:

SET completion_type=1; --1
begin;--2
INSERT test2 VALUES(1,'张1');--3
commit work;--4
INSERT test2 VALUES(2,'张1');--5
select * from test2;--6
rollback;--7
select * from test2;--8

第4条语句中,我们提交了一个事务,第5条语句中我们又插入了一条数据,此时第六条语句可以查询出2条数据,接下来我们回滚,语句8再去查询就会发现只剩一条数据了,因为语句6倍回滚了,我们在语句4之后并没有显示的开启一个事务,这就说明语句4自动开启了一个新的事务。

举个栗子2:

SET completion_type=2;
begin;
INSERT test2 VALUES(3,'张1');
commit work;
select * from test2;

最后一条语句返回如下结果:
在这里插入图片描述
先提示的断开连接,然后自动重连。测试这个例子的时候用工具比如sqlyog可能会不是很明显,因为工具会自动帮忙重连,看起来就好像没断开一样,建议用命令窗口的形式测试

事务的分类

从事务的理论角度来说,我们可以把事务分为以下五大类:

扁平事务

这种是最简单也是最常用的一种事务,这种事务中的所有操作都是原子的,要么全部成功,要么什么都不做。

带有保存点的扁平事务

这种一般比较适合于长事务,事务处理到后面报错的时候,我们可以选择不全部回滚事务,而是回滚到我们自定义好的某一个保存点。如下例子:

BEGIN;
INSERT test VALUES(1,'张1');
SAVEPOINT A
INSERT test VALUES(2,'张2');
ROLLBACK TO A
COMMIT;

上面示例语句中,我么你定义了一个保存点A,然后在后面又回滚到A,这时候提交事务,那么第二条插入语句是失败的,而第一条语句是成功的。

注意:回滚到指定保存点之后,事务仍然还在活动状态,我们依然需要执行COMMIT或者ROLLBACK语句才算结束了事务

链事务

在提交一个事务之后,释放掉我们不需要的数据,将必要的数据隐式的传给下一个事务。(注意:提交事务操作和开始下一个事务操作是一个原子操作)这就意味着下一个事务能看到上一个事务的结果。

链事务可以看成带有保存点的特殊事务,他们的区别就是带有保存点的事务可以回滚到任意保存点,但是回滚之后事务仍然活跃,需要执行COMMIT或者ROLLBACK之后才结束事务,而链事务中只能回滚到最近的一个保存点(即开始事务的点)。

链事务可以通过上面的completion_type参数来实现。上文中有举例使用方法,这里就不重复举例了。

嵌套事务

嵌套事务就是说一个事务之中嵌套另一个事务,事务之间存在父子关系,子事务的提交之后并不生效,需要等到父事务提交之后才会生效。

需要注意的是MySQL原生并不支持嵌套事务,但是可以通过保存点模拟嵌套事务,只是说这么模拟的话就没有真正的嵌套事务这么灵活。

分布式事务

分布式事务通常就是在分布式环境下,多个数据库下运行不同的扁平事务。多个数据库环境下运行的扁平事务就合成了一个分布式事务。

事务的隔离级别

Read Uncommitted(未提交读)

简称RU。这种是最低的隔离级别,等于没有隔离,基本上没有数据库会使用这个级别。一个事务可以读取到其他事务未提交的数据,这种也叫做脏读。

什么是脏读?请看下面这个例子:
在这里插入图片描述
左边是事务1,先查一次,查到id为1的数据name为张三,这时候事务2又来了,把张三改成了李四,然后事务1又进行了一次查询,查出来了name为李四,那么假如这时候事务2发生了回滚,也就是name还是张三,但是事务1却读到了李四,这就是脏读。

Read Committed(已提交读)

简称RC。一个事务只能读取到其他事务已提交的数据,就是说在一个事务里面,执行同样的查询,会出现两次不一样的结果。Oracle和SQL Server数据库默认的数据库隔离级别。这种隔离级别解决了脏读问题,但是会出现不可重复读的问题。

什么是不可重复读?还是看上面那个例子,假设事务2更新之后马上就提交,然后事务1第二次查询查出来的结果还是李四,只是这次就不算是脏读了,因为事务2提交了,这种就叫不可重复读,因为事务1中两次查询同一条数据结果不一样。

Repeatable Read(可重复读)

简称RR。这种隔离级别解决了不可重复读问题,就是说在同一个事务中,执行相同的查询,结果都是一样的,但是这种级别会出现幻读问题(InnoDB引擎例外,InnoDB引擎通过间隙锁解决了幻读问题)。

什么是幻读?请看下面这个例子:
在这里插入图片描述
上面图形中,事务1进行了一个范围查询,第一次只能查出一条记录,这时候事务2来插入了一条数据,然后事务1再次执行同一个查询,这时候就能查出来两条记录,也就是多了一条,给人一种幻觉,所以称之为幻读。(InnoDB通过临键锁解决了幻读问题,想详细了解的请点击这里)

说到这里,可能有人就有疑问了,因为感觉不可重复读和幻读都是读取到已提交事务的结果,好像没什么区别?确实如此,

**不可重复读和幻读本质上是一样的,但是不可重复读针对的是更新和删除操作,而幻读仅针对插入操作。**

Serializable(串行化)

这种是隔离的最高级别,也就是说所有的事务都是串行执行的,也就不存在并发事务,脏读,可重复读和幻读问题自然也就没有了。

不同隔离级别对比

不同的隔离级别可以解决不同的问题,大致如下图:
在这里插入图片描述
对于未提交读和已提交读大家可能都很好理解,只要控制一个事务提交之后才能对另一个事务可见,但是对于可重复读,MySQL到底是如何实现即使一个事务已经提交了,还能对另一个事务不可见呢?这就是接下来我们要讲解的MVCC了。

事务隔离的实现方案

事务隔离的实现方案有两种,LBCC和MVCC

LBCC

本章节开始会涉及到一些锁的概念,这个我会在下一篇文章专门讲解锁,本文不会讲解过多锁的概念。想了解的

LBCC,基于锁的并发控制,英文全称Based Concurrency Control。这种方案比较简单粗暴,就是一个事务去读取一条数据的时候,就上锁,不允许其他事务来操作(当然这个锁的实现也比较重要,如果我们只锁定当前一条数据依然无法解决幻读问题)。

当前读

这个概念其实很好理解,MySQL加锁之后就是当前读。假如当前事务只是加共享锁,那么其他事务就不能有排他锁,也就是不能修改数据;而假如当前事务需要加排他锁,那么其他事务就不能持有任何锁。总而言之,能加锁成功,就确保了除了当前事务之外,其他事务不会对当前数据产生影响,所以自然而然的,当前事务读取到的数据就只能是最新的,而不会是快照数据(后文MVCC会解释快照读概念)。

LBCC方案中,如果我们的业务系统是读多写少的话,这种方案就会极大影响了效率,所以我们就有了另一种解决方案:MVCC。

MVCC

MVCC,多版本的并发控制,英文全称:Multi Version Concurrency Control。就是当我们在修改数据的时候,可以为这条数据创建一个快照,后面就可以直接读取这个快照。

那么MVCC具体到底是如何实现的呢?

为了实现MVCC机制,InnoDB内部为每一行添加了两个隐藏列:DB_TRX_ID和DB_ROLL_PTR(MySQL另外还有一个隐藏列DB_ROW_ID,这是在InnoDB表没有主键的时候会用来作为主键)。

DB_TRX_ID

长度为6字节,存储了插入或更新语句的最后一个事务的事务ID。

DB_ROLL_PTR

长度为7字节,称之为:回滚指针。回滚指针指向写入回滚段的undo log记录,读取记录的时候会根据指针去读取undo log中的记录。

正因为MySQL中undo log中会维护一个历史数据记录,所以我们应该养成定期提交事务的习惯,否则回滚段会越来越大,甚至占满了表空间。

快照读

快照读是针对上文的当前读而言,指的是在RR隔离级别下,在不加锁的情况下MySQL会根据回滚指针选择从undo log记录中获取快照数据,而不总是获取最新的数据,这也就是为什么另一个事务提交了数据,在当前事务中看到的依然是另一个事务提交之前的数据。

MySQL什么时候开始读取快照

我们先看看MySQL默认隔离级别RR下的一个例子(注意,test和test2两张表一开始都是空表,均只有id和name两个字段)。

  • 场景1(事务1操作数据之后再进行第一次查询):
事务1事务2
BEGIN;BEGIN;
INSERT INTO test VALUE(1,‘张三’);
COMMIT;
SELECT * FROM test WHERE id=1;
(查出id=1,name=张三)
UPDATE test SET NAME=‘李四’ WHERE id=1;
SELECT * FROM test WHERE id=1;
(查出id=1,name=张三)
COMMIT;COMMIT;
  • 场景2(事务1不进行任何操作,事务2先开始第一次查询)
事务1事务2
BEGIN;BEGIN;
SELECT * FROM test WHERE id=1;
(查出id=1,name=李四)
UPDATE test SET NAME=‘王五’ WHERE id=1;
COMMIT;
SELECT * FROM test WHERE id=1;
(查出id=1,name=李四)
COMMIT;

通过上面两个场景中我们可以得出结论:
RR隔离级别快照并不是在BEGIN就开始产生了,而是要等到事务当中的第一次查询之后才会产生快照,之后的查询就只读取这个快照数据

  • 场景3(事务2先进行一次t1表查询之后,事务1再去操作其他表t2)
事务1事务2
BEGIN;BEGIN;
SELECT * FROM test WHERE id=1;
(查出id=1,name=王五)
INSERT INTO test2 VALUE(1,‘杨过’);
COMMIT;
SELECT * FROM test2 WHERE id=1;
(空)
COMMIT;

从场景3我们可以得出结论:RR隔离级别快照并不只是针对当前所查询的数据,而是针对当前MySQL中的所有数据(跨库也一样,只要在同一个MySQL)

MVCC查询机制

MVCC机制到底如何查询的呢?假设由很多个事务同时进行,那么就会产生很多快照,查询的时候又到底是怎么做的呢?

接下来我们把抽象的概念具体化,假定DB_TRX_ID和DB_ROLL_PTR均为整型,接下来我们进行查询演示:

1、清空原先的test表,事务A插入两条数据,此时DB_TRX_ID(事务id)为1,DB_ROLL_PTR(回滚指针为null)

idname事务id回滚指针
1张三1null
2李四1null

2、这时候事务B进行了一次查询,会得到上面的结果,事务2还没提交的时候又来了事务C,事务C插入了id=3的数据,此时表中的数据如下:

idname事务id回滚指针
1张三1null
2李四1null
3王五3null

注意,这时候第3条数据的事务id为3,因为事务2也会产生一个事务id
3、这时候事务B再次进行查询,根据上面了解的,我们知道,这时候应该是查询不出王五的,所以实际上二次查询可能是这么查的:

select * from test where 事务id<=2-- 因为当前的事务id为2

4、假如这时候事务D又来了,把id=1的数据给删除了,这时候会把原数据的回滚指针记录为当前的事务id:4,所以此时数据如下:

idname事务id回滚指针
1张三14
2李四1null
3王五3null

5、回到事务B,继续查询,应该还是只有1和2两条数据,那么他可能是这么查询的:

select * from test where 事务id<=2 and (回滚指针 is null or 回滚指针 >2)

6、假如这时候又来了事务E,对第2条数据进行了更新,这时候会生产一条事务id为5的数据,并把原数据的回滚指针也同时标记为当前的事务id:5,那么会得到如下数据:

idname事务id回滚指针
1张三1null
2李四15
3王五34
2王五5null

根据上面猜测,执行下面的查询:

select * from test where 事务id<=2 and (回滚指针 is null or 回滚指针 >2)

这时候发现,查出来的数据还是只有1和2两条。

MVCC查询两大规则

综上,MVCC大致查询规则如下:
1、只查询事务id小于等于当前事务id的数据。(这里要等于是因为假如自己的事务插入了一条数据,会生成一条当前事务id的数据,所以必须包含本事务自己插入的数据)
2、只查询未删除(回滚指针为空)或者回滚指针大于当前事务id的数据。(这里不能等于是因为假如自己的事务删除了一条数据,会生成数据的回滚指针为当前事务id,所以必须排除掉自己删除的数据)

当然,上面规则只是简化了,实际查询远比这里复杂,只是希望借助这种简单化的概念可以帮助大家更好的理解MVCC工作机制。