目录

  1. 概述
  2. 事务的 ACID 特性
    1. 原子性(Atomicity)
    2. 一致性(Consistency)
    3. 隔离性(Isolation)
    4. 持久性(Durability)
  3. 隔离性与隔离级别
  4. 事务隔离的实现
  5. 数据库事务的启动方式
  6. 事务模型
  7. 数据库的事务
    1. MySQL 中的事务
    2. 事务的创建
    3. 数据库的隔离级别
  8. 附录

开发人员和数据库打交道时,总是会用到事务,其中最经典的例子就是转账。转账逻辑具体到程序里会有一系列的操作,比如查询余额、做加减法、更新余额等,如果单独看待转账这个需求,需要保证的是转账前后,用户的余额必须保证是一致的即可;另一方面,事务同样也是 MySQL 等关系型数据库区别于 NoSQL 的重要方面,是保证数据一致性的重要手段。本文主要了解 MySQL 中事务的实现原理以及相关的事务并发控制协议理论。

概述

❓为什么需要事务?

  • 事务(Transaction)是数据库区别于文件系统的重要特性之一,在文件系统中,如果你正在写文件,但是操作系统突然崩溃、宕机了,这个文件就很有可能被破坏。虽然有一些机制可以把文件恢复到某个时间点,但是如果需要保证两个文件同步,这个文件系统可能就显得无能为力了
  • 数据库引入事务的主要目的:事务会把数据库从一种一致状态转换为另一种一致状态。再数据库提交工作时,可以确保其要么所有修改都已经保存了,要么所有修改都不保存

简而言之,通过事务能够保证数据库中的一组操作,要么全部成功,要么全部失败,在 MySQL 中,事务支持是在引擎层实现的。由于 MySQL 的引擎是可插拔式的,所以并不是所有的引擎都支持事务,比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 会被 InnoDB 取代的原因之一。

🤔 一个数据库有了 ACID 能怎么样?

如果一个数据库系统具备了 ACID 特性,能够使得应用程序开发人员能够将注意力集中在单个事务上,而不必考虑并发容错等问题。所以,拥有了事务的数据库,说到底还是为了方便业务开发人员进行业务开发。

📓 按照严格的标准,只有同时满足 ACID 特性才是事务;但是在各大数据库厂商的实现中,真正满足 ACID 的事务少之又少,例如:

  • MySQL 的 NDB Cluster 事务不满足持久性和隔离性
  • InnoDB 默认事务隔离级别是可重复读,不满足隔离性
  • Oracle 默认的事务隔离级别为 READ COMMITTED,不满足隔离性

👴 因此与其说 ACID 是事务必须满足的条件,不如说它们是衡量事务的四个维度

事务的 ACID 特性

如果一个数据库声称支持事务,那么该数据库必须要具备 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)四个特性,各个特性具体含义将在下面进行阐述。

原子性(Atomicity)

原子性是指事务包含的所有操作要么全部成功执行,要么全部执行失败并回滚。其中事务成功的条件是事务里的所有操作都成功,只要有一个操作失败,整个事务就失败,需要回滚。具体而言,原子性是指一个事务是一个不可分割的工作单位。通过原子性能够保证,事务中的 sql 语句要么都做,要么都不做;如果事务中一个 sql 语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态

MySQL 的日志有很多种,如二进制日志、错误日志、查询日志、慢查询日志等。InnoDB 存储引擎提供了两种事务日志:redo log&undo log,其中redo log用于保证事务持久性;undo log则是事务原子性和隔离性实现的基础

实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的 sql 语句。InnoDB 能够实现回滚,靠的就是 undo log。当事务对数据库进行修改时,InnoDB 会生成对应的 undo log,如果事务执行失败或调用了 rollback,导致事务需要回滚,便可以利用 undo lo g 中的信息将数据回滚到修改之前的样子

undo log属于逻辑日志,它记录的是 sql 执行相关的信息,当发生回滚时,InnoDB 会根据undo log的内容做与之相反的工作,执行语句大致如下:

  • 对于每个insert,回滚时会执行delete
  • 对于每个delete,回滚时会执行insert
  • 对于每个update,回滚时会执行一个相反的update,把数据改回去

