这篇开始记录一些对 MySQL 事务的理解。

何为事务

说到事务,一个高频的例子就是银行转账。

A 给 B 转账20元,总共涉及6步骤:

1.读取A账户余额到内存

2.将A账户余额减去20元

3.更新A账户余额到数据库

4.读取B账户余额到内存

5.将B账户余额加上20元

6.更新B账户余额到数据库

这6步的执行必须“一气呵成”,要么全部成功,要么全部失败,这就是事务,否则就会闹出笑话,比如A的账户少了20元,可是B账户的余额却没有多出20元。

MySQL有很多存储引擎,比如MyISAM、InnoDB等。但并非所有存储引擎都支持事务,比如MyISAM就不支持。

怎么产生事务

上面提到了事务的重要应用场景,那么在数据库中,是怎么才能让一连串操作在一个事务中发生呢?

在 MySQL 中事务的启动方式主要有以下几种:

  1. 显式启动

    开始时使用语句: begin/start transaction

    结束时使用语句: commit

    中间的就可以包括事务执行逻辑。

    这里需要注意的是:begin/start transaction 严格来说并非事务起点,而是在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。

    如果想要立即启动事务,则可以执行命令start transaction with consistent snapshot

  2. 自动启动

    set autocommit = 1

    这个时候,在没有显式启动事务的情况下,每执行一个 SQL 语句就是一个事务。

    InnoDB 中 autocommit 的默认值为 1。

事务并发后怎样

在实际使用过程中免不了要有多个事务并发执行,比如A在给B转账,但是B又在给C转账也可能在这个时候发生,那么这个时候,就有两个事务并发执行了。

如何控制才能保证两个事务都能正确执行呢?

答案:事务隔离。

顾名思义:要对两个事务进行逻辑上的隔离。但是隔离的级别有轻有重,这就涉及到事务的隔离级别的问题了。

事务隔离级别

此处的“隔离”,针对的是多个同时执行的事务,也就是事务同时执行时候,互相之间的数据影响程度。

任何事情都是要付出代价的,多个事务之间“隔离”得越严格,带来的性能损失就越大,所以很多时候,我们需要妥协和权衡,是否事务隔离地最严格就一定是最好的。

SQL标准的隔离级别有四种:

  • read uncommitted

    读未提交。

    事务A还没提交,事务B就能看到A的修改。显然,这是一种最低的隔离级别。

  • read committed

    读已提交。

    只有事务A已经提交后,事务B才能看到A的修改。

  • repeatable read

    事务A在执行过程中看到的数据是不变的,比如事务A要对表T的字段c进行操作,那么从事务A开始到结束,主要没有对c进行更改,那么c的值将一直不变。用伪代码描述如下:

    1
    2
    3
    4
    5
    6
    transaction begins:
    ​ c : read_disk(field_c) //读取字段c的值赋给变量c
    ​ other operations //此处代表其他操作,期间没有对c进行任何修改。
    ​ d : read_disk(field_c) //再次读取字段c的值到内存,赋给变量d
    ​ assert true: c == d //c和d一定相等
    transaction ends
  • serializable

    串行化。

    比如我们对表中的同一行数据进行多个事务的操作,事务之间一定按照FIFO的顺序进行。

    比如我们先对表T中字段c进行“读事务”的操作,同时又要对c进行“写事务”的操作,那么“写事务”必须要等到“读事务”进行完毕。

这里举一个例子说明上面几种隔离级别:

现在有一张表 T,其中有一个字段c,c的值为1。现在我们按照时间顺序执行两个事务。

事务

可以看到事务A和事务B并发执行。

但是对于表格中的V1,V2,V3三个变量的值隔离级别不同,则其值也会不同。

对第一个级别:read uncommitted.

由于A事务可以看到B事务中没有提交的修改,所以有:

V1=2 //虽然事务B没有提交,但是事务B已经对c的值进行了修改,所以读到的值会发生变化

V2=2

V3=2

这种情况叫脏读现象。也就是说假如事务B执行异常回滚了,那么此时c的值还是1,而在事务A里却读到了2,此时就会造成与数据库里面的值不一致的现象。

第二个级别:read committed

由于A事务只能看到B事务的修改,所以有:

V1=1

