MySQL 中的事务

前言

MySQL 中的事务操作,要么修改都成功,要么就什么也不做,这就是事务的目的。事务有四大特性 ACID,原子性,一致性,隔离性,持久性。

A(Atomic),原子性:指的是整个数据库事务操作是不可分割的工作单元,要么全部执行,要么都不执行;

C(Consistent),一致性:指的是事务将数据库从一种状态转换成下一种一致性状态,在事务开始之前和事务结束之后,数据库的完整性约束没有被破坏;

  • 数据的完整性: 实体完整性、列完整性(如字段的类型、大小、长度要符合要求)、外键约束等;

  • 业务的一致性:例如在银行转账时,不管事务成功还是失败,双方钱的总额不变。

  • 如果事务执行过程中,每个操作失败了,系统可以撤销事务,系统可以撤销事务,返回系统初始化的状态。

I(isolation): 隔离性还有其它称呼,如并发控制,可串行化,锁等,事务的隔离性要求每个读写事务对象对其他事务操作对象能够相互隔离,即事务提交之前对其它事务都不可见;

D(durability), 持久性: 指的是一旦数据提交,对数据库中数据的改变就是永久的。即使发生宕机,数据库也能恢复。

原子性

原子性:指的是整个数据库事务操作是不可分割的工作单元,要么全部执行,要么都不执行。

在对数据库进行修改的时候,就会记录 undo log ,这样当事务执行失败的时候,就能使用这些 undo log 恢复到修改之前的样子。

InnoDB 通过 undo log 进行事务的回滚,实际上做的是和之前相反的工作,对于每个 INSERT ,InnoDB 会生成一个 DELETE ;对于 DELETE 操作,InnoDB 会生成一个 INSERT。。。通过反向的操作来实现事务数据的回滚操作。实现事务的原子性。

一致性

一致性:指的是事务将数据库从一种状态转换成下一种一致性状态,在事务开始之前和事务结束之后,数据库的完整性约束没有被破坏。

一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。

持久性

持久性: 指的是一旦数据提交,对数据库中数据的改变就是永久的。即使发生宕机,数据库也能恢复。

redo log 用来从保证事务的持久性。

redo log 简单点讲就是 MySQL 异常宕机后,将没来得及提交的事物数据重做出来。

redo log 包括两部分:一个是内存中的日志缓冲( redo log buffer ),另一个是磁盘上的日志文件( redo log file )。

MySQL 每执行一条 DML 语句,先将记录写入 redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file 。这种 先写日志,再写磁盘 的技术就是 MySQL 里经常说到的 WAL(Write-Ahead Logging) 技术。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe

并发事务存在的问题

事务并发可能会出现下面几种问题,【脏读,不可重复读,幻读】 等情况。

  • 脏读:读到其他事务未提交的数据;

  • 不可重复读:前后读取的数据不一致;

  • 幻读:前后读取的记录数量不一致。

脏读

脏读又称无效数据的读出,是指在数据库访问中,事务T1将某一值修改,然后事务T2读取该值,此后T1因为某种原因撤销对该值的修改,这就导致了T2所读取到的数据是无效的,值得注意的是,脏读一般是针对于 update 操作的。

幻读

幻读是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,比如这种修改涉及到表中的“全部数据行”。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入“一行新数据”。那么,以后就会发生操作第一个事务的用户发现表中还存在没有修改的数据行,就好象发生了幻觉一样。

简单的讲就是,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

不可重复读

不可重复读,是指在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。

在一个事务内,多次读同一个数据。在这个事务还没有结束时,另一个事务也访问该同一数据并修改数据。那么,在第一个事务的两次读数据之间。由于另一个事务的修改,那么第一个事务两次读到的数据可能不一样,这样就发生了在一个事务内两次读到的数据是不一样的,因此称为不可重复读,即原始读取不可重复。

幻读和不可重复读的的区别

不可重复读和幻读都是读的过程中数据前后不一致,只是前者侧重于修改,后者侧重于增删。

隔离性

