MySQL的事务、隔离级别和MVCC原理

2022/1/21

# 事务

# 什么是事务?

数据库事务指的是一组sql语句组成的数据库逻辑处理单元,在这组的sql操作中,要么全部执行成功,要么全部执行失败。

例子:转账。用户A要转账给用户B,要经历如下过程:用户A转账扣钱->用户B收账加钱,为了保证数据的一致性,要采用事务。两步操作都要成功才能成功,只要有一步出错,全都执行失败,即回滚。

# 事务的特性(ACID)

  • 原子性(Atomicity):事务的原子性操作,数据操作要么全部成功,要么全部失败。
    • 基于日志的Redo/Undo机制
  • 一致性(Consistent):事务执行前后的状态要一致,可理解为数据一致性。
  • 隔离性(Isalotion):事务之前相互隔离,不受影响,与事务的隔离级别密切相关。
    • 数据库系统提供-定的隔离机制,事务处理过程中的中间状态对外部是不可见的,保证事务在不受外部并发操作影响的“独立”环境执行。
  • 持久性(Durable):事务完成之后,它对于数据的修改是永久性的(持久化到数据库),即使出现系统故障也能够保持。

原子性、隔离性、持久性都是为了保障一致性而存在的,一致性也是最终的目的。

# 什么是Redo/Undo机制?

Redo log用来记录某数据块被修改后的值,可以用来恢复事务已提交但还未持久化到数据库的数据;Undo log是用来记录数据更新前的值,保证数据更新失败能够回滚。

场景:假如某个时刻数据库崩溃,在崩溃之前有事务A和事务B在执行,事务A已经提交,而事务B还未提交。当数据库重启进行 crash-recovery 时,就会通过Redo log将已经提交事务的更改写到数据文件,而还没有提交的就通过Undo log进行roll back。

# 什么是脏读?幻读?不可重复读?(事务可能导致的问题)

# 脏读

脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了并一定最终存在的数据,这就是脏读。

一个事务读到了另一个未提交事务修改的数据。 回滚造成的影响。

# 不可重复读

对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的,可能会受到其他事务的影响,比如其他事务改了这批数据并提交了。通常针对数据**更新(UPDATE)**操作。

一个事务修改了另一个未提交事务读取的数据。 再次读取数据发生变更。

# 幻读

