事务是恢复和并发控制的基本单位,它通过 持久性、原子性、隔离性,实现了 一致性。 注意:在 MySQL 中,事务支持是在引擎层实现的。

  • 事务的启动时机
    • begin/start transaction(默认)
      • 执行后延迟到第一个操作 InnoDB 表的语句才会开启事务;
      • 一致性视图是在执行第一个快照读语句时创建的。
    • start transaction with consistent snapshot
      • 执行后立刻开启事务;
      • 一致性视图是在执行该命令时就立即创建的。

        ACID特性

        | 事务特性 | 简述 | 关注点 | 实现 | | —- | —- | —- | —- | | 原子性 | 事务是最小工作单元,操作不可分割,要么全部成功,要么失败! | 事务的可中断性和异常的可恢复性 | undo log 保证 | | 一致性 | 事务开始前和结束后,数据库的完整性约束没有被破坏 | 本质上是应用层对数据结果的预期结果 | 由程序保障+AID | | 隔离性 | 事务间互不影响,解决并发访问时数据一致性问题 | 并发访问的数据可见性 | undo log + 事务ID + mvcc 保证 | | 持久性 | 保证已提交的事务数据被正确存储,异常断电数据也不会丢失 | 已经成功提交后的数据的正确 | redo log 保证 |

特别说明:其中一致性是我们最终的目的。
原子性的前提必须保证持久性,原子性 实现了单线程下的一致性,原子性+隔离性 实现了并发情况下的一致性。

我认为称ACID特性并不准确,因为这四种特性并不正交,A、I、D 是手段,C 是最终目的,弄到一块去完全是为了拼凑个单词缩写。D是A的因,而C是I+A的果。

原子性(Atomicity)

一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可简约。

持久性是实现原子性的前提;原子性保证了单线程下的一致性。
注意:事务中的原子性和Java中原子类的原子性是不同的,它只能保证事务的正常执行,不能保证线程安全。

一致性(Consistency)

事务开始前和结束后,数据库的完整性约束没有被破坏 。即 数据库总是从一个一致性的状态转换到另一个一致性的状态,因此一致性和原子性是息息相关的。 注意:一致性 是在 原子性&隔离性&持久性 共同作用下的最终结果

  • 举例说明什么是一致性

一致性: 执行事务前后,保证数据一致,多个事务对同一个数据读取的结果是相同的; 例如转账案例 假设有五个账户,每个账户余额是100元,那么五个账户总额是500元,如果在这个5个账户之间同时发生多个转账,无论并发多少个,比如在A与B账户之间转账5元,在C与D账户之间转账10元,在B与E之间转账15元,五个账户总额也应该还是500元,这就是保护性和不变性。

隔离性(Isolation)

保证事务间互不影响,实现并发访问时数据一致性。 即 解决当数据库上有多个事务同时操作相同数据时,可能出现的脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)问题。

简述 关注点
脏读 读到了其他事务修改了还未提交的新值 读到了可能不存在的数据(回滚)
不可重复读 在同一个事务中,两次读取到的数据不一致 针对delete、update导致的数据不一致
幻读 针对insert导致的数据不一致
  • 幻读 vs 不可重复读

两者既然都是在同一个事务中,两次读取数据不一致,为什么需要区分是新增、删除、更新的情况呢?因为从控制的角度来看, 两者的区别就比较大了。不可重复读, 只需要锁住满足条件的记录(行锁);而幻读,要锁住整张表(表锁)。因此可以看出要解决幻读代价更大。
如果使用锁机制来实现这两种隔离级别,在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。

隔离级别

当隔离得越严实,效率就会越低,一致性越强。因此很多时候,我们都要在二者之间寻找一个平衡点。而这个平衡点就是通过隔离级别来控制的。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。

隔离级别 描述 优点 缺点 一言蔽之
读未提交 RU
(read-uncommitted)
指一个事务提交前,它做的变更就能被其他事务看到 隔离级别最低,效率最高,无需加锁 一致性最差,可能产生 “脏读”、”幻读”、”不可重复读”
一般情况,都不会考虑这种级别
别人改数据的事务尚未提交,我在我的事务中也能读到;
读已提交 RC
(read-committed)
指一个事务提交后,它做的变更才会被其他事务看到 效率高,不用加锁却能有效的避免脏读(通过快照实现);
这是各种系统中最常用的一种隔离级别,也是SQL Server和Oracle的默认隔离级别
仍然可能产生 “幻读”、”不可重复读” 别人改数据的事务已经提交,我在我的事务中才能读到;
可重复读 RR
(repeatable-read)
指一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
(当然未提交的变更对其他事务也是不可见的)
有效避免”不可重复读”和”脏读”;
(Mysql的默认隔离级别)
在当前读中仍然可能产生 “幻读”;
普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读只在“当前读”下才会出现。
别人改数据的事务已经提交,我在我的事务中也不去读;
串行化
(serializable)
指对记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 能有效避免 “脏读”、”幻读”、”不可重复读”;
(最高隔离级别)
执行效率最差,性能开销也大,
所以一般情况也没人用
我的事务尚未提交,别人就别想读改我事务中的数据;