事务的隔离级别

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

  • 读未提交:一个事务还没提交时,它的变更就能被别的事务看到,读取未提交的数据也叫做脏读;

  • 读提交:一个事务提交之后,它的变更才能被其他的事务看到;

  • 可重复读:MySQL 中默认的事务隔离级别,一个事务执行的过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的,在此隔离级别下,未提交的变更对其它事务也是不可见的,此隔离级别基本上避免了幻读;

  • 串行化:这是事务的最高级别,顾名思义就是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

串行化,不是所有的事务都串行执行,没冲突的事务是可以并发执行的。

可以看到,只有串行化的隔离级别解决了【脏读,不可重复读,幻读】这 3 个问题。

下面来详细的介绍下 读提交可重复读

准备下数据表

create table user
(
id int auto_increment primary key,
username varchar(64) not null,
age int not null
);
insert into user values(2, "小张", 1);

来分析下下面的栗子

MySQL 中的事务理解-LMLPHP

读未提交

V1、V2,V3 的值都是2,虽然事务1的修改还没有提交,但是读未提交的隔离能够看到事务未提交的数据,所以 V1 看到的数据就是 2 了。

读提交

V1 的值是1,V2 是2,V3 是2。因为事务1提交了,读提交可以看到提交的数据,所以 V2 的值就是2,V3 查询的结果肯定也是2了。

可重复读

V1、V2 的值是1,V3 的值是 2。

虽然事务1提交了,但是 V2 还是在事务2 中没有提交,根据可重复读的要求,一个事务执行的过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的,所以 V2 也是 1。

串行化

V1、V2 的值是1,V3 的值是 2。因为事务2,先启动查询,所以事务1必须等到事务2提交之后才能提交事务的修改,所以 V1、V2 的值是1,因为 V3 的查询时在事务1提交之后,所以 V3 查询的值就是2。

事务隔离是如何实现

在了解了四种隔离级别,下面来聊聊这几种隔离级别是如何实现的。

首先来介绍一个非常重要的概念 Read View

Read View 是一个数据库的内部快照,用于 InnoDB 中 MVCC 机制。

可重复读 和 读提交

可重复读 和 读提交 主要是通过 MVCC 来实现,MVCC 的实现主要用到了 undo log 日志版本链和 Read View

undo log 日志版本链

undo log 是一种逻辑日志,当一个事务对记录做了变更操作就会产生 undo log,里面记录的是数据的逻辑变更。

对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列。

  • trx_id:每次对某条聚簇索引记录进行改动时,都会把对应的事务 id 赋值给 trx_id 隐藏列;

  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

MySQL 中的事务理解-LMLPHP

每次事务更新的时候,undo log 就会用 trx_id 记录下当前事务的事务 ID,同时记录下当前更新的数据,通过 roll_pointer 指向上个更新的旧版本数据,这样就形成了一个历史的版本链。

Read View

undo log 版本链会将历史事务进行快照保存,并且根据事务的版本大小,通过指针串联起来,对于 可重复读 和 读提交 这两种事务隔离级别,只需要在 undo log 中选择合适的事务版本进行数据读取,就能实现对应的读取隔离效果。

判断 undo log 版本链中,那个事务版本对当前事务可见,InnoDB 中通过 Read View来解决,作用是事务执行期间用来定义“我能看到什么数据”。

Read View 中有四个重要的字段

  • m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。

  • min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。

  • max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;

  • creator_trx_id :指的是创建该 Read View 的事务的事务 ID。

MySQL 中的事务理解-LMLPHP

Read View 可以在理解为一个数据的快照,可重复读隔离级别会在每次启动的事务的时候生成一个 Read View 记录下当前事务启动瞬间,当前所有活跃的事务 ID。

创建该 Read View 的事务的事务 ID,会在 Read View 中根据事务 ID 大小,判断当前事务落在了那个区域,然后判断当前事务 ID 对应的数据快照是否可读。

  • 已提交的事务:对于当前事务来讲,因为都是已经提交或者是当前事务生成的,这部分数据都是可见的;

  • 未开始事务:Read View 中应该给下一个事务的 ID,这部分的数据是不可见;

  • 未提交事务集合:这种有下面两种情况;