以 update 操作为例:当事务执行 update 时,其生成的 undo log 中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到 update 之前的状态

一致性(Consistency)

一致性是指任何一个数据库事务的执行,应该让数据库保持在一致状态,并不会改变数据库原本的一致性约束

对于转账例子而言,假设用户 A 和用户 B 两者的钱加起来一共是 1000,那么不管 A 和 B 之间如何转账,转几次账,事务结束后两个用户的钱相加起来应该还得是 1000,这就是事务的一致性。

  • ACID 中的「一致性」,是对于整个数据库的「一致」状态的维持。抽象来看,对数据库每进行一次事务操作,它的状态就发生一次变化。这相当于把数据库看成了状态机,只要数据库的起始状态是「一致」的,并且每次事务操作都能保持「一致性」,那么数据库就能始终保持在「一致」的状态上 (Consistency Preservation)。
  • 所谓状态是不是「一致」,其实是由业务层规定的。比如转账的例子,“转账前后账户总额保持不变”,这个规定只对于「转账」这个特定的业务场景有效。如果换一个业务场景,「一致」的概念就不是这样规定了。所以说,ACID 中的「一致性」,其实是体现了业务逻辑上的合理性,并不是由数据库本身的技术特性所决定的。

一致性时事务追求的最终目标,ACID 中的原子性、持久性和个理性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性的措施包括如下几点:

  • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
  • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
  • 应用层面进行保障,例如转账操作只扣转账者的余额,没有增加接受者的余额,无论数据库实现的多么完美,也没有办法达到一致的状态

隔离性(Isolation)

数据库能够提供给多个用户并发访问,当多个用户同时操作同一张表时,数据库就会为每一个用户开启的事务,避免其他事务的操作对自身事务的影响。通过事务的隔离特性,能够保证多个并发事务之间不会互相影响。隔离性在数据库系统中是由隔离级别来表现的,这也是本文接下来详细介绍的重点。

通过事务的隔离特性,就是想要做到这样一种结果:对于任意两个并发的事务 T1 和 T2,在事务 T1 看来,T2 要么在 T1 开始之前就已经结束,要么在 T1 结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。

既然隔离性追求的是并发情形下事务之间互不干扰,简单考虑的话,事务的隔离性主要可以分为两个方面:

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

持久性(Durability)

持久性是指一旦一个事务提交后,事务所做的修改将会永久的保存在数据库上,即使数据库系统出现宕机等其他故障,已经完成的事务操作也不会丢失。

例如:我们在使用 JDBC 操作数据库时,在提交事务方法后,提示用户事务操作完成,当我们程序执行完成直到看到提示后,就可以认定事务以及正确提交,即使这时候数据库出现了问题,也必须要将我们的事务完全执行完成,否则就会造成我们看到提示事务处理完毕,但是数据库因为故障而没有执行事务的重大错误。

其实,持久性是一个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。并且不可能有能做到 100% 的持久性保证的策略,故障恢复的错误率难免会发生。

InnoDB 作为 MySQL 的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘 IO,效率会很低。为此,InnoDB 提供了缓存(Buffer Pool),Buffer Pool 中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从 Buffer Pool 中读取,如果 Buffer Pool 中没有,则从磁盘读取后放入 Buffer Pool;当向数据库写入数据时,会首先写入 Buffer Pool,Buffer Pool 中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)。

Buffer Pool 的使用大大提高了读写数据的效率,但是也带了新的问题:如果 MySQL 宕机,而此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

于是,redo log 被引入来解决这个问题:当数据修改时,除了修改 Buffer Pool 中的数据,还会在 redo log 记录这次操作;当事务提交时,会调用 fsync 接口对 redo log 进行刷盘。如果 MySQL 宕机,重启时可以读取 redo log 中的数据,对数据库进行恢复。redo log 采用的是 WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到 Buffer Pool,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求。

