事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一
隔离性与隔离级别
ACID
- Atomicity,原子性
- Consistency,一致性
- Isolation,隔离性
- Durability,持久性
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念
SQL 标准的事务隔离级别
- 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到
- 读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到,新闻线上数据库都是READ-COMMITTED
- 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。MySQL默认是REPEATABLE-READ
- 串行化(serializable ):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行
隔离的实现
事务启动的时候会创建一个视图,访问的时候以视图的逻辑结果为准
- 在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图
- 在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的
- 在“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念
- 而“串行化”隔离级别下直接用加锁的方式来避免并行访问
隔离示例
假设数据表 T 中只有一列,其中一行的值为 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
隔离的应用
假设你在管理一个个人银行账户表
一个表存了每个月月底的余额,一个表存了账单明细
候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致
你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果, 这时候使用“可重复读”隔离级别就很方便
事务启动时的视图可以认为是静态的,不受其他事务更新的影响
事务的启动
-
事务的启动方式
- 隐式启动事务语句,set autocommit = 1, 在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中
- 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。
- 如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。上边这种启动方式,一致性视图是在第执行第一个快照读语句时创建的; 这种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的
set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接
有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务
MySQL默认是autocommit = on
你可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
事务隔离的实现
在 MySQL 中,每条记录在更新的时候都会同时记录一条回滚操作(undo log)。记录上的最新值,通过回滚操作,都可以得到前一个状态的值,这样同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(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
- 图 2中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来
事务隔离是咋么实现的
- 按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。 因此,一个事务如果一个数据版本是在他启动之前生成的,就认;如果是他启动以后才生成的,就不认,必须通过undo log找到它的上一个版本”。 当然,如果“上一个版本”也不可见,那就得继续往前找
- 还有,如果是这个事务自己更新的数据,它自己还是要认的
- 在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交
- 数组里面事务 ID 的最小值记为低水位 low-water-mark,相对于已提交的事务就是up-limit-id,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位 high-water-mark 相对于未开始的事务就是low-limit-id
- 这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。 而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的
这个视图数组把所有的 row trx_id 分成了几种不同的情况
对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的
- 如果落在黄色部分,那就包括两种情况
- 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见
- 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见
比如,对于上图中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。有了这个声明后,系统里面随后发生的更新,就跟这个事务看到的内容无关了,因为之后的更新,生成的版本一定属于上面的 2 或者 3(1) 的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了
小插曲
MySQL中的两个视图
一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样
另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
回滚日志总不能一直保留吧,什么时候删除呢?
答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除就是当系统里没有比这个回滚日志更早的 read-view 的时候
为什么建议你尽量不要使用长事务
长事务意味着系统里面会存在很老的事务视图
由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间
在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。见过数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库
事务隔离下的查询&更新
建表语句:
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);
3个事务的执行时机:
假设
- .事务 A 开始前,系统里面只有一个活跃事务 ID 是 99
- 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务
- 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90
这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]
查询
事务 A 的语句返回的结果,为什么是 k=1?
为了简化分析,只画出跟事务 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 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读
更新
事务 B 的 update 语句,如果按照一致性读,好像结果不对。 你看图 5 中,事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?
- 如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1
- 但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了
- 因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作
- 这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)
- 因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101
- 所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3
这里我们提到了一个概念,叫作当前读。其实,除了 update 语句外,select 语句如果加锁,也是当前读
- 如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3(会进行所等待)
下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)
mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;
假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢
事务 C的不同是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先发起了。虽然事务 C’还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?
- 这个时候,“两阶段锁协议”就要上场了。
- C没提交,也就是说 (1,2) 这个版本上的写锁还没释放
- 而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读
到这里,一致性读、当前读和行锁就串起来了
读提交下的事务
上边说的例子是可重复读情况下的,读提交隔离级别下呢
先总结下可重复读
- 可重复读的核心就是一致性读(consistent read)
- 务更新数据的时候,只能用当前读
- 当前的记录的行锁被其他事务占用的话,就需要进入锁等待
读提交的逻辑和可重复读的逻辑类似,最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图
在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢
下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的 read view 框。(注意:这里,我们用的还是事务 C 的逻辑直接提交,而不是事务 C’)
- 这时,事务 A 的查询语句的视图数组是在执行这个语句的时候创建的,时序上 (1,2)、(1,3) 的生成时间都在创建这个视图数组的时刻之前。
- 但是,在这个时刻: (1,3) 还没提交,属于情况 1,不可见; (1,2) 提交了,属于情况 3,可见
- 所以,这时候事务 A 查询语句返回的是 k=2。 显然地,事务 B 查询结果 k=3
内容有点多,总结一下
- InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图
- 普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。
- 对于可重复读,查询只承认在事务启动前就已经提交完成的数据
- 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
- 而当前读,总是读取已经提交完成的最新版本,读未提交隔离用的就是当前读
"start transaction with consistent snapshot; "
意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction