@adamhand
2019-03-15T01:47:40.000000Z
字数 3273
阅读 1272
如果是可重复读隔离级别,事务 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_idtrx_id_t creator_trx_id; // 创建当前视图的事务idUT_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过程简介