1. 基础知识
1.1 常规读和带锁读
- 带锁读(当前读):如
select .. lock in share mode
、select .. for update
、以及隐含当前读的insert
、update
、delete
等(读出来才能进行更新/删除/唯一索引判断等) - 常规读(一致性读):如常用的
select ...
【带锁读】通过加锁的方式保证事务隔离特性(有无脏读/不可重复读/幻读等);
【常规读】则是通过 多版本并发控制机制(MVCC,Multi-Version Concurrency Control)实现。
插入/更新/删除等写操作时:既会加锁保证【带锁读】的隔离特性;也会备份之前版本的数据用于MVCC,以保证【常规读】的隔离特性(详见本文第二节)。
1.2 事务隔离级别
隔离级别 | 脏读 (Dirty Read) |
不可重复读 (Non-Repeatable Read) |
幻读 (Phantom Read) |
---|---|---|---|
未提交读 (UNCOMMITTED) |
✓ | ✓ | ✓ |
提交读 (READ COMMITTED) |
- | ✓ | ✓ |
可重复读 (REPEATABLE READ) |
- | - | - |
串行化 (SERIALIZABLE) |
- | - | - |
值得注意的是:MySQL InnoDB中默认的隔离级别【可重复读】下,是不存在幻读问题(带锁读/常规读下的幻读)。
本事务读到其他事务尚未提交的数据时,称之为【脏读】。
这里的【脏】指的是【未提交的数据】,这个和读到【过期的数据】是不同的。
比如某个时刻,a=1 已经被其他事务更新成 a=2 且提交了,而我这个事务还是读到a=1,这就是读到过期数据了,可以称之为【过期读】。
而如果其他事务更新 a=2 尚未提交,我这个事务就读到了a=2,这个就是【脏读】了。
脏读通常是不可容忍的,除非有特殊要求,否则隔离级别一般不会设置为【未提交读】。
同一个事务中,同样的SQL,多次查询,查询结果不一样时,称之为【不可重复读】。
例如 同一个事务中,第一次查询 [name=zhangsan] 但是第二次查就变为了:[name=lisi]
相反地,如果同一个事务中,每次查询结果都不会变时,自然就是【可重复读】了。
【可重复读】隔离级别下,读到的数据有可能是过期的,但不会是脏读。
类似
select * from t where id=1
和select * from t where id=1 for update
并不属于同样的SQL。所以哪怕是在【可重复读】的隔离级别下,同一个事务中,这两条SQL查询结果不一样也是正常的。
同一个事务中,同样的SQL,多次查询,查询的结果集不一样时,称之为【幻读】
例如 同一个事务中,第一次查询结果为一行,但是第二次查询就变成两行了。
【不可重复读】关注的是某行内容是否发生变化,而【幻读】则关注行数量是否发生变化。
注:本文幻读的含义主要参考MySQL官网文档:14.7.4 Phantom Rows ;即快照读和当前读 认为是不同的 query.
2. MVCC
2.1 多个版本的行数据
InnoDB中记录数据的基本单位为页(InnoDB Page,默认16KB),页的类型有有多种的,比如存储当前数据的数据页(B-Tree Node)、存储逻辑回滚/备份数据的undo 页(Undo Log Page)等。
当执行insert/update/delete
等写操作时,除了要修改对应数据页之外,还会对之前的数据进行备份(记录至undo页中)。如果事务需要回滚,找到对应的undo 记录进行应用回滚即可。
注意:哪怕事务尚未提交,写操作也会立即修改当前的数据页。所以回滚要到undo log中找。
显然,行数据是会有多个版本的(当前数据页 + undo页),为了区分各个版本的数据,每一行记录都会额外多出一个隐藏的版本号字段(trx_id),trx_id即对应写操作的事务id。
每个事务都能分配到一个全局递增的事务id(trx_id),当该事务进行写操作时,会将该值一并写入行记录中(见下例)。
例一:当前事务id=10,插入:[id=1, name=zhangsan]
:
- 找到可以插入的数据页;
- 写入记录:
[id=1, name=zhangsan, trx_id=10]
,
同时生成undo log:[log_type="insert", id=1]
*回滚*时:找到undo log进行应用,删除id=1
的记录(插入的反操作)。
例二:当前事务id=20,更新:[set name=lisi where id = 1]
- 找到对应记录的所在记录页;
- 修改记录为:
[id=1, name=lisi, trx_id=20]
同时生成undo log:[log_type="update", id=1, name=zhangsan, trx_id=10]
*回滚*时:找到undo log进行应用,反向更新数据回 [id=1, name=zhangsan, trx_id=10]
例三:当前事务id=30,删除:[id=1]
- 找到对应记录的所在记录页;
- 修改记录为:
[id=1, name=lisi, trx_id=30, delete_flag=1]
,
同时生成undo log:[log_type="update", id=1, name=lisi, trx_id=20, delete_flag=0]
执行删除SQL时,并不是直接将记录从数据页中抹掉,而是通过一个删除位(delete_flag)来进行标识,将该字段置为1即标识这行数据已经被删除了;同时和其他写一样会记录操作事务的trx_id。
*回滚*时:找到undo log进行应用,反向更新数据回 [id=1, name=lisi, trx_id=20, delete_flag=0]
undo log的具体记录字段可以稍微了解下:
insert into...
:含主键;delete ..
:含所有字段的之前的值。update ..
:含需要更新字段的之前的值;
如果是更新主键,等同于将之前行记录的删除,然后再插入,将产生两条undo log。
undo log除了用于备份数据支持事务回滚之外,其数据多版本的特性与事务快照结合之后,将可以用于支持事务隔离的相关特性(比如避免脏读/不可重复读/幻读等)。
2.2 事务快照
大家应该拍过照片,按下快门,我们就可以将当前时刻的景物记录到一张小小的照片,尽管时光荏苒,岁月变迁,照片中的景物也不会发生变化。
如果我们给数据库中的事务拍一张照片的话,我们会看到:在拍照的那一瞬间,有的事务已经提交,有的正在运行中,有的事务尚未开始:
就如上图中的快照,trx_id小于15的事务都已经提交了,大于等于31的则尚未开始;中间的15/25还在跑,而20/30已经提交。
如果你现在的事务id为25,当隔离级别为【可重复读】时:你能到哪些事务修改的数据呢?答案是显然的,已经提交的则看得见(图中黑色),还没提交的自然就看不见,否则就是脏读了。
可时间是会变化的,假设后来15进行了提交,那我们能否看得见该事务的修改记录呢(比如 a=1 修改为了 a=2)?这个也是应该看不见的,因为如果事务15提交前我们看到的是a=1,而提交后变为a=2了,这就出现了不可重复读了,这显然和【可重复读】相悖了。
事实上,正如前面所说时间会变但照片不变一样,一旦我们拍下事务快照之后,id=15的事务对于咱们来讲,“它一直都是处于未提交的”(除非我们重新拍过另外一张快照)。
在【可重复读】隔离级别下,一旦触发快照后,这个快照会一直存在,直至事务结束。哪些事务已提交,哪些没提交,也会在这一瞬间定格。这也就保证了我们永远都在同一张照片里面“找”数据,从而保证了【可重复读】。
接下来我们来看一下怎样基于事务快照来“找”数据。
注:【可重复读】隔离级别下,事务快照的触发时机主要有:
- 开启事务后(
begin/start transaction;
),执行第一条常规读SQL(select
)时;- 开启事务时,直接开启快照:
start transaction with consistent snapshot
.
2.3 MVCC查询基本流程
基于数据快照和多版本数据,查询的大概过程为:
- 触发事务快照;
- 根据查询条件找到的数据页中的记录,获取该数据的版本号(即写入该记录的事务trx_id)
- 基于快照,判断这个写入记录的事务(trx_id)对于快照来讲是否可见:
3.1 如果可见,则返回结果;
3.2 如果不可见,继续找下一个版本的数据。
我们可以用一个简单的数据结构(Read View)来记录事务快照(建议结合上节的事务快照图看):
Read_View {
// 最小的事务id,数据版本号 < min_id 表示可见
long min_id;
// 最大的事务id,数据版本号 >= max_id 表示不可见
long max_id;
// 中间还在跑的事务id,数据版本号在里面则表示不可见(排除本事务,自己肯定看得到自己修改的记录)
long[] running_ids;
// 是否可见
bool canSee(long data_version_trx_id) {
return data_version_trx_id < min_id || !running_ids.contains(data_version_trx_id);
}
}
假设时间上有那么三个写操作,
- 插入记录:
[id=1, name=zhangsan]
,操作的事务trx_id = 10
- 更新记录为:
[id=1, name=lisi]
,操作的事务trx_id = 20
- 删除该记录:操作的事务
trx_id = 30
都执行后,其数据多版本的一个呈现如下图:
如果期间有其他事务有触发过快照,基于【可重复读】的隔离级别,快照之后读到的数据都是一样的(同一个事务中)。我们来分析一下,等上面三个操作均执行完成之后,我们是怎么追溯回快照时刻的数据的。
例一:假设本事务在某个时刻建立了快照:[min_trx_id=40, max_trx_id=50, running_ids=[40]]
,而后在某个时刻发起查询select * from t where id=1
:
快照时刻,事务10/20/30均已经提交了,所以最新的修改记录就是事务30将这条记录给删了,这个“删除”的修改对于快照是可见的,所以结果返回空了。
例二:假设本事务在某个时刻建立了快照:[min_trx_id=15, max_trx_id=28, running_ids=[15]]
,而后在某个时刻发起查询select * from t where id=1
:
快照时刻,事务10/20已经提交,而事务30尚未开始,所以能看到所有已经提交中最新的记录,即事务20:更新记录为[id=1, name=lisi]
例三:假设本事务在某个时刻建立了快照:[min_trx_id=10, max_trx_id=28, running_ids=[10, 20]]
,而后在某个时刻发起查询select * from t where id=1;
:
快照时刻,事务30尚未开始,事务10/20均在运行中,均属于未提交;插入的事务(10)都尚未提交,所以都看不见,最终返回空。
关于undo log的清除:
- 对于运行中事务引用到的undo log,不可以清除,因为可能要用于回滚;
- 对于插入产生的undo log,在对应写事务结束后便可以删除了;因为对于"insert"类型的undo对于其他事务来讲等同于空(插入之前的数据自然是空的)。
- 对于其他类型的undo log,将对被定期清除(Purge),前提是要确定当前所有的事务快照不会再有机会用到(到达)该版本的数据了。
可以看到,事务快照不变时,看到的数据将始终停留在某一个版本的。
- 隔离级别为【可重复读】时,一旦获取快照后,会一直用这个快照,从而保证不会出现【不可重复读】。基于快照,如果插入数据的事务尚未提交,也是不可见的,这也就避免了【幻读】。
- 隔离级别为【提交读】时,每次常规读(
select
)都会创建一个新的事务快照,所以每次读到的都是最新快照时刻的数据;这也就导致了【不可重复读】。 - 隔离级别为【未提交读】时,并没有使用快照,而是无论事务有无提交,直接读数据页中的行记录作为结果(undo页的数据都不管),从而导致【脏读】。
3. 总结
- 带锁读通过加锁保证事务特性,而常规读通过MVCC实现;
- 当执行写SQL时,除了写数据页,还会记录undo log;undo log可用于以前版本数据的回溯;
- 同一个事务快照,常规读 读到的数据 一直都是一致的;
- 针对不同的隔离级别,常规读时:
- 未提交读:直接读取当前数据页的数据(可能脏读);
- 提交读:每次读都建立新快照,会读到已提交的、最新的数据(无脏读、可能不可重复读)
- 可重复读:只会在第一次读时(或
start transaction with consistent snapshot
)建立快照,之后的常规读均基于该快照(无脏读、无不可重复读、无幻读) - 串行化:主动开启事务查询时,会将常规
select
将被转换为select .. lock in share mode
,通过加锁的方式(参考可重复读)保证事务特性(无脏读、无不可重复读、无幻读)