前言

InnoDB引擎中的事务完全符合ACID的特性.也许ACID的定义很多人倒背如流,可是在我看来,不仅定义要熟悉,而且其各自的意义以及实现方法也要有所了解.

ACID中,一致性是事务的最终目的,事务中的操作,都是为了让系统从一个正确的状态变为另一个正确的状态.而原子性,持久性和隔离性则是一致性的实现手段.

原子性的实现关键在于是否支持事务回滚.在InnoDB中,事务的回滚是由回滚日志undo log来保证的.

持久性的实现关键在于系统崩溃前能否及时地保存已提交的事务,这个能力称为crash-safe.在InnoDB中,这一点是由redo log来保证的.

而隔离性,我们上一篇就提到过,在InnoDB中是由锁机制来保证的.

由于原子性和持久性都与日志有关,所以这篇重点讨论InnoDB的隔离性,讨论一下在不同隔离级别下事务的执行可能会发生哪些问题,而InnoDB又是如何解决的.

事务的启动方式

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

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

有些客户端连接框架会默认连接成功后先执行一个set autocommit=0的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。

长事务不仅占用锁资源,还会导致回滚日志无法被清理.

因此,我会建议你总是使用set autocommit=1, 通过显式语句的方式来启动事务。(Navicat默认方式)

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

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

事务的隐式提交

以下SQL语句会产生一个隐式的提交操作,即执行完这些语句后,会有一个隐式的commit操作.

  • DDL语句 : alter database,alter table,create databse….
  • 修改mysql架构的操作 : create user,drop user, grant,rename user….
  • 管理语句 : analyze table , cache index,check table….

很多人往往会忽视对于DDL语句的隐式操作,这有时候可能会引起一些误解.

隔离级别

首先我们要知道,隔离得越严实,效率就会越低.因此很多时候,我们都要在二者之间寻找一个平衡点.

SQL标准的事务隔离级别包括:

  • 读未提交(read uncommitted): 一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交(read committed): 一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读(repeatable read): 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化(serializable ): 串行化会在读取的每一行数据上都加锁(行锁),当其他事务想去update该行时会被阻塞。

不过在实践中,读未提交和串行化几乎不怎么使用.为了完整性,这里也会稍微讨论一下.

MySQL的默认隔离级别是可重复读,简称是RR,所以这篇文章重点讨论的是RR级别.

不过据我所知,阿里云RDS的默认级别是读提交,简称RC,他们觉得在大部分的业务上并没有可重复读的需求,为了提高并发性,所以默认使用RC,若有问题应由研发在代码中做好控制.

不同隔离级别的区别

假设在表中只有一列,其中一行的初始值为1,来看看在不同隔离级别下,图里面V1、V2、V3的返回值分别是什么:

  • 读未提交 : V1=2,V2=2,V3=2
  • 读提交 : V1=1,V2=2,V3=2
  • 可重复读 : V1=1,V2=1,V3=2
  • 串行化 : V1=1,V2=1,V3=2

如果不清楚结果为什么是这样,可以往下看完后再回来验证一遍.

MVCC

上一篇我们提到过,修改数据会加上一个X锁,并且直到事务提交之后才会释放锁.可是在上述例子的RC以及RR级别下,你实验后可以发现事务A在查询V1的时候并没有被阻塞,这难道不是和锁的机制相矛盾了吗?

其实并不是,X锁仍然存在,只不过表中某一列的某一行上"并非只有一个数据",事务A查询的数据并不是被X锁锁住的那个数据.

我们可能会被Nacivat等可视化客户端展示的视图所蒙骗,以为InnoDB的表结构就是行与列的二维结构.

其实它是三维的,在行与列之外还有历史版本数据,这就是MVCC.

Multi-Version Concurrency Control 多版本并发控制,简称MVCC,是InnoDB用于解决不可重复读,幻读的重要机制.

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

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

下图就是一个记录被连续更新后的状态.

数据多版本

引入了MVCC后,在一行上有最新的数据,也有历史的数据,根据读哪个版本的数据也就区分出了快照读当前读:

  • 快照读 : 事务读到的是在事务开启之前就已经提交,或者在本事务中被修改的数据.
  • 当前读 : 事务读到的是最新提交的数据.

在RC级别下,任何读写都是当前读(update之前也得先查到对应的数据)

在RR级别下,只有普通查询是快照读,任何加锁操作都是当前读,例如select … for update(X锁),select … lock in share mode(S锁),update/insert/delete(X锁).

MVCC的多版本数据实际上并不是并行存在的,而是根据undo log计算出来的.例如一个数据update后从V2变为V1,然后记录在undo log中.此后如果想查询V1版本的数据,就根据undo log对V2进行一个反操作即可得到V1.