1、如果当前事务 ID 在未提交事务集合中,表示这个版本是由还没提交的事务生成的,不可见;

2、如果当前事务 ID 不在未提交事务集合中,表示这个版本是已经提交了的事务生成的,可见。

总结下来就是

1、首先创建当前 Read View 时的事务 ID 会判断当前事务落在了 Read View 中那个区域中;

2、然后判断当前事务 ID 对应的数据是否可读;

3、如果可读通过 undo log 版本链找到对应事务的快照数据,这就是目前该事物能够读到的数据;

4、如果不可读,在顺着 undo log 版本链找到上个事务的版本,持续重复 1~3 的步骤,直到找到版本链中最后一个数据,如果最后一个版本的数据也是不可见,那就表示当前查询找不到记录。

可重复读读提交 事务隔离级别的区别就在于创建 Read View 的时机不同,可重复读事务隔离级别会在每次启动事务的时候创建 Read View读提交 会在每次查询的时候创建 Read View

因为可重复读事务隔离级别在事务开始创建了 Read View,就能保证事务中的看到的数据一致了,而读提交事务隔离级别在每次查询的时候,创建 Read View,就能在每次查询的时候读到已经提交的事务数据。

下面来个栗子具体分析下可重复读隔离级别的读取过程

MySQL 中的事务理解-LMLPHP

栗如,上面的三个事务,在可重复读隔离级别中,事务的查询结果

其中 V1 的查询结果是 3,V2 的查询结果是 1。

这里具体的分析下,假定现在有下面几种场景

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

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

3、三个事务开始前,id = 2 的 age 为 1 这一行数据的 trx_id 是 90。

这样三个事务的 Read View 分别是

MySQL 中的事务理解-LMLPHP

可以看到事务3 提交修改,102 的 trx_id 就是最新版本,此时的数据 id = 2 的 age 为 2。

然后事务 2 提交修改 trx_id 的最新版本就变成了 101,此时的数据 id = 2 的 age 为 3。

这时候事务1 的查询,事务1 中的事务版本号是100。在事务1 Read View 中的未提交事务集合中,所以数据不可见,需要根据版本链寻找上一个版本。

1、找到(1,3)的时候,判断出 trx_id=101,处于当前 Read View 的未开始事务中,所以数据不可见;

2、接着,找到上一个历史版本,trx_id=102,同样处于当前 Read View 的未开始事务中,所以数据不可见;

3、再往前找,找到了(1,1),它的 trx_id=90,处于当前 Read View 的已提交事务中,所以数据可见;

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

1、版本未提交,不可见;

2、版本已提交,但是是在视图创建后提交的,不可见;

3、版本已提交,而且是在视图创建前提交的,可见。

数据更新

数据更新需要注意,事务2 时候先于事务3 生成,按道理应该事务2 的修改当时看到的数据应该是1,而不是2,那么数据库是如何处理的呢?

如果事务2在数据更新之前先去查询一次,那么看到的数据就是id = 2 的 age 为 1,但是更新数据,就不能在老的数据中更新了,否则事务3的更新数据就会丢失了。

更新数据都是先读后写的,读使用的是当前读,每次读取的内容都是最新的。

这样更新的时候使用当前读,就保证了事务2拿到的最新的数据,所以更新完成之后的查询 id = 2 的 age 就为 3 了。

除了update语句外,select语句如果加锁,也是当前读。例如,加上 读锁(S锁,共享锁) lock in share mode 或 写锁(X锁,排他锁)for update

select age from user where id=2 lock in share mode;

select age from user where id=2 for update;

如果当前事务3 的事务还没提交,这时候,事务2就开始了写入

MySQL 中的事务理解-LMLPHP

这时候虽然事务3还没有提交,但是数据已经生成了,这时候事务2去读取,是不能读取的,这里会用到两阶段锁协议,首先事务3会给数据加写锁,事务2读取的时候会加读锁,这样数据会被锁住,直到事务3释放锁。

什么是两阶段锁协议:在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

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

在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;

在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

读提交隔离级别,这里就不展开分析了。

串行化

读写都需要加锁,读的时候加读锁,写的时候加写锁。

读未提交