幻读是针对数据插入(INSERT 操作来说的。假设事务A修改了某些行的数据,但未提交,此时事务B插入了与事务A更改前记录相同的记录行,并先于事务A提交。那么在事务A查询时,会发现好像刚才更改对某些数据未起作用,但其实是事务B刚刚插入进来的,感觉除了幻觉,称之为幻读。

一个事务根据搜索条件读出了一批数据,该事务未提交,但是另一个事务写入了(增删改)符合条件的记录,再次读取发现数据发生变更

# 可重复读(正常情况)

可重复读指的是在同一个事务内,最开始读到的数据和事务结束前的任何时刻读到的同一批数据都是一致的。通常针对数据**更新(UPDATE)**操作。

# 事务隔离级别

# 隔离级别解决的问题

事务隔离其实就是为了解决上面提到的脏读、不可重复读、幻读这几个问题。

隔离级别 脏读 不可重复读 幻读
读未提交(READ UNCOMMITTED) 可能 可能 可能
读提交 (READ COMMITTED) 不可能 可能 可能
可重复读 (REPEATABLE READ) 不可能 不可能 可能
串行化 (SERIALIZABLE) 不可能 不可能 不可能

从上往下,隔离强度逐渐增强,性能逐渐变差。采用哪种隔离级别要根据系统需求权衡决定,其中,可重复读是 MySQL 的默认级别。

# 如何设置隔离级别

查看当前数据库的隔离级别

#
查看事务隔离级别 5.7.20 之后
show variables like 'transaction_isolation';
SELECT @@transaction_isolation;

#
5.7.20 之前
SELECT @@tx_isolation;
show
variables like 'tx_isolation';

#结果+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

查询当前有多少事务正在运行

select *
from information_schema.innodb_trx;
1
2

修改数据库的隔离级别

set
[作用域] transaction isolation level [事务隔离级别]SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
1
2
3
4
  • 作用域中GLOBAL 是全局的,而 SESSION 只针对当前回话窗口。
  • 隔离级别是 {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE} 这四种,不区分大小写。

例子:比如下面这个语句的意思是设置全局隔离级别为读提交级别。

set global transaction isolation level read committed;
1

# 隔离级别分析

建立一张表用来测试

CREATE TABLE `user`
(
    `id`   int(11) NOT NULL AUTO_INCREMENT,
    `name` varchar(30) DEFAULT NULL,
    `age`  tinyint(4)  DEFAULT NULL,
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 2
  DEFAULT CHARSET = utf8mb4
1
2
3
4
5
6
7
8
9

初始只有一条记录:

初始user表数据

# 读未提交

MySQL 事务隔离其实是依靠锁来实现的,加锁自然会带来性能的损失。而读未提交隔离级别是不加锁的,所以它的性能是最好的,没有加锁、解锁带来的性能开销。但有利就有弊,这基本上就相当于裸奔啊,所以它连脏读的问题都没办法解决。

任何事务对数据的修改都会第一时间暴露给其他事务,即使事务还没有提交。

做一个实验,先将全局隔离级别设置为读未提交

set global transaction isolation level read uncommitted;
1

设置完成后,只对之后新起的 session 才起作用,对已经启动 session 无效。如果用 shell 客户端那就要重新连接 MySQL,如果用 Navicat 那就要创建新的查询窗口。这时候再重新启动两个黑窗口进行模拟。

Mysql中开启事务有两种方式begin/start transaction,最后提交事务执行commit,或者回滚事务rollback。在执行begin/start transaction 命令,它们并不是一个事务的起点,在执行完它们后的第一个sql语句,才表示事务真正的启动 。

分析执行流程

1、在第一个黑窗口(事务A)中,执行begin;后;将id=1的数据行改为name='duktig666'

begin;
UPDATE user
SET name='duktig666'
WHERE id = 2;
1
2
3
4

2、在第二个黑窗口(事务B)中,执行执行begin;后;执行查询,观察数据。

begin;
SELECT * FROM user;
1
2

3、在第一个黑窗口(事务A)中将事务回滚,在第二个黑窗口(事务B)中再次执行查询,观察数据。

rollback;
1
SELECT * FROM user;commit;
1

读未提交分析

总结

读未提交,其实就是可以读到其他事务未提交的数据,但没有办法保证你读到的数据最终一定是提交后的数据,如果中间发生回滚,那就会出现脏数据问题,读未提交没办法解决脏数据问题。更别提可重复读和幻读了,想都不要想。

# 读提交

既然读未提交没办法解决脏数据问题,那么就有了读提交。

读提交就是一个事务只能读到其他事务已经提交过的数据,也就是其他事务调用 commit 命令之后的数据。那脏数据问题迎刃而解了。

读提交事务隔离级别是大多数流行数据库的默认事务隔离界别,比如 Oracle,但是不是 MySQL 的默认隔离界别。

继续验证,将事务隔离级别设置为读提交,然后重新打开两个mysql黑窗口。

set global transaction isolation level read committed;
1

分析执行流程

1、事务A开启事务,执行修改操作修改id=2的name:duktig->duktig666。

2、此时事务A未提交,事务B开启事务,执行查询操作,数据为duktig。

3、事务A提交,事务B再次执行查询操作,数据为duktig666。

具体代码参看“读未提交都差不多”。

读提交分析

在不同的时刻,查询出来的数据可能是不一致的,可能会受到其他事务的影响。

总结

读提交解决了脏读的问题,但是无法做到可重复读,也没办法解决幻读。

# 可重复读

可重复是对比不可重复而言的,上面说不可重复读是指同一事务不同时刻读到的数据值可能不一致。

可重复读是指,事务不会读到其他事务对已有数据的修改,即使其他事务已提交,也就是说,事务开始时读到的已有数据是什么,在事务提交前的任意时刻,这些数据的值都是一样的。但是,对于其他事务新插入的数据是可以读到的,这也就引发了幻读问题

继续验证,需改全局隔离级别为可重复读级别,将name重置为duktig,并重新打开两个黑窗口。

set global transaction isolation level repeatable read;
1

可对读提交的流程再执行一次,发现修改操作不会出现可重复读,即解决了可重复读(上述操作不在重复验证)。但是更新操作又引起了数据不一致(幻读)。

分析验证流程

1、开启事务A,执行修改操作修改id=2的name:duktig->duktig666。

2、开启事务B,在事务A执行完update后,执行insert操作,插入记录“name='duktig' age=23”(这条数据和事务A修改前的name和age的值相同)。

INSERT INTO user (name, age)
VALUES ('duktig', 23);
1
2

3、事务B提交后,事务A执行select操作,查询age=23的数据,这时出现了多一行的数据,这是事务B刚刚插入的,即幻读。

可重复读分析

看到有文章提到,在Mysql中,默认的不可重复读个隔离级别也解决了幻读的问题。但是我这确实出现了幻读问题,这需要再分析分析。

# 串行化

串行化是4种事务隔离级别中隔离效果最好的,解决了脏读、可重复读、幻读的问题,但是效果最差,它将事务的执行变为顺序执行,与其他三个隔离级别相比,它就相当于单线程,后一个事务的执行必须等待前一个事务结束。

# MVCC原理

# 版本链

InnoDB聚簇索引的两个必要隐藏列:

  1. trx_id:一个事务每次对某条聚簇索引改动时,会把改事务的id赋值给trx_id
  2. roll_pointer:每次对某条聚簇索引改动时,会将旧版本写到undo日志中。这个隐藏列相当于一个指针,可以通过它找到该记录修改前的信息。(Insert操作的undo日志没有该属性,insert undo只在事务回滚时发挥作用,事务提交后就没用了。

版本链:每次更新记录,旧值放到undo日志,根据roll_pointer连成一条版本链,头节点是当前记录的最新值。另外还包含每个版本对应的事务id。

通过版本链来控制并发事务访问相同记录时的行为,称这种机制为多版本并发控制。

# ReadView

读未提交直接读最新的版本就好了,但是读提交和可重复读都必须保证读到已提交的事务修改过的记录,即另一个事务已经修改了记录但是未提交,则不能读取最新版的记录。

核心问题:需要判断版本链中的哪个版本是当前事务可见的

ReadView的四个重要内容:

  1. m_ids:在生成ReadView时,当前系统活跃读写事务的事务id列表。
  2. min_trx_id:在生成ReadView时,当前系统活跃读写事务的最小事务id;也就是m_ids中的最小值。
  3. max_trx_id:在生成ReadView时,系统应该分配给下一个事务的id值。
  4. creator_trx_id:生成该ReadView的事务的事务id。只有执行insert、update、delete操作时才会分配事务id,否则该值默认为0。

如何判断版本可见?

  1. 访问版本的trx_id与creator_trx_id相同(事务访问自己的版本),可以访问该版本。
  2. 访问版本的trx_id小于min_trx_id,表明生成该版本的事务在当前事务生成ReadView之前已提交,可以访问。
  3. 访问版本的trx_id大于max_trx_id,不可访问。
  4. trx_id在min_trx_id和max_trx_id之间,需要判断trx_id是否在m_ids中,用以确定是否访问。在,则不能访问。

读提交和可重复读生成ReadView的时机:

  • 读提交:每次读取数据前都生成一个ReadView。
  • 可重复读:在第一次读取数据时生成ReadView。

# 二次索引与MVCC

只有在聚簇索引中才有 trx_idroll_pointer 隐藏列,那么二级索引(非聚簇索引)如何判断可见性呢

  1. 二级索引页面的Page Header部分,有一个属性 PAGE_MAX_TRX_ID,执行增删改查时,如果执行操作的事务id大于PAGE_MAX_TRX_ID 的值,则将其值设置为执行操作的事务id。即 PAGE_MAX_TRX_ID 代表修改该二级索引页面最大的事务id
  2. 执行select时,如果ReadView的 min_trx_id 大于 PAGE_MAX_TRX_ID,则说明该页面的值对ReadView可见;如果小于,则需要回表进行判断。
  3. 利用二级索引中的主键进行回表操作,得到聚簇索引的记录后,再按照聚簇索引的方式从第一个版本开始,依次判断可见性。

# 可重复读是否能解决幻读问题?

mysql的可重复读在MVCC和锁机制下尽可能保证幻读问题,但是并不能完全禁止幻读。

特殊情况下,仍然可能出现幻读问题:

  • T1执行SELECT生成ReadView
  • T2新插入一条记录,并且提交。
  • ReadView不能阻止T1执行UPDATE或者DELETE语句来改动这条新插入的记录。(由于T2提交,改动这条记录并不能造成阻塞)
  • T1修改这条记录,这条记录的trx_id变成了T1的id。
  • 之后T1再次查询时,在查询结果中就可以发现这条记录了。

参考: