@adamhand
2019-03-15T09:47:40.000000Z
字数 3273
阅读 1026
如果是可重复读隔离级别,事务 T
启动的时候会创建一个视图 read-view
,之后事务 T
执行期间,即使有其他事务修改了数据,事务 T
看到的仍然跟在启动时看到的一样。
但是,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?
下面先看一个问题。
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
这个命令。如果设置autocommit=1
,上面的图在可重复读的隔离级别下,事务A和事务B最后得到的数据是什么呢?
先说答案,A
读到的数据是1
,B
读到的数据是3
。
要分析上面的结果,需要先看一下几个概念。
InnoDB
里面每个事务有一个唯一的事务 ID
,叫作 transaction id
。它是在事务开始的时候向 InnoDB
的事务系统申请的,是按申请顺序严格递增的。
参考MySQL官方手册,InnoDB
为每一行数据都添加了三个隐藏字段:
DB_TRX_ID
:DB_TRX_ID
是该数据行的事务ID,每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id
赋值给它;因为它是行的TRX_ID
,下面的 row trx_id
和它是一个意思。DB_ROLL_PTR
:即回滚指针,这个指针指向了该行回滚段中的undo_log
。DB_ROW_ID
:行ID,数据表在InnoDB
的底层存储结构为B+树,而B+树需要根据主键来生成聚集索引,如果数据表的创建者未定义主键,那么InnoDB将会默认DB_ROW_ID
作为主键来生成聚集索引;MVCC
是根据DB_ROLL_PTR
、DB_TX_ID
这两个字段(还有一个在“特殊位置”的删除标记)来构建事务可视版本(即快照,read-view
)的。
下图中的V1、V2、V3、V4
一个记录被多个事务连续更新后的状态。图中的三个虚线箭头,就是 undo log
;而 V1、V2、V3
并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log
计算出来的。
快照是基于整个库的,但是为什么即使库很大,“拍快照”的时间却很短?其实InnoDB
利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。下面从read view的源码看一下
。
struct read_view_t{
// 由于是逆序排列,所以low/up有所颠倒
trx_id_t low_limit_id; // 能看到当前行版本的高水位标识,> low_limit_id皆不能看见
trx_id_t up_limit_id; // 能看到当前行版本的低水位标识,< up_limit_id皆能看见
ulint n_trx_ids; // 当前活跃事务(即未提交的事务)的数量
trx_id_t* trx_ids; // 以逆序排列的当前获取活跃事务id的数组,其up_limit_id<tx_id<low_limit_id
trx_id_t creator_trx_id; // 创建当前视图的事务id
UT_LIST_NODE_T(read_view_t) view_list; // 事务系统中的一致性视图链表
};
文章一开始的哪个问题其实就是行记录的可见性问题。上述源码中与可见性相关的两个参数为:low_limit_id
和up_limit_id
。
InnoDB
为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID
。“活跃”指的就是,启动了但还没提交。数组里面事务 ID
的最小值记为低水位(up_limit_id
),当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(low_limit_id
)。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)
。
而数据版本的可见性规则,就是基于数据的 row trx_id
和这个一致性视图的对比结果得到的。如下图所示:
row trx_id<up_limit_id
,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;row trx_id>low_limit_id
,表示这个版本是由将来启动的事务生成的,是肯定不可见的;row trx_id
在数组中,表示这个版本是由还没提交的事务生成的,不可见;row trx_id
不在数组中,表示这个版本是已经提交了的事务生成的,可见。有点晕,用白话总结一下,一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外可见性规则有四个,可以按照以下四个规则来判断可见性(可重复读版本):
current read
)。因此,更新完之后在执行select
操作,就能查到最新的数据,因为自己的更新总是可见。用这四条规则分析刚开始的问题,就能通了。
上面提到一个“当前读”的概念,当前读就是读取当前最新的数据,当前读需要加锁,除了updata
语句,select
语句也可以当前读:
mysql> select k from t where id=1 lock in share mode; //加读锁(S锁,共享锁)
mysql> select k from t where id=1 for update; //加写锁(X锁,排他锁)
而普通的select
语句都是快照读。
假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?
和前面的不同主要在B和C上,C开启事务之后。执行update
语句会拿到行的锁,根据两阶段锁协议,在commit
之前它是不会释放锁的,所以,当B要执行update
时就需要等待C释放锁,之后才能拿到锁,执行当前读。
所以,可重复读的核心就是一致性读(consistent read
);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
所以开始的问题如果是在读提交的隔离级别下,A在select
时会创建一个新视图,这时C已经提交完成,所以此时C更新的数据对A是可见的,所以A会返回2。而B的返回值不变。
需要注意的是,“start transaction with consistent snapshot; ”
的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction
。
总结一下,“读提交”和“可重复读”的情况如下:
InnoDB存储引擎MVCC的工作原理
InnoDB多版本并发控制机制-MVCC底层实现
MySQL · 源码分析 · InnoDB的read view,回滚段和purge过程简介