在MySQL中,有很多概念看似易懂,实际上很容易犯迷糊,比如MVCC与锁的结合.也许你会和我一样,知道了锁的基本概念后就以为全都懂了,然而下一秒就陷于"为啥这一行加了锁,别的事务也能访问呢"的困惑中.

学习了MVCC后,对于加锁需要有这样的概念 : 锁不是锁住某一行,而是锁住某一行的最新版本数据.

此外,也许你之前会记住一个概念,类似"由于InnoDB有锁,所以读操作和写操作是相互阻塞的".

有了当前读和快照读之后,这个表述就容易让人误会了.

实际上,快照读并不会被X锁或者S锁阻塞.也就是快照读和当前读是兼容共存的.

为什么呢?回忆下X锁以及S锁的定义,X锁阻塞的是S锁或者另一个X锁,S锁阻塞的是X锁.

那么快照读是什么锁呢?快照读其实没有锁.所以它和X锁,S锁都没有冲突.

假设表t中有id,c两列,有(0,0),(5,5),(10,10)等行,那么下面这个例子中,session A更改了id=5这一行后,session B使用快照读并不会被阻塞.

session A session B
T1 begin;
T2 update t set c=5 where id=5;
T3 select * from t where id=5;(query ok)
T4 commit;

在上面的例子中,由于session A修改了数据,session B读到的不是最新的,这还不能证明快照读与X锁相互兼容.

再来看个例子,在这个例子中,没有数据更改,两个事务读到的都是同一版本的数据.

session A session B
T1 begin;
T2 select * from t where id=5 for update;
T3 select * from t where id=5;(query ok)
T4 commit;

那么,快照读与MDL写锁兼容吗?答案是它们会相互阻塞.

session A session B
T1 begin;
T2 select * from t where id=5;
T3 ALTER TABLE t MODIFY COLUMN d int(13) NULL DEFAULT NULL AFTER c;(blocked)
T4 commit;

可以看到当前读阻塞了alter.

可是如果操作顺序颠倒,你可能会看到不一样的结果.

session A session B
T1 begin;
T2 ALTER TABLE t MODIFY COLUMN d int(13) NULL DEFAULT NULL AFTER c;
T3 select * from t where id=5;(query ok)
T4 commit;

可以看到快照读并没有被阻塞.如果你用这个去验证快照读与MDL写锁是否兼容,那么你就会被误导了.

为什么会出现这个结果呢?原因就在于前面我们提到的DDL隐式提交的特点.alter完会自动提交事务,也就把MDL写锁给释放了,这样自然不会阻塞到后面的快照读.

此外,lock table … write会阻塞快照读,但是read不会.这里就不举例了.

读未提交

在这一级别下,读取到的都是最新的数据,而且不存在锁,所以可以立即看得到其他事务所做的修改.

这样做会有什么问题呢?

session A session B
T1 begin;
T2 begin;
T3 update t set c=10 where id =5;
T4 select * from t where id =5;
T5 rollback;
T6
T7 commit;

T4时session A拿到了session B修改了但未提交的数据,结果T5时session B回滚了,这样session A就很尴尬了.

这个问题叫做脏读.

读未提交的特点:

  • 无锁
  • 没有MVCC
  • 读到的都是最新数据.

读提交

读提交与读未提交的区别,就在于引入了锁.在这一级别下,事务更新数据时会加上X锁,并且在事务提交时才释放.

而且在这一级别下是当前读模式,所以,别的事务试图读取未提交的记录会被阻塞,相当于只能读到已提交的数据,所以解决了脏读问题.

可是它也仍然存在问题.

session A session B
T1 begin;
T2 select * from t where id =5;(c=5) begin;
T3 update t set c=10 where id =5;(c=10)
T4 commit;
T5 select * from t where id =5;(c=10)
T6 commit;
T7

可以看到T2和T6时刻,session A两次相同的查询却得到不一样的结果.这个问题叫做不可重复读.

不过不可重复读也不是非要解决不可的问题,因为读到的数据都是已经提交了的,像我们前面所说的,阿里云RDS就不把这个当问题.

我想你可能会问那什么时候需要“可重复读”的场景呢?我们来看一个数据校对逻辑的案例。

假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。

这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。

读提交的特点:

  • 所有查询都是当前读.
  • 没有MVCC.
  • 引入了锁,试图读取未提交的数据时会被阻塞,因此只能读取已提交的数据.

可重复读

可重复读与读提交的区别,在于它引入了MVCC多版本控制以及间隙锁,读数据被分为快照读以及当前读两种模式.

来看个例子吧.下面是一个只有两行的表的初始化语句。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

可重复读例子

