请回答事务(1)-本地事务

77 阅读17分钟

一、基本概念

1.1 事务定义

A 银行的 a 账户给 b 账户转账 100 元,总共需要两步:

  1. a 账户减100元

  2. b 账户加100元

A银行需要保证第1步、第2步要么全部成功,要么全部失败,不然就可能造成a+b的账户总额在操作前后不一致的情况

定义: 事务由一系列的数据库操作组成的一个工作单元,工作单元中的各个操作不可分割,要么全部发生,要么全部不发生(all or nothing)

1.2 特性

ACID:Atomicity、Consistency、Isolation、Durability

  • A:原子性(Atomicity)

    • 一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • C:一致性(Consistency)

    • 事务执行前后,数据从一个一致性状态变换到另一个一致性状态。这里的一致性与 CAP 中的 C 不是一个概念。这种一致性状态是一种合法性状态。那什么是合法性状态呢?满足预定的约束的状态就叫做合法的状态。比如:某个字段必须>=0,某个字段必须满足唯一性约束,比如 a+b 的总额保持不变。如果事务成功地完成,那么系统中所有变化将正确地应用,系统将处于下一个合法状态。如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始的合法状态。
  • I:隔离性(Isolation)

    • 一个事务的执行不能被其它事务干扰,即一个事务内部的操作及使用的数据对并发的其它事务是隔离的,并发执行的各个事务之间不能互相干扰。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据
  • D:持久性(Durability)

    • 只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态

原子性是基础,隔离性是手段,一致性是约束,持久性是目的

1.3 类型

1.3.1 单机事务

单机事务又被称为本地事务。顾名思义,单机事务中的所有操作都是对同一个数据库中的数据的操作,单机事务的 ACID 特性由数据库直接提供。值得一提的是,事务是在引擎层支持的,如InnoDB支持事务,而MyISAM不支持事务

1.3.2 分布式事务

当一个事务中的动作需要操作不同的数据库(产生不同的数据库连接)时,就会产生分布式事务

需要从 A 银行的 a 账户转100元到 B 银行的 b 账户,同样需要两步:

  1. A 银行的 a 账户减100元

  2. B 银行的 b 账户加100元

由于A、B 银行的转账服务部署在各自的服务器上,操作的数据库自然也不是同一个,这种情况第1步、第2步组成的事务就是分布式事务

1.4 状态

我们现在知道事务是一个抽象的概念,它其实对一绑着一个或多个数据库操作,MySQL根据这些操作所执行的不同阶段把事务大致划分成几个状态:

  • 活动的(active)

事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。

  • 部分提交的(partially committed)

当事务中最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并未刷新到磁盘时,我们就说该事务是部分提交的状态

  • 失败的(failed)

当事务处在活动的部分提交的状态时,可能遇到了某种错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。

  • 中止的(aborted)

如果事务执行了一部分而变为失败的状态,我们及需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态

  • 提交的(committed)

当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。

一个基本的状态转移图如下所示:

图中可见,只有当事务处于提交的中止的状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所造的修改将永久生效,对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行到该事务之前的状态。

二、单机事务

单机事务由数据库引擎层直接支持,我们以InnoDB为例,来分析下它是如何实现事务的ACID特性的

2.1 原子性

事务的原子性由 undo log 来实现。什么是 undo log 呢?

undo log:是存储引擎层(InnoDB)生成的日志,记录的是“逻辑操作”日志(sql语句)。比如对某一行数据进行了插入操作,那么undo log 就生成一条与之相反的删除操作。主要用于事务的回滚(undo log 记录的是每个修改操作的逆操作)和一致性非锁定读(undo log 回滚行记录到某种特定的版本---MVCC,即多版本并发控制)

所以,mysql 的原子性实际上并非简单的“一步操作”,它是会产生中间结果的。通过记录事务中每一条增、删、改语句对应的逆操作,使得在事务中间执行失败时,可以找回执行事务之前的数据,从而把数据恢复成事务执行之前的样子。

