Cwww3's Blog

Record what you think

0%

Isolation And MVCC

事务隔离级别

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

假设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。

1
2
mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);
image-20210426223339317
  • 读未提交 v1=2 v2=2 v3=2 A事务v1能读到未提交事务B的变动
  • 读提交 v1=1 v2=2 v3=2 A事务 v1不能读到未提交的事务B的变动
  • 可重复读 v1=1 v2=1 v3=2 A事务 v1不能读到未提交的事务B的变动 ,且在A事务中,v2的值数据没变
  • 串行化 v1=1 v2=1 v3=2 事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准

“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。

“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。

“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念

“串行化”隔离级别下直接用加锁的方式来避免并行访问。

查看数据库隔离级别

1
mysql> show variables like 'transaction_isolation';

事务的启动方式

  1. 显式启动事务语句,begin/start transaction。配套的提交语句是 commit,回滚语句是 rollback
  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

注意事务的启动时机

begin/start transaction 命令并不是一个事务的起点,在执行到第一个操作 InnoDB 表的语句,事务才真正启动。

如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

事务隔离的实现

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)

image-20210426225401655

对于 read-view A,要得到 1,就必须将当前值(4)依次执行图中所有的回滚操作得到。

回滚日志在不需要的时候才删除。系统会判断,没有比这条回滚日志更早的 read-view 时,这条回滚日志会被删除。

长事务

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

1
2
-- 查询事务 大于 1分钟
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

视图

在 MySQL 里,有两个“视图”的概念:

  • 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
  • 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。

“快照”在 MVCC 里是怎么工作的?

可重复读

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。如图:

image-20210426232909504

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

语句更新会生成 undo log(回滚日志)。那么,undo log 在哪呢?实际上,图 2 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。

image-20210426234705470

对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分(低水位到高水位是最小未提交事务id和最大未提交事务id的范围,不代表这个范围内的事务都没提交)
    1. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    2. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于上上图 中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。

InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。(前面提到快照基于整库创建,这个特性避免了拷贝整个库的数据)

可重复读分析

1
2
3
4
5
6
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
image-20210426231444890

假设:

事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;

事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;

三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

查询

这样,事务 A 的视图数组就是[99,100], 事务 B 的视图数组是[99,100,101], 事务 C 的视图数组是[99,100,101,102]。

事务 A 查询逻辑有关的操作:

image-20210427001400180

从图中可以看到,第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。

第二个有效更新是事务 B,把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。

在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。

现在事务 A 要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务 A 查询语句的读数据流程是这样的:

  • 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。

更新

事务 B 的 update 语句,如果按照一致性读,好像结果不对哦?

如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。

因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)

因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。

所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。

这里我们提到了一个概念,叫作当前读。其实,除了 update 语句外,select 语句如果加锁,也是当前读。

所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。

1
2
3
4
-- 读锁 共享锁
mysql> select k from t where id=1 lock in share mode;
-- 写锁 排他锁
mysql> select k from t where id=1 for update;

再往前一步,假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?

image-20210427002632560

事务 C’的不同是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先发起了。

虽然事务 C’还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?

这时候,两阶段锁协议上场了。事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。

读提交分析

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。

如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?

查询

说明:start transaction with consistent snapshot; 的意思是从这个语句开始,创建一个持续整个事务的一致性快照。

所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。(事务C下的图,不是C’)

image-20210427003316641

这时,事务 A 的查询语句的视图数组是在执行这个语句的时候创建的,时序上 (1,2)、(1,3) 的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:

  • (1,3) 还没提交,属于情况 1,不可见;
  • (1,2) 提交了,属于情况 3,可见。

所以,这时候事务 A 查询语句返回的是 k=2。事务 B 查询结果 k=3。

补充

MVCCInnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

对于MVCC中的读可以分为两种,分别叫做快照读当前读快照读就是简单的select查询,查询的都是快照版本,这个场景下因为都是基于MVCC来查询快照的某个版本,所以不会存在幻读的问题,也可以认为是解决了幻读的方案之一,对于RC级别来说,因为每次查询都重新生成一个read view,也就是查询的都是最新的快照数据,所以会可能每次查询到不一样的数据,造成不可重复读,而对于RR级别来说只有第一次的时候生成read view,查询的是事务开始的时候的快照数据,所以就不存在不可重复读的问题,当然就更不可能有幻读的问题了。

所以,现在我们说幻读,其实不是指快照读的场景,而是指的是当前读的场景。当前读指的是lock in share modefor updateinsertupdatedelete这些需要加锁的操作。对于MVCC来说就是解决的快照读的场景,而对于当前读那么就是Next-Key Lock要解决的事情。

Donate comment here.