我们先建一下实验环境(默认autocommit=1,隔离级别为可重复读),如下所示
//创建了一个表t,主键为id字段,不能为空,还有一个额外的字段k
mysql> CREATE TABLE `t` (
-> `id` int(11) NOT NULL,
-> `k` int(11) DEFAULT NULL,
-> PRIMARY KEY (`id`)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected, 2 warnings (0.04 sec)
//查看一下创建的表
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| people |
| products |
| t |
| user |
+----------------+
4 rows in set (0.00 sec)
//向表t中插入两条数据
mysql> insert into t(id, k) values(1,1),(2,2);
Query OK, 2 rows affected (0.01 sec)
Records: 2 Duplicates: 0 Warnings: 0
//查看表t中的数据
mysql> select * from t;
+----+------+
| id | k |
+----+------+
| 1 | 1 |
| 2 | 2 |
+----+------+
2 rows in set (0.00 sec)
接着开启三个事务,执行顺序如下:
事务A | 事务B | 事务C |
---|---|---|
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; | ||
update t set k=k+1 where id=1; | ||
update t set k=k+1 where id=1;select k from t where id=1; | ||
select k from t where id=1; | ||
commit; |
现在请你猜一下,每个事务查询中k的值是多少?
5!
4!
3!
2!
1!
答案揭晓…
如果都对了,请受小弟一拜,为什么事务B查出来的是3,而事务A查出来的是1,事务C查出来的是2呢?
要解释清楚这些,会涉及到非常多的概念。所以阅读起来会比较费劲,这个例子是我在极客时间的专栏里看到的,经历了好几次看睡着后,终于慢慢理清了,相信对理解MySQL的多版本并发控制,行锁,一致性视图,当前读都会有比较大的帮助。
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表
的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。比如上面例子里的事务A和事务B。
事务C没有显式地使用begin/commit,表示这个update语句本身就是一个事务,语句完成的时候会自动提交。
view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。
创建视图的语法是create view…,而它的查询方法与表一样。(你可以这样理解,如果把一个表看成是语言里的一个对象,这个对象里组合了其它对象,你在使用组合的对象的方法)
consistent read view,一致性视图。InnoDB用于实现MVCC,以支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别。
注:一致性视图没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。可能有点抽象,下面的“快照实现原理”中会具体说的。
多版本指的是一个记录(表中的一行)有很多版本,需要多版本是因为:有许多事务同时读或写同一条记录,如果不做处理,肯定会造成混乱,也就是需要实现事务的隔离。每个版本都与一个事务相关,InnoDB提供了一个管理这些版本的机制,也就是所谓的控制。你可以暂时这么粗略的理解。
接着1中的例子,从快照(snapshot)和MVCC之间的联系出发,说明事务查询表中记录的时候发生了什么,并解释开头给出的例子的结果。再看事务更新逻辑。最后总结一下这两者的区别。
在可重复读隔离级别下,事务在启动的时候就给整个数据库“拍了个快照”,就是当前数据库的全部内容被记录了。
这看上去不太现实啊。如果一个库有100G,那么我启动一个事务,MySQL就要拷
贝100G的数据出来,这个过程得多慢啊。可是,平时的事务执行起来很快啊。这是因为快照并不是通过简单的复制整个数据库,而是通过前面提到的多版本。
每行数据是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为rowtrx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它(通过undolog)。
InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向
InnoDB的事务系统申请的,是按申请顺序严格递增的。
从一个小例子出发简述MVCC,再看看快照实现的原理。
虚线框中的就是一条记录的多个版本,生成的版本与哪个事务相关,就在版本里隐式的保存事务id。事务中的语句更新都会生成相应的undo日志,通过undo日志(图中虚线箭头)可以回滚到旧的版本。实际上,V1,V2,V3并不是物理上真实存在的,而是每次需要根据当前版本和undo日志算出来的。 根据例子简单说了一下MVCC的过程,进入快照的实现原理。
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活
跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。 具体来说,一致性视图就是所有活跃事务ID和已经创建过的事务ID的最大值+1。而数据版本的可见性规则,就是基于数据的rowtrx_id和这个一致性视图的对比结果得到的。 可见的记录版本加上没有事务操作的记录就构成了数据库的快照。接下来进入数据版本的可见性规则。
数据版本的可见性规则,就是基于数据的rowtrx_id和这个一致性视图的对比结果得到的。这里的可见性是针对读(查询而非更新)而言的。
【已提交的事务】【未提交事务】【未开始事务】
将未提交事务中的最小事务ID值称为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。
视图数组(每个事务都有一个视图数组)把所有的rowtrx_id 分成了几种不同的情况。
InnoDB 利用了 “所有数据都有多个版本 ”的这个特性,实现了 “秒级创建快照 ”的能力。
现在来看下开头的例子,假设在开启事务A之前的活跃事务只有90,记录版本为[id=1,k=1,row trx_id=90]。事务A到事务C的事务ID分别为100, 101, 102,事务C最先执行update语句创建了一个版本[id=1,k=2,row trx_id=102],自动提交;
然后事务B执行到update语句(更新没有所谓的可见性,更新是当前读),所以事务B创建的一个新版本为[id=1,k=3,row trx_id=101],之后事务B查询,当前版本为[id=1,k=3,row trx_id=101],对事务B可见,因为是当前事务,所以查出来的k=3;
然后事务A执行查询语句,版本[id=1,k=3,row trx_id=101]对事务A不可见,因为101等于高水位,对事务A而言是未开始的事务,版本[id=1,k=2,row trx_id=102]对A不可见,因为102大于高水位,版本[id=1,k=1,row trx_id=90]对A可见,因为90小于低水位,所以查出来k=1;
这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。
更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。 上一节中事务B更新时就是直接读了[id=1,k=2,row trx_id=102], 除了update语句外,select语句如果加锁,也是当前读。如果事务A的查询改成
select k from t where id=1 lock in share mode;
或者
select k from t where id=1 for update;
查出来的就是k=3。
两阶段锁
在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
将开头的例子改为如下:
事务A | 事务B | 事务C’ |
---|---|---|
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; update t set k=k+1 where id=1; | ||
update t set k=k+1 where id=1;select k from t where id=1; | ||
select k from t where id=1; commit; | commit; | |
commit; |
事务C’没提交,也就是说[id=1,k=2,row trx_id=102]这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。
所以,这时候事务A执行的结果为k=1,事务B的执行结果为k=3;
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。