显然这只是从结果上保证原子性了,但实际事务执行过程中会产生一系列的中间结果。在并发场景下其它事务可能会看到中间结果。为了保证中间结果对外不可见,需要通过隔离性来保证并发场景下的正确性

2.2 隔离性

隔离性追求的是并发情形下事务之间互不干扰。事务的隔离性由锁机制+MVCC实现。

我们主要考虑最简单的读操作和写操作那么隔离性的探讨,主要两个方面:

  1. (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性

MySQL 中关于锁的实现非常丰富,这里我们只需要有一点认识:InnoDB 存储引擎下,对数据的写操作的前提是获得该记录的行锁。只有当上一个持有该行数据行锁的事务提交了,下一个意图修改该行数据的事务才可能会获得行锁进行数据写操作。以此,保证了并发场景下多个事务写操作的并发安全。

  1. (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性

MVCC 实现的核心是 undo log 和 read view,把这两者关联的核心是事务id。

  • undo log 使得数据存在了多个版本,每个版本的undo log都存储了上个版本undo log的指针,这使得每条数据都生成了一个版本链,事务id可以认为是每个版本的唯一id

  • Read view 就是一个视图,就是事务在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID。在该视图下,大于该数组中最大值的事务ID,表示启动本事务时,这个事务还未启动;属于该数组中事务,表示启动本事务时,这个事务在执行中未提交;小于数组中最小值的事务ID,表示启动本事务时这个事务已经提交了,也即是应该可以被本事务查看到的数据版本。

    • 通过控制 read view 生成的时机,来分别实现读已提交和可重复读的隔离级别。读已提交:事务内每次查询时都生成一个视图;可重复读:只在事务启动时生成一个视图,随后每次查询时都使用该视图。

2.3 持久性

持久性由redolog实现

Redo log:是存储引擎层(InnoDB)生成的日志,记录的是“物理级别”上的页修改操作:如页号xxx、偏移量yyy写入了了“zzz”数据。主要是为了保证数据的可靠性

  • 当数据修改时,除了修改 Buffer Pool 中的数据,还会在 redo log 记录这次操作;

  • 当事务提交时,会调用 fsync 接口对 redo log 进行刷盘。

  • redo log 采用的是 WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到 Buffer Pool 。

  • 如果 MySQL 宕机,重启时可以读取 redo log 中的数据,对数据库进行恢复,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求。

2.4 一致性

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

2.5 小结

以上只是从一个概述的角度总结了下ACID的实现原理,实际的实现细节还有很多,尤其是undo、redo log、隔离性的实现、MVCC 等。

三、隔离级别

MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。事务有隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一个数据的多个事务时性能尽量高些,那就看二者如何取舍了。

3.2 数据并发问题

针对事务的隔离性和并发性,我们怎么做取舍呢?先看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:

脏写(Dirty Write)---丢失写

对于两个事务A、B,如果事务A修改了另一个未提交的事务B修改过的数据,那就意味着发生了脏写。示意图如下:

假设 studentno = 1 对应的 name = "小谷",此时依次开启了A、B两个事务

A 修改后先提交,而后 B 回滚,此时 B 事务会将数据回滚至事务开启时的最初状态,即 name = "小谷"。那么 A 事务的修改就丢失了,这在业务上来说是不能容忍的。

脏读(Dirty Read

对于两个事务A和B,A读取了已经被B更新了但还未提交的字段数据,随后B回滚,A读取的内容就是临时且无效的。

脏写和脏读对应的意思是:丢失写内容和读内容无效。复现的关键点是:A事务用的数据在B事务中也在使用,并且B事务会回滚

不可重复读(Non-Repeatable Read

对于两个事务A和B,A读取了一个字段,然后B更新了该字段。之后A再次读取同一个字段,发现值不同了。

幻读(Phantom

对于两个事务A和B,A 根据某查询条件查到了一些数据,随后B在该表中插入了一些新的数据。之后,A再次用相同查询条件查询时,发现会多出几行数据。

注意点1:

有的同学会有疑问,那如果B中删除了一些符合 studentno > 0 的记录而不是插入新纪录,那么 A 之后再根据studentno > 0的条件读取的记录变少了,这种现象算不算幻读呢?这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取读到了之前没有读取到的记录。

注意点2:

那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前没有读取到的记录。

3.3 SQL 中的四种隔离级别

上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们给这些问题按照严重性来排一下序: 脏写 > 脏读 > 不可重复读 > 幻读

我们愿意舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,并发问题发生的就越多。 SQL标准 中设立了4个 隔离级别 :

READ UNCOMMITTED :读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结 果。不能避免脏读、不可重复读、幻读。

READ COMMITTED :读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做 的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可 重复读、幻读问题仍然存在。

REPEATABLE READ :可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提 交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍 然存在。这是MySQL的默认隔离级别。

SERIALIZABLE :可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止 其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避 免脏读、不可重复读和幻读。

SQL标准 中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:

脏写 怎么没涉及到?因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。 不同的隔离级别有不同的现象,并有不同的锁和并发机制,隔离级别越高,数据库的并发性能就越差,4种事务隔离级别与并发性能的关系如下:

理解【读取】:select 是读取,insert 其实也属于隐式的读取,只不过是在 mysql 的机制中读取的,插入数据也是要先读取一下有没有主键冲突才能决定是否执行插入。

幻读,并不是说两次读取获取的结果集不同,幻读侧重的方便是某一次 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读。

在 RR 隔离级别下,step1、step2 是会正常执行的,step3 则会报主键冲突,对于事务1的业务来说是执行失败的,这里是事务1发生了幻读。因为事务1在step1中读取的数据状态并不能支撑后续的业务操作。事务1:“见鬼了,我刚才读到的结果应该可以支持我这样操作的才对啊,为什么现在不可以”。事务1不敢相信的又执行了 step4,发现和step1读取的结果是一样的(RR下的MVCC机制)。此时,幻读无疑已经发生,事务1无论读取多少次,都差不到id=3的记录,但它的确无法插入这条它通过读取来认定不存在的记录(此数据已经被事务2插入),对于事务1来说,它幻读了。

其实RR也是可以避免幻读的,通过对select操作手动加行X锁(独占锁)(SELECT ... FOR UPDATE 这也正是串行化隔离级别下会隐式为你做的事情)。同时,即便当前记录不存在,比如 id = 3 是不存在的,当前事务也会获得一把记录锁(因为 InnoDB 的行锁锁定的是索引,故记录实体存在与否没关系,存在就加行X锁,不存在就加间隙锁),其他事务则无法插入此索引的记录,故杜绝了幻读

在串行化隔离级别下,step1执行时是会隐式的添加行X锁/gap X锁的,从而step2会被阻塞,step3会正常执行,待事务1提交后,事务2才能继续执行(主键冲突执行失败)。对于事务1来说业务是正确的,成功的阻塞扼杀了扰乱业务的事务2,对于事务1来说他前期读取的结果是可以支撑其后续业务的。

所以MySQL的幻读并非什么读取两次返回结果集不同,而是事务在插入事先检测不存在的记录时,惊奇地发现这些数据已经存在了,之前的检测读获取到的数据如同鬼影一般。(查询不到,但是插入时却报主键冲突

四、参考资料

声明:本文后半段为课程学习笔记,侵权删。原资料:

  • 尚硅谷宋红康mysql讲解

最后

  • 如果觉得有收获,三连支持下;
  • 文章若有错误,欢迎评论留言指出,也欢迎转载,转载请注明出处;
  • 个人vx:Listener27, 交流技术、面试、学习资料、帮助一线互联网大厂内推等