这4种隔离级别,并行性能依次降低,安全性依次提高。

  • 底层原理

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

  • RU下:直接返回记录上的最新值,没有视图概念;
  • RC下:这个视图是在每个 SQL 语句开始执行的时候创建的;
  • RR下:这个视图是在事务启动时创建的,整个事务存在期间都用这个视图;
    • 在更新或者删除数据时,是当前读,通过行锁解决了不可重复读的问题。
  • “串行化”下:直接用加锁的方式来避免并行访问。

视图

用于定义事务执行期间“我能看到什么数据”。没有物理结构。

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

  • view

它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。

  • read view

也称 一致性视图(consistent read view)它是 InnoDB 在实现 MVCC 时用到的一致性读视图,用于支持 RC和 RR 隔离级别的实现。

MVCC

MVCC是隔离性的实现原理,MySQL 中,实际上每条记录在更新的时候都会同时记录一条undo log(回滚日志)。 同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(Multi-Version Concurrency Control)。 作用:利用MVCC,可实现了“秒级创建快照”的能力。

  • 举例说明

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
image.png
当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。
同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

  • undo log什么时候删除?

系统会判断当没有事务需要用到这些回滚日志的时候,回滚日志会被删除;
什么时候不需要了?当系统里么有比这个回滚日志更早的read-view的时候。

两种数据读取方式

RR:只需要在事务开始的时候创建视图,之后事务里的其他查询都共用这个视图; RC:每一个语句执行前都会重新算出一个新的视图。

  • 一致性读(快照读):

一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,也能读取在它的视图创建前,别的事务已经提交了的新值。具体来总结为三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

说明:底层代码中为了实现这一规则,是通过活跃数组去判断的数据可见版本来实现的。

  • 当前读

更新、插入、删除数据都是先读后写的,而这个读,只能读当前的值(即 最新的值),称为“当前读”(current read)。
当前读,为保证一致性,在读取时会将读取的数据加排他锁,直到该事务提交为止。当然,如果读时其他事务正在更新该值,读取的事务会阻塞等待,直到写的事务提交为止再读此时的最新值。如果不阻塞等待写事务,而当写事务修改完就读会出现脏读。

  • 为什么需要当前读?

因为当它要去更新数据的时候,就不能再在历史版本上更新了,否则该事务的更新就丢失了。只有拿到最新的值做更新操作,才能保证更新不会丢失。

  • select 语句不加锁是一致性读,加锁,也是当前读。加锁方式有以下两种(加在查询语句末尾即可):
    • 读锁(共享锁):lock in share mode;
    • 写锁(排他锁):for update。

“秒级快照” 实现 分析

在RR下,事务在启动的时候就“拍了个基于整库的快照”,用于实现一致性读。 说明:这个“快照”并非传统意义上的快照,而是通过MVCC实现的类似快照的能力。它用比快照稍慢的查询作为代价,实现了整个库的秒级别的快照速度,而且占用空间更小,这就是MVCC的精妙之处。 下面是实现分析。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

图例说明
image.png
图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。
而图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

在可重复读的隔离级别下,以事务启动的时间为准,如果一个数据版本是在我启动之后生成的,就依次往上找,直到找到符合条件的第一个版本的数据才认。当然,如果是这个事务自己更新的数据,它自己还是要认的。

  • 示例一

对于上图示例中的数据来说,如果有一个事务 transaction_id=23,它的活跃数组[18,20,23],即 低水位18,高水位25,那么当它访问这数据k时,通过数据版本可见性策略可知:
首先会读取到k的最新版本V4,此时row trx_id=25,大于高水位24,属于当前事务开启之后才开启的事务,不可见;
然后从 V4 通过 U3 计算出 V3版本,此时row trx_id=17,小于最低水位18,属于当前事务开启前就提交的事务,可见。得到可读的k值11。

  • 示例二

对于上图示例中的数据来说,如果有一个事务 transaction_id=22,它的活跃数组[15,20,23],即 低水位20,高水位24,那么当它访问这数据k时,通过数据版本可见性策略可知:
首先会读取到k的最新版本V4,此时row trx_id=25,大于高水位24,不可见;
然后从 V4 通过 U3 计算出 V3版本,此时row trx_id=17,处于活跃数组之间,无法快速判断,需要通过活跃数组进行具体匹配判断,发现活跃数组中不存在17,属于已经提交的事务,即可见。得到可读的k值11。

数据可见性策略

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间当前正在“活跃”的所有事务 ID,称为活跃数组。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

这个视图数组把所有的 row trx_id 分成了几种不同的情况:
事务 - 图3
这样,当前事务启动瞬间的数据可见性,就可通过 row trx_id对比高低水位来判断:

