事务隔离级别提到,如果是可重复读,事务T启动时会创建一个视图read-view。 之后事务T执行期间,即使有其他事务修改了数据,事务T看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像不受外界影响。
但是行锁时候又提到,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待。 既然进入了等待,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是啥?
举个例子。下面是个只有两行的表的初始化语句。
注意事务的启动时机。
begin/start transaction 命令并非事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真启动。 如果想立即启动事务,可使用
start transaction with consistent snapshot
没有特别说明,都默认autocommit=1。
事务C没显式使用begin/commit,表示这个update语句本身就是一个事务,语句完成时会自动提交。事务B在更新行后查询; 事务A在一个只读事务中查询,并且时间顺序上是在事务B的查询后。
事务B查到的k的值是3,而事务A查到的k的值是1,你是不是感觉有点晕呢?
在MySQL,有两个“视图”的概念:
今天说明查询和更新区别,把read view拆开。更深一步地理解MVCC。
可重复读下,事务在启动时就“拍了个快照”。该快照基于全库。
你可能觉得不太好吧!如果一个库100G,启动个事务,MySQL就拷贝100G数据,得多慢! 可我平时事务执行好像很快啊!
事实上无需拷贝出这100G的数据。我们先来看看这个快照是怎么实现的。
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。
前面提过更新语句会生成undo log(回滚日志),它在哪? 图中三个虚线箭头,就是undo log;V1、V2、V3并不是物理上存在的,而是每次需要时根据当前版本和undo log算出的。 比如需要V2时,通过V4依次执行U3、U2算出。
按可重复读定义,一个事务启动时,能够看到所有已提交的事务结果。但之后,这个事务执行期间,其他事务的更新对它就不可见了。
因此一个事务只需在启动时声明:以我启动时刻为准
InnoDB为每个事务构造一个数组,保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。
“活跃”:启动了但尚未提交。
数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。
这视图数组和高水位,就组成为当前事务的一致性视图(read-view)。 而数据版本的可见性规则,就基于数据的row trx_id和这个一致性视图的对比结果得到。
这个视图数组把所有的row trx_id 分成了几种情况。
考虑当前事务的启动瞬间,一个数据版本的row trx_id,可能有以下几种情况:
举例,对于上面的行状态变更图中的数据,若有个事务,它的低水位是18,当它访问这行数据时 因为启动后才生成的 tr_id25,所以不认!必须再追溯上个版本: 就会从V4通过U3计算出V3; 而 V3 的 tr_id17,是在启动前生成的,所以认! 所以它看来,这行值11。
有这声明后,系统里随后发生的更新,是不是跟这事务看到的内容无关了? 因为之后的更新,生成的版本一定属于上面的2或者3(a)情况,在它它看来,这些新数据版本都是不存在的,所以这事务的快照,就是“静态”的了。
现在知道了吧,InnoDB利用“所有数据都有多版本”的特性,实现了“秒级创建快照”能力。
接下来,我们继续看一下 图-事务A、B、C的执行流程 中的三个事务,分析事务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查询语句的读数据流程是这样的:
执行下来,虽然期间这一行数据被后启动的事务们修改过了,但事务A不论在何时查询,看到这行数据的结果都一致,所以称之为一致性读。
一个数据版本,对一个事务视图来说,除了自己的更新总是可见外的
用这规则来判断 图-事务A查询数据逻辑 的查询结果,事务A的查询语句的视图数组是在事务A启动时生成,这时:
去掉数字对比,只用时间先后顺序判断,分析起来轻松多了! 以后就用这套规则分析!
有人可能疑问:事务B的update语句,如果按照一致性读,好像结果不对哦?
看上图,事务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 是 B 写的 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’的不同于之前的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)生成时间都在创建这个视图数组的时刻前。 但是,在这个时刻:
所以,这时事务A查询语句返回k 2。 显然事务B查询结果k 3。
InnoDB的行数据有多版本。 每个数据版本有自己的row trx_id。 每个事务或者语句有自己的一致性视图。
普通查询语句是一致性读 一致性读根据row trx_id和一致性视图确定数据版本的可见性。
当前读,总读取已经提交完成的最新版本。
因为表结构没有对应行数据,也没row trx_id,只能遵循当前读逻辑。
当然,MySQL 8.0已经可以把表结构放在InnoDB字典里,也许以后会支持表结构的可重复读。
在一个连接中循环执行20次 delete from T limit 500? 确实这样的,第二种方式相对较好的。
第一种方式,直接delete from T limit 10000 单个语句占用时间长,锁时间也较长;而且大事务还导致主从延迟。
第三种方式,在20个连接中同时执行delete from T limit 500),会人为造成锁冲突。
如果可以加上特定条件,将这10000行天然分开,那就可考虑第三种而不会锁冲突。在操作的时候也建议尽量拿到ID再删除。