这里,我们需要注意的是事务的启动时机。

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

事务B先启动事务.

事务C启动比B晚,不过更新数据在B之前.

事务B在更新了行之后查询;

事务A在一个只读事务中查询,并且时间顺序上是在事务B的查询之后。

这时,如果我告诉你事务B查到的k的值是3,而事务A查到的k的值是1,你是不是感觉有点晕呢?

这是因为事务A是快照读模式,由于事务B和事务C都是在它启动之后才提交了,所以它看不见B和C的更新.

事务B使用了update当前读,所以读到了事务C提交的修改,所以它实际上的操作是把2变成3,由于这个操作是本事务内进行的,所以它后来的select可以看得到这个结果.

数据可见性

关于数据的可见性,在mysql45讲中使用了"快照"来进行比喻.

  • RC级别下,快照是在sql语句执行的时候生成.事务只能看到sql语句执行前提交的数据.
  • RR级别下,快照是在事务开启的时候生成.事务只能看到事务开启前提交的数据.

这个快照,InnoDB称其为一致性视图(consistent read view),但它并不是像Redis里面的快照一样,将这一时刻的数据拷贝一份出来,因为如果数据量太大,这么做就太慢了.

一致性视图在实现上的算法和数据版本的row trx_id有关,不过人肉用这个算法去判断可见性很不直观,至少我是这么认为的,本来觉得已经理解了可见性,但是接触这个算法之后又晕了会.

在面试时如果需要判断数据的可见性,由于题目中都会像我们的例子那样可以看出sql语句的执行顺序,所以就可以无视row trx_id直接判断了.

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

  • 版本未提交,不可见
  • 版本已提交,但是是在视图创建后提交的,不可见
  • 版本已提交,而且是在视图创建前提交的,可见

幻读

除了不可重复读之外,InnoDB的RR级别还解决了另一个重要的问题 : 幻读.

在sql标准里面,其实RR级别并不需要解决幻读,这本该由串行化解决,所以InnoDB的RR级别实际上达到了sql标准里串行化的标准.怎么样,可以看出InnoDB多厉害了吧.

幻读的意思是:

第一次查询时本来没有的数据,第二次查询时却冒了出来,感觉像看到"幻象"一样.

不可重复读侧重点在于同一行数据在两次查询时的表现不同.

而幻读的侧重点在于两次查询得到不同的数据量.

所以大部分人会把不可重复读归因到update,而幻读则归因到insert/delete.这不太准确.

在mysql技术内幕中,甚至还把不可重复读直接称作"幻象问题".

来看一个例子.我们还是假设表中只有id和c两列,数据行有(0,0),(5,5),(10,10),(15,15)

session A session B
T1 begin;
T2 begin;
T3 select * from t where id >=5 and id <=10
T4 update t set id=7 where id =15;
T5 commit;
T6 select * from t where id >=5 and id <=10
T7 commit;

在T3时刻,session A查到的数据有(5,5),(10,10)两行,但是在T6时刻查询的数据就多出了(7,15)这一行.

这就是一个典型的由update导致的幻读问题.

所谓的insert或者delete是针对全表的数据而言的,而我们的select可能只是表中的一个范围,所以update可以把select范围内的一条数据变得不符合where的筛选条件,也可以把select范围外的一条数据变得符合where的筛选条件.用绝对的insert或者delete去定义幻读是不妥的.

这里绝不是教大家去做一个杠精,去杠什么是幻读,什么不是幻读.

事实上,定义是为了我们更好地描述一样事物,所谓名可名,非常名,同一样事物,可能你和我对它的叫法不一样,但只要双方理解讨论的具体是什么东西就行了.讨论幻读时,我们只要清楚讨论的是哪些情况,有哪些注意问题,又有哪些解决方案就足矣.

InnoDB是通过MVCC+间隙锁解决幻读的.被间隙锁🔒住的区间无法插入数据.

间隙锁之间是相互兼容的,因为它们共同的目的在于阻止插入.

可重复读的特点:

  • 在锁的基础上还引入了MVCC和间隙锁.
  • 查询数据可分为快照读和当前读.
  • 只能读到事务开启前就已经提交的数据.

串行化

串行化的目的在于使得两个冲突的事务串行执行,安全性最高,效率也最低.

在实现上,串行化下没有快照读,它的每个普通select语句后面都会自动加上lock in share mode,使之持有S锁,阻塞其他访问相同资源的事务.

这里顺便说下,MySQL的分布式事务方案使用的是XA事务,也就是二阶段提交,对于隔离性的要求比较高,所以使用的是串行化级别.

不过在微服务概述那篇文章里面有提到,分布式事务一般不会使用mysql,因为这样太慢了.