事务隔离与行锁与更新操作
之前有提到事务启动时会创建一个视图,即使有其他事务修改了事务,在这个事务中看到的和启动时仍然是一样。但是当事务 A 和事务 B 更新用一条记录时会发生冲突,先者会阻塞后者,后者会陷入等待状态。那么在这种情况下事务 B 先修改了记录 R,那么事务 A 看到的记录 A 还是它刚开启事务的时候的值吗?
此视图非彼视图
我们讲的事务的多并发控制中,事务每次启动时创建的视图并不是通常说的视图 view,view 是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
这个视图指的是InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。作用是事务执行期间用来定义“我能看到什么数据”。
一致性视图的组成
在不可重复度的隔离级别下,事务每次启动会创建一致性视图,那么在创建期间事务具体做了什么呢?或者说一致性视图是怎么组成的呢?
首先事务在启动的时候就“拍了个快照”,而这个快照是基于整个库的。在解释快照的实现之前必须知道:
- InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
- 而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。(这个信息就是回滚日志 undo log)
图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。而图中的三个虚线箭头就是 undo log。
快照的实现如下:
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。
这个视图数组把所有的 row trx_id 分成了几种不同的情况。这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分,那就包括两种情况 a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见; b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见(b 的意思应该是快照的时候事务 A 还没提交,但在当前事务提交之前,事务 A 提交了)。
我们前面说过快照是整个库级别的,那么一个库那么大,它是怎么做到的呢?现在就有答案了:
InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
现在为了验证上面的结论,做如下假设:
- 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
- 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
- 三个事务开始前,(id=1,k=1) 这一行数据的 row trx_id 是 90。
当事务 A 执行 get k 时会有下面的流程:
- 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见; 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见; 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。
这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。这也验证了可重复读隔离级别为什么能避免脏读(A 读不到 B 更新过后的数据,因为 A 在执行 get k 的时候事务 B 还未提交)
但是现在还有一个问题:事务 C 执行完更新语句就提交了,为什么事务 B 在更新后 k = 3 呢?(按道理事务 B 在创建的时候,事务 C 还没开始,所以事务 B 的一致性视图里数组不包含事务 C,可在更新时却还是读到了事务 C 更新后的数据)。
注意在数据的多版本中,不管事务有无提交数据更新都会被记录为一个版本。
之所以如此是因为更新是另一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。所以事务 B 读到的是事务 C 提交的最新值。
而在事务 B 更新后,事务 B 继续执行 get k,k = 3,是因为这个时候的最新值是事务 B 更新后的,row trx_id 就等于事务 B 的 transaction id。
而除了更新语句是当前读外,select 语句如果加锁也是当前读,也就是说加了锁就是当前读(因为更新会加行锁)
下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。所以它们都可以读到版本号是 101 的数据。
1
2
mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;
而当出现下面的情况:事务 C’ 修改 k 后并没有提交,这时候事务 B 修改 k 会发生什么呢?
这个时候两阶段锁就发挥作用了:由于事务 C’ 没提交,所以行锁不会释放,那么事务 B 就会被锁住进入等待状态。
到这里,我们把一致性读、当前读和行锁就串起来了。
可重复读的能力是怎么实现的?
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
读提交的能力是怎么实现的?
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
也就是说区别在与快照的时机,读提交是在语句每次执行的时候,不可重复读是事务创建的时候。
所以事务 A 执行 get k 时,因为 101 未提交所以不可见,而 102 属于已提交可见。所以最终返回 k = 2。