既然 redo log 也需要在事务提交时将日志写入磁盘,为什么它比直接将 Buffer Pool 中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:

  • 刷脏是随机 IO,因为每次修改的数据位置随机,但写 redo log 是追加操作,属于顺序 IO。
  • 刷脏是以数据页(Page)为单位的,MySQL 默认页大小是 16KB,一个 Page 上一个小修改都要整页写入;而 redo log 中只包含真正需要写入的部分,无效 IO 大大减少。

隔离性与隔离级别

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了「 隔离级别 」的概念。并且随着隔离级别不断的提升,一条简单的 SQL 语句的执行效率会逐渐下降,因此很多时候,开发人员都是在隔离级别和执行效率之间寻找一个平衡点。

隔离问题含义
脏读一个事务读取到了另外一个事务未提交的数据
不可重复读同一个事务中,多次读取到的数据不一致
幻读一个事务读取数据时,另外一个事务进行更新,导致第一个事务读取到了没有更新的数据

❓ 既然幻读和不可重复读都是前后两次读取到的内容不一致,那么这两者到底有什么不同尼?

幻读和不可重复读都是读取了另一条已经提交的事务,不同的是:不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体。

  • 不可重复读的重点是修改,同一个事务,两次读取到的数据不一样。
  • 幻读的重点在于新增或删除,同样的条件下,第一次和第二次读出来的记录数是不一样的

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable ),具体的含义如下表

隔离级别含义
读未提交(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);

现在就可以查看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2 与 V3 的返回值分别是什么。

  • 若隔离级别是「 读未提交 」, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
  • 若隔离级别是「 读提交 」,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
  • 若隔离级别是「 可重复读 」,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
  • 若隔离级别是「 串行化 」,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。

在实现事务的隔离级别时,数据库中会创建一个视图,访问的时候以示图的逻辑结果为准。

  • 在 「 可重复读 」隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图;
  • 在 「 读提交 」隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的;
  • 在 「 读未提交 」隔离级别下直接返回记录上的最新值,没有视图概念;
  • 在 「串行化 」隔离级别下直接用加锁的方式来避免并行访问

所以,在不同隔离界别下,数据库的行为是不同的。Oracle 数据库的默认隔离级别是 「 读提交 」,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,一定要记得将 MySQL 的隔离级别设置为「 读提交 」

修改 MySQL 隔离级别配置的方式是,将启动参数transaction-isolation的值设置成READ-COMMITTED,首先可以通过show variables来查看当前的值

1
2
3
4
5
6
7
8
9
10
11
mysql> show variables like 'transaction_isolation';

+-----------------------+----------------+

| Variable_name | Value |

+-----------------------+----------------+

| transaction_isolation | READ-COMMITTED |

+-----------------------+----------------+

📓 这四种隔离级别都有着各自存在的道理,并没有那种隔离级别一定比另一种隔离级别要好的情况,都是根据自己的业务情况来确定事务的隔离级别。

❓ 什么时候需要可重复读的场景?

假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。这时候使用「 可重复读 」隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。

事务隔离的实现

🎯 本小节将详细展示在可重复读隔离级别下,事务隔离具体是如何实现?

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

假设一个值从 1 按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到

同时,即使现在有另一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

❓ 有了回滚日志可以方便事务执行撤销操作,但是这些回滚日志要保留到什么时候?什么时候应该删除这些回滚日志?

当然是在系统不需要的时候删除这些回滚日志,数据库系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志就会被删除。简单的一点就是:当系统里没有比这个回滚日志更早的 read-view 的时候,就可以考虑删除此回滚日志

基于以上的原因,也可以解释,为什么在开发中不建议使用长事务。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库。除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库

数据库事务的启动方式

开发过程中当然应该避免使用长事务,其实很多时候业务开发人员并不是有意使用长事务,通常都是由于误用所致。总结来说,MySQL 的事务启动方式有以下几种:

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

有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。因此,总好使用 set autocommit=1, 通过显式语句的方式来启动事务。

❓ 如何避免长事务对业务的影响?