V2=2 //此时事务B才真正提交,所以读到的值也才会发生变化

V3=2

V1和V2都是在事务A执行期间读到的值,但是值却不同,这种就叫不可重复读

第三个级别: repeatable read

由于A事务开始到最后事务提交,中间没有对c进行过任何修改,所以有:

V1=1

V2=1

V3=2 // 此时事务A已经结束,所以读到的值是事务B修改过后的。

这种结果比较符合我们的预期,但是这种情况下也还是会存在一种情况:

现在假设表T里面有两行记录:c =1,c=2。

事务A要查的不是一个字段的值而是一个结果集V(比如找出所有小于10的c的值)。

执行步骤如下图所示:

结果集操作

则我们查到的结果集V1和V2就会不同,这种现象叫幻读

可以看到这种隔离级别只对另外一个事务对字段进行更改起作用,而对insert操作不起效果。

第四个级别:serializable

读写事务之间串行执行。

所以在事务B对c的值进行修改时,因为此时事务A的读c事务已经开始,所以此时B事务会等到读事务完成之后才进行。

V1=1

V2=1

V3=2 //读事务执行完毕,写事务执行完毕。

盗用网上的一张表格:

isolation_info

隔离级别的实现

上面讲了在设置不同的隔离级别下,我们并发执行事务时会产生什么影响。而这一部分,主要简单讲下 RR(可重复读)这种隔离级别是如何实现的,其他几种隔离级别后面深入了解后再补充进来。

RR 隔离级别的实现

视图

“视图”是讲隔离级别一个绕不开的概念。

在 MySQL 里面视图分为两种:

  1. view

    用于查询语句定义的虚拟表。

    创建 view 的方法:

    create view viewname(这里是视图名) as ……(这里是查询SQL语句)

  2. InnoDB 在实现 MVCC(多版本并发控制)用到的一致性视图(consistent read view)

    这个视图支持RC、RR 两种级别的实现。

简单来讲,视图并非一个物理结构,而是一个类似窗口的东西,表示通过这个视图你可以“看到”什么。

视图的创建时机:事务启动时。

视图的声明周期:整个事务期间。

MVCC

在RR隔离级别下,InnoDB 中每个事务都有一个唯一的事务ID,叫做 transaction id,它是在事务开始时候向存储引擎事务管理系统申请的,按照申请顺序严格递增

我们看到这里有一个 MV,multi version。

这个多版本指的就是每一行数据的多个版本,MVCC 也就是对同一行数据不同版本的并发控制。

行数据的多版本是什么样子

每一行数据都有一个参数叫row trx_id。事务更新这一行数据的时候,这一行数据会有一个新版本产生,同时事务会将自己的 transaction id赋给这个参数。

下面就是行记录多版本的一张示意图。

多版本

行数据多版本如何控制

上面图中的V1,V2,V3并非真实物理存在。

但是假如我们想得到行数据的 V1 ,则可以通过图中的虚线箭头计算出来。而这些虚线箭头就是undo log(回滚日志)。

行数据的每次更新都会写入 undo log,那么这些 undo log 会不会越来越大

不会的。系统会将没用的 undo log 回收掉。何为没有用?当一个事务已经完全提交后,undo log 不会马上删除,而是会放入待清理的链表,由一个 purge 线程来判断是否有其他事务在使用 undo 段中表的上一事务版本信息,决定是否清理 undo log 的日志空间。

按照前面提到的 RR 的定义:一个事务启动时,能够看到所有已提交的事务结果,但是之后,在这个事务执行期间,会对其他事务的更新不可见

简单介绍下实现思路。

InnoDB 为每个事务构造了一个数组,这个数组里保存了这个事务启动的那一刻,所有当前“活跃”的事务id,即已经启动了但是还没有提交的事务id。

数组里id最小的值叫低水位,最大的id值+1称为高水位

水位示意图

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

  1. 落在绿色区域。

    这个版本是已提交的事务或者当前事务自己生成的,数据可见。

  2. 落在红色区域。

    这个版本是将来启动的事务生成的,不可见。

  3. 落在黄色区域。

    a. row trx_id 在数组中

    未提交的,不可见。

    b. row trx_id 不在数组中

    已提交的,可见。