区域 可见性
绿色 Y
黄色 不活跃 Y
活跃 N
红色 N
  1. 绿色:表示这个版本数据是当前事务开启之前就已创建并提交或者是当前事务自己创建的,这个数据是可见的;
  2. 红色:表示这个版本数据是当前事务开启之后才创建,是肯定不可见的;
  3. 黄色:表示这个版本数据在当前事务开启之前就已创建,但有没有提交,需根据活跃数组来判断:
    1. 若 row trx_id 不在活跃数组中,表示创建这个版本数据的事务已经提交,可见;
    2. 若 row trx_id 在活跃数组中,表示创建这个版本数据的事务还未提交,不可见。

当然,读数据都是从最新版本依次向历史版本读起,直到该数据可见为止。

  • 思考说明

情况3之所以会分两种情况讨论,是因为事务ID虽然在生成的时候保证了有序递增,但每个事务的生命周期是不确定的。所以,落在高、低水位之间只能保证是在当前事务开启前,修改该数据的事务就已经开启了,但在当前事务开启前,修改该数据的事务是否已经提交,需要通过活跃数组来判断了。

所以,有了row trx_id,数据库里随后发生的更新,就跟这个事务看到的内容无关了。
因为之后的更新,生成的版本一定属于上面的 2 或者 3(a) 的情况,而对它来说,这些新的数据版本是不存在的,所以事务的快照,就是“静态”的了

在mysql8.0以前表结构不支持“可重复读”。这是因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵循当前读的逻辑。MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,因此也标志着支持“可重复读”了。

示例

数据k的初始值为1
image.png
结果:示例中事务 A 查到的 k 的值是 1,而事务 B 查到的 k 的值是 3。

  • 通过 两种数据读取方式 直接分析
    • 事务A:查询是一致性读,且事务A创建时 K=1,因此,事务A查到的K=1。
    • 事务B:事务B中存在更新k值,更新k值就会产生当前读。因此事务B在更新时会阻塞直到事务C提交,然后读取k的最新值=2,然后更新其值为3。所以在事务B中最终查询得到的数据为3。
  • 说明

示例中,我是直接通过 两种数据读取方式 中的读取规则来直观分析的结果。当然,底层代码是通过活跃数组去判断的数据可见版本来实现的,这里也可以完全通过这种方式来推倒数据的可见性,只是需要给出更多的数据出来,比较麻烦,就略了。

持久性(Durability)

事务提交后,对数据的修改就是持久的,即便系统故障也不会丢失,这种特性称为 持久性,这种能力称为 crash-safe。持久性保证了宕机情况下的原子性。 持久性主要通过redo log来实现。

Why

可重复读到底有什么用?

每次读出最新的数据有啥不好?读个历史数据有啥用?
可重复读本质作用是保证在开启事务后,对数据库所有表数据的查询都是相同的版本数据,即开启事务那一刻的版本。所以可重复读事务级别解决的并不是表面上的不可重复读现象。就算如此,又有什么用呢?
实际场景:
可重复读也经常用在数据库不停机备份过程中,由于数据库备份时数据还有可能在不断修改,我们肯定希望备份整个数据库开始时的那个版本,而不希望备份的数据有些是之前那个时刻版本的,有些则是之后那个时间版本的。

换角度理解事务

我们可以不从赃读,不可重复读,幻读这些现象看事务隔离级别,而是从读一致性上来理解,如下:

  • RU

读未提交,不解决任何读一致性问题,只保证了事务的写一致性(又称原子性),事务提交后,要么都修改成功,要么都不成功。

  • RC

读提交,保证其它并发事务的修改要么全可见,要么全不可见,可以理解为”写一致性读”,注意断句!”写一致性”、”读”,这是最常用的事务隔离级别,可以保证业务数据含义的一致性。
比如用户下单场景,开事务先后写了order主表订单数据与order_item子表订单中商品数据,如果在两个写中间,有一个事务,去读取order与order_item,就会发现只读到了order而没有读到order_item,这给用户看到了,那一定会吓一跳的,我交钱了结果买了一个空单!虽然用户刷新一下又可以看到完整数据。
但如果使用提交读事务隔离级别就不会有这个问题,用户要么查不到任何数据,要么查到完整数据,这也从侧面说明了逻辑上有关联的数据修改,一定要开事务来操作。

  • RR

可重复读,保证事务开启的那一刻,后面所有对整个数据库所有表的读都是读那一刻的数据版本。

  • Serializable

串行化,一般来说解决的是并发上的逻辑错误,因为此级别逻辑上可以认为所有事务都是串行执行的(虽然数据库实际上可能会并发执行)。
比如两个事务先判断数据有没有,没有则插入数据的场景,并发情况下两个事务同时查询,发现没有数据后插入数据,结果插入了两条数据,而使用串行化隔离级别就没有这个问题,这在并发编程中叫竞态条件,所以串行化解决了读写的竞态条件问题。
当然,这个问题也可以通过添加唯一索引,或使用外部显示加锁的方法来解决。