可以从应用开发端和数据库端进行解决。首先对于应用开发端而言,想要避免长事务有以下方法:

  1. 确认是否使用了set autocommit = 0,这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1
  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉
  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间

其次,对于数据库端而言,有以下几种方式避免长事务:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警/或者 kill;
  2. 使用 Percona 的 pt-kill 工具;
  3. 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题
  4. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便

事务模型

状态含义
活动的初始状态,事务执行时处于这个状态
部分提交的最后一条语句执行后
失败的发现正常的执行不能继续进行,必须进行回滚
中止的事务回滚并且数据库已恢复到事务开始执行前到状态后
提交的成功完成

撤销已提交事务所造成的影响的唯一方法是执行一个补偿事务,这也是回滚能够成功的原因。

数据库的事务

之前都是一些理论知识中对于事务的描述,解析来主要关注于 MySQL 中如何使用事务控制语言(TCL,Transaction Control Language)进行 MySQL 中的事务操作。

MySQL 中的事务

MySQL 中的事务主要分为两种:隐式事务显示事务,其中隐式事务没有明显的开始和结束的标记(INSERT,UPDATE,DELETE 语句本身就是一个事务);显示事务具有明显的开启和结束的标记

事务的创建

MySQL 系统默认启用隐式事务:每执行一条 SQL 语句,都是一个单独的事务。如果需要在一个事务中包含多条 SQL 语句,那么需要先关闭隐式事务.

  1. 将自动提交功能为禁用
1
2
3
show variables LIKe 'autocommit';	#查看当前数据库是否为隐式事务
set autocommit=0; #将事务设置为显示事务
SHOW VARIABLES LIKE 'autocommit'; #再次查看当前事务类型
  1. 使用显示事务进行操作

开启事务:start transaction

结束事务:commitrollback

在执行 SQL 语句之前,先执行strat transaction,这就开启了一个事务(事务的起点),然后可以去执行多条 SQL 语句,最后要结束事务,commit 表示提交,即事务中的多条 SQL 语句所做出的影响会持久化到数据库中。或者 rollback,表示回滚,即回滚到事务的起点,之前做的所有操作都被撤消了!也可以打断点,此时事务回滚不再是回滚到起点,而是回滚到断点处

1
2
3
4
5
6
7
8
#创建事务要用到的表
CREATE TABLE account(
id INT PRIMARY KEY AUTO_INCREMENT,
NAME VARCHAR(30),
balance NUMERIC(10.2)
);
INSERT INTO account(NAME,balance) VALUES('fanbingbing', 100000);
INSERT INTO account(NAME,balance) VALUES('lichen', 100000);

演示事务执行失败
案例一:回滚 事务执行失败!

1
2
3
4
START TRANSACTION;
UPDATE account SET balance=balance-10000 WHERE id=1;
UPDATE account SET balance=balance+10000 WHERE id=2;
ROLLBACK;

案例二:退出 mysql 客户端 事务执行失败!

1
2
3
START TRANSACTION;
UPDATE account SET balance=balance-10000 WHERE id=1;
UPDATE account SET balance=balance+10000 WHERE id=2;

演示事务执行成功

1
2
3
4
START TRANSACTION;
UPDATE account SET balance=balance-10000 WHERE id=1;
UPDATE account SET balance=balance+10000 WHERE id=2;
COMMIT;

数据库的隔离级别

通过设置事务的隔离级别可以解决脏读、不可重复读或幻读等问题。

  1. READ UNCOMMITTED
  2. READ COMMITTED可以避免脏读
  3. REPEATABLE READ可以避免脏读、不可重复读和一部分幻读
  4. SERIALIZABLE可以避免脏读、不可重复读和幻读

    常见的事务隔离界别操作,

    • 设置隔离级别:
    1
    set session|global  transaction isolation level 隔离级别名;
    • 查看隔离级别:
    1
    select @@tx_isolation;

附录

事务的四大特性以及隔离级别
深入理解 MySQL 事务
事务的并发控制协议理论&MySQL 源码分析