这块可能看着有点晕,举个例子就明白了:

  1. 假设有一组事务 id:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
  2. 其中已提交(可见):[1, 2, 3, 6, 7],未开始的(不可见):[10, 11, 12],当前 id:[8]
  3. 那么活跃 id 数组(不可见):[4, 5, 9],高水位:9,低水位:4;
  4. 高低水位之间既有已提交但不在数组中的(可见):[6, 7],又有活跃的(不可见):[4, 5, 9]

一个典型的 MVCC 的例子

下面是一张表,使用 InnoDB 引擎存储。

id k
1 1
2 2

现在假设有三个事务A、事务B、事务C 并发执行:

事务A 事务B 事务C
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update set k=k+1 where id=1;
update set k=k+1 where id=1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;

问题:事务A、事务B和事务C中k的值各是多少?

我们不妨先假设:

  1. 事务A开始前,系统里面只有一个活跃的事务id=99
  2. 事务A的id=99,事务B的id=100,事务C的id=101
  3. 三个事务开始前,(1,1)这行数据的 row trx_id = 90

因为事务A、事务B、事务C 依次开始,所以事务A的视图数组[99,100],事务B的视图数组是[99,100,101],事务C的视图数组是[99,100,101,102]。

事务A的查询逻辑图:

事务A的查询逻辑图

从上图中可以看到,(1,1)数据的当前版本已经是101了。

而当事务A 进行查询的时候,它的视图数组是[99,100]。

从 undo log 开始找:

  • (1,3)的当前版本是 101,高水位区域,不可见;
  • (1,2)的当前版本是 102,高水位区域,不可见;
  • (1,1)的当前版本是 90,低水位区域,可见。

所以我们可以看到:虽然行(1,1)被多次修改过,但是因为事务id,事务A看到的数据结果永远是一致的,这就实现了RR。

这个比较id的办法虽然可行,但是复杂,可以使用一条简单的准则来判断:

一个数据版本,对于一个事务视图来说,除了自己的更新总是可见外,有以下三种情况:

  1. 版本已经提交,并且在视图创建之前提交,可见。
  2. 版本已提交,并且是在视图创建之后提交,不可见。
  3. 版本未提交,不可见。

用这个准则再来判断上面的例子可以同样得出事务A查到的k值是1,但是已经简洁了许多。

事务A的答案,我们已经知道,那么对于事务B呢?

这就涉及到了更新逻辑,因为事务B中有一条update语句。

事务的更新永远是建立在最新的版本上的。比如事务B的更新,不管事务C的更新是否提交。事务B此时的 set k=k+1 更新,是在 (1,2)的基础上进行的。

更新数据的准则:先读后写,读只读当前值

稍微改下事务的执行流程如下:

事务A 事务B 事务C
start transaction with consistent snapshot;
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update set k=k+1 where id=1;
update set k=k+1 where id=1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;
commit;

事务C相较上次发生了变化:显式启动事务,且在事务B的更新语句执行之后提交。

继续一步一步分析:

事务A,B,C依次启动;

对于id=1的那行数据,事务C正在更新k的值为2,更新尚未提交

事务B的update语句也尝试将k的值加1,此时会发生什么?

更新为3?

非也。此时涉及到了前文讲的MySQL行锁的概念)。事务C在未提交之前,会将 id=1 的行数据上写锁,而事务B对该行也要进行更改操作,故也是写锁,写锁之间是互斥的,所以此时事务B会阻塞等待,直到事务C释放。如果事务B只有 select 语句,则此时根据前面的分析:k 的值依然是1.

事务B的查询逻辑图

总结一下,在RR事务隔离级别下,只需要在事务一开始的时候创建一个一致性视图,之后事务的其他查询都共用这个一致性视图。

RC 隔离级别的实现

RC情况相较RR更为简单:RC是每个语句执行前都会重新算出一个新的视图。

再以上面为例。

读提交隔离

所以事务A在查询k值的时候创建了一张视图,这个视图创建之前其他事务已经提交的修改均为可见,于是事务A看到的k值为2。

(全文完)

参考资料

  1. 丁奇《MySQL 45讲》
  2. https://www.cnblogs.com/wy123/p/8365234.html
  3. https://hoxis.github.io/mysql-zhuanlan-08-current-read.html