读取最新的数据,读不用加锁,不用遍历版本链,直接读取最新的数据,不管这条记录是不是已提交。不过这种会导致脏读。

对写仍需要锁定,策略和读已提交类似,避免脏写。

可重复读解决了幻读吗

MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:

针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。

针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

可重复读是如何解决幻读的呢?

读操作,可重复读隔离级别会使用 MVCC 针对当前事务生成事务快照,通过对比事务版本,就能保证事务中的快照读。

读写操作,这种就需要借助于 Next-Key,MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key 锁。

Next-Key 算法中,对于索引的扫描,不仅仅是锁住扫描到的索引,而且还锁着这些索引覆盖的范围。因此对于范围内的插入都是不允许的,这样就能避免幻读的发生。

create table user
(
id int auto_increment primary key,
username varchar(64) not null,
age int not null
);
insert into user values(2, "小张", 1);
insert into user values(4, "小明", 1);
insert into user values(6, "小红", 1);
insert into user values(8, "小白", 1);
MySQL 中的事务理解-LMLPHP

上面的这两个事务,事务1 中的 select * from where age>4 for updated 会加上一个间隙锁 (4,6] 这样事务 2 中的插入就需要等待事务1中锁释放才能提交修改。

事务 1 中的前后两次查询,就不会出现幻读的情况了。

MySQL 中的事务理解-LMLPHP

不过可重读真的就完全避免了幻读吗?下面来看两种异常的情况。

场景1

MySQL 中的事务理解-LMLPHP

因为 事务1 在 事务2 提交插入操作之后,使用了 for updated 当前读,这就会出现读取的结果和第一次读取结果的不一致。

场景2

MySQL 中的事务理解-LMLPHP

类似于场景1,因为事务1,在事务2进行插入数据之后,执行了数据更新的操作,数据更新的操作会先查后修改,这个查询就是当前读,所以就能找到刚插入的数据并且修改,这时候当前事务中的 trx_id 就是最新的了,后面的查询就能读取到刚刚更新的数据。

上面两种场景的最终原因,就是后面的查询间接或直接的使用了当前读,造成了数据的不一致,所以只需要在最开始的查询加上 for updated,就能避免幻读的出现了。

总结

1、MySQL 中的事务隔离级别分别是

  • 读未提交:一个事务还没提交时,它的变更就能被别的事务看到,读取未提交的数据也叫做脏读;

  • 读提交:一个事务提交之后,它的变更才能被其他的事务看到;

  • 可重复读:MySQL 中默认的事务隔离级别,一个事务执行的过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的,在此隔离级别下,未提交的变更对其它事务也是不可见的,此隔离级别基本上避免了幻读;

  • 串行化:这是事务的最高级别,顾名思义就是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

2、可重复读 是 MySQL 中默认的事务隔离级别;

3、可重复读 和 读提交 主要是通过 MVCC 来实现,MVCC 的实现主要用到了 undo log 日志版本链和 Read View

4、串行化,读写都需要加锁,读的时候加读锁,写的时候加写锁;

5、读未提交,读取最新的数据,读不用加锁,不用遍历版本链,直接读取最新的数据,不管这条记录是不是已提交。不过这种会导致脏读。对写仍需要锁定,策略和读已提交类似,避免脏写;

6、MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:

针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。

针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

7、可重复读虽然最大程度的避免了幻读,但是还是还有幻读的场景出现。

参考

【高性能MySQL(第3版)】https://book.douban.com/subject/23008813/
【MySQL 实战 45 讲】https://time.geekbang.org/column/100020801
【MySQL技术内幕】https://book.douban.com/subject/24708143/
【MySQL学习笔记】https://github.com/boilingfrog/Go-POINT/tree/master/mysql
【MySQL总结--MVCC(read view和undo log)】https://blog.csdn.net/huangzhilin2015/article/details/115195777
【深入理解 MySQL 事务:隔离级别、ACID 特性及其实现原理】https://blog.csdn.net/qq_35246620/article/details/61200815
【分布式事务】https://mp.weixin.qq.com/s/MbPRpBudXtdfl8o4hlqNlQ

02-22 01:38