事务简介

简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在MySQL中,事务支持是在引擎层实现的。你现在知道,MySQL是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如MySQL原生的MyISAM引擎就不支持事务,这也是MyISAM被InnoDB取代的重要原因之一。

那么提到事务,我们必然想到它的ACID四个特性,所以如果一个数据库声称支持事务操作,那么这个数据库必须支持以下四个特性:

  • 原子性(Atomicity)
    原子性是指事务所包含的所有操作要么全部成功,要么全部失败回滚;因此事务的操作如果成功就必须要完全应用到数据库,如果操作失败则不能对数据库有任何影响。
  • 一致性(Consistency)
    一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是一个事务执行之后和执行之前都必须处于一致性状态。
  • 隔离性(Isolation)
    隔离性是指当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作锁干扰,多个并发事务之间要相互隔离。
  • 持久性(Durability)
    持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是数据库系统遭遇到故障的情况下也不会丢失提交事务的操作。

事务的启动方式

MySQL的事务启动方式有以下几种:

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

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

但是有的开发场景下会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,建议使用commit work and chain语法。

在autocommit为1的情况下,用begin显式启动的事务,如果执行commit则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行begin语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。

隔离级别

现在重点说明事务的隔离性,当多个线程都开启事务操作数据库中的数据时,数据库系统要能进行隔离操作,以保障各个线程获取数据的准确性,在介绍数据库提供的各种隔离级别之前,我们先看看如果不考虑事务的隔离性,可能会发生的几种问题:

  • 脏读
    在一个事务中读取到了其他事务还没提交的数据。
  • 不能重复读取
    对于数据库中的某个数据,在一个事务中多次查询却返回了不同的结果,即在查询间隔中,该数据被其他事务做了修改并提交了。脏读的区别是,脏读读取到的是其他事务还没提交的数据。诚然,在某些场景下,不可重复读并不是问题,因为很多数据以最后一次查询为准。
  • 幻读
    幻读是事务非独立执行时发生的一种现象。当某个事务在读读取某一范围内的数据时,另外一事务在该范围内插入并提交了新的数据,该事务再次查询这个范围的数据式就会出现幻行。和不可重复读一样都是读取到了其他事务提交了的数据,区别在于不可重复读查询的是同一数据项,幻读则针对的是一批数据整体。

以上的情况其实都属于数据库读一致性的问题,为了解决这些问题,数据库提供了事务隔离级别机制。在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL标准的事务隔离级别包括:

  • 读未提交(Read Uncommitted)
    一个事务还没提交时,它做的变更就能被别的事务看到;即啥也不保证的最低级别。
  • 读已提交(Read Committed)
    一个事务提交之后,它做的变更才会被其他事务看到;可避免脏读问题。是Oracle的默认级别。
  • 可重复读(Read Repeatable)
    一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。可避免脏读、不可重复读问题。是MySQL的默认级别
  • 串行化(Serializable)
    顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。即最高级别的隔离,100%的隔离。

以上描述是SQL标准中的定义,理论上可重复读级别没有解决幻读问题,但是MySQL的InnoDB和XtraDB通过MVCC解决了幻读问题。由此可见,每种数据库产品,甚至每种存储引擎所实现的隔离级别不尽相同。实在无力详尽对比介绍。

用一个例子说明这几种隔离级别。假设数据表T中只有一列,其中一行的值为1,下面是按照时间顺序执行两个事务的行为。

事务A 事务B
启动事务A
查询得到值1
启动事务B
查询得到值1
将1改为2
查询得到值V1
提交事务B
查询得到值V2
提交事务A
查询得到值V3

我们来看看在不同的隔离级别下,事务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语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

MySQL里有两个“视图”的概念,一个是view,它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view … ,而它的查询方法与表一样。另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view。个人认为可以理解为快照,用于支持RC和RR隔离级别的实现。

综上所述,我们可以初步得出一个结论:数据库实现事务隔离的方式,基本可以分为两种:

  • 一种是在读取数据前,对其加锁,阻止其他事务对数据修改。
  • 另一种不加锁,通过一定的机制生成一个数据请求点的一致性数据快照,并用这个快照来提供一定级别(语句级别或者事务级别)的一致性读取。从用户的角度来看,就好像是数据库可以提供同一个数据的多个版本,因此这种技术就称之为多版本并发控制(MultiVersion Concurrency Control, MVCC)。

MVCC

在MVCC中,读操作又可以分为两类:快照读 (snapshot read)与当前读 (current read)。

其中快照读读取的是记录的可见版本(有可能是历史版本),不用加锁。当前读读取的是记录的最新版本,并且当前读返回的记录都会加锁,以保证其他事务不会再并发修改这条记录。(PS:有关锁的部分这里没有整理,后面会慢慢整理出来)

在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

快照读

简单的select操作,属于快照读,一般不加锁。

1
select * from demo where id = 666;

当前读

特殊的操作,插入/更新/删除操作,属于当前读,需要加锁。

下面的语句都属于当前读,读取记录最新的版本。并且读取之后,还需要保存其他并发事务不能修改当前的记录,对读取记录加锁。其中,除了第一条语句,对读取记录加共享锁外,其他的操作加的都是排它锁。

1
2
3
4
5
select * from demo where id = 123 lock in share mode;   ## 共享锁
select * from demo where id = 666 for update;
insert into demo values(...);
delete from demo where ...;
update demo set ... where ...;

设置隔离级别

在MySQL数据库中查看当前事务的隔离级别:

1
select @@tx_isolation;

或者,也可以用show variables来查看当前的值:

1
show variables like 'transaction_isolation';

在MySQL中设置事务的隔离级别:

1
set tx_isolation='级别名称';

或者

1
set [global | session] transaction isolation level 级别名称;

注意:设置数据库的隔离级别一定要在开启事务之前!另外,隔离级别的设置只对当前的连接有效,对于使用MySQL命令窗口而言,一个窗口就相当于一个连接;对于JDBC操作而言,即一个connection有效。

再谈隔离级别

理解了事务的隔离级别,这里我们展开说明“可重复读”,再来看看事务隔离具体是怎么实现的。

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

假设一个值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文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

你可以在information_schema库的innodb_trx这个表中查询长事务,比如下面这个语句,用于查找持续时间超过60s的事务。

1
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

另一个场景

假设一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它就会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?

假设默认autocommit=1,有一张简单的表t,有主键id和字段k,初始值为(1,1);三个事务执行流程如下:

事务A 事务B 事务C
start transaction with consistent snapshot
start transaction with consistent snapshot
update t set k=k+1 where id=1;
update t set k=k+1 where id=1;
select k from t where id=1;
select k from t where id=1;
commit
commit;
这里,我们需要注意的是事务的启动时机。

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。

在这个例子中,事务C没有显式地使用begin/commit,表示这个update语句本身就是一个事务,语句完成的时候会自动提交。事务B在更新了行之后查询; 事务A在一个只读事务中查询,并且时间顺序上是在事务B的查询之后。

这个例子中的结果是事务B查到的k的值是3,而事务A查到的k的值是1。

???说实话我现在也有点晕了,但是不要紧,我们逐步揭开这个原因

“快照”在MVCC里是怎么工作的?

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整个库的

InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。如下所示,一个记录被多个事务连续更新后的状态:

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

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。

数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。而数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图的对比结果得到的。

这个视图数组把所有的row trx_id 分成了几种不同的情况。对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况:
    1. 若 row trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    2. 若 row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于之前图中的数据来说,如果有一个事务,它的低水位是18,那么当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行的值是11。

有了这个声明后,系统里面随后发生的更新所生成的版本一定属于上面的2或者3.1的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

场景分析

回到本小节开头的三个事务例子中来,分析下事务A的语句返回的结果,为什么是k=1。先做如下假设:

  1. 事务A开始前,系统里面只有一个活跃的事务,ID是99;
  2. 事务A、B、C的版本号分别为100、101、102,且当前的系统中只有这4个事务;
  3. 三个事务开始前,(1,1)这一行数据的row trx_id是90。

这样,事务A的视图数组就是[99,100],事务B的视图数组是[99,100,101],事务C的视图数组是[99,100,101,102]。

为了简化分析,把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作:

从图中可以看到,第一个有效更新是事务C,把数据从(1,1)改成了(1,2)。这时候,这个数据的最新版本的row trx_id是102,而90这个版本已经成为了历史版本。第二个有效更新是事务B,把数据从(1,2)改成了(1,3)。这时候,这个数据的最新版本(即row trx_id)是101,而102又成为了历史版本。

在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了。但这个版本对事务A必须是不可见的,否则就变成脏读了。

现在事务A要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的:

  • 找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。

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

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

用这个规则再来判断一遍事务A的查询结果:

  • (1,3)还没提交,属于情况1,不可见;
  • (1,2)虽然提交了,但是是在视图数组创建之后提交的,属于情况2,不可见;
  • (1,1)是在视图数组创建之前提交的,可见。

更新逻辑

但是又有疑问了,事务B的update语句,如果按照一致性读,好像结果不对哦?事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)来?

是的,如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了。因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,即前文所提到的“当前读”。

因此,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx_id是101。

所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。

再往前一步,假设事务C不是马上提交的,而是变成了下面的事务D,会怎么样呢?

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

事务D的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过了,虽然事务D还没提交,但是(1,2)这个版本也已经生成了,并且是当前的最新版本。那么,事务B的更新语句会怎么处理呢?

这时候就又涉及到锁的问题了(后面整理),先简单来说就是,事务D没提交,也就是说这个版本上的写锁还没释放。而事务B的更新必须要用当前读,且必须加锁,因此就被锁住了,必须等待事务D的锁释放之后才能继续它的操作。

可重复读的实现

到这里就可以得到结论了,可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

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

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

那么,我们再看一下,在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?

这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。
这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:

  • (1,3)还没提交,属于情况1,不可见;
  • (1,2)提交了,属于情况3,可见。

所以,这时候事务A查询语句返回的是k=2。显然地,事务B查询结果k=3。

小结

本篇内容有点多,篇幅也很长,因为我想把相关的知识尽量地整理得完善一些。不整理不知道,一整理发现原来我这么牛逼我自己都不知道,哈哈哈,其实这些我也都忘了,记不起来。总之,拿别人的总结直接粘过来吧。

InnoDB的行数据有多个版本,每个数据版本有自己的row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据row trx_id和一致性视图确定数据版本的可见性。

  • 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
  • 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
  • 当前读,总是读取已经提交完成的最新版本,需加锁。
  • 快照读,简单的查询语句,不加锁。