MVCC基本实现原理以及与事务隔离级别的关联

1. 基础知识

1.1 常规读和带锁读

  1. 带锁读(当前读):如select .. lock in share modeselect .. for update、以及隐含当前读的insertupdatedelete等(读出来才能进行更新/删除/唯一索引判断等)
  2. 常规读(一致性读):如常用的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=1select * 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]

  1. 找到可以插入的数据页;
  2. 写入记录:[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]

  1. 找到对应记录的所在记录页;
  2. 修改记录为:[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]

注:两条undo log记录可能不在同一个undo页中

例三:当前事务id=30,删除:[id=1]

  1. 找到对应记录的所在记录页;
  2. 修改记录为:[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的具体记录字段可以稍微了解下:

  1. insert into... :含主键;
  2. delete .. :含所有字段的之前的值。
  3. 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查询基本流程

基于数据快照和多版本数据,查询的大概过程为:

  1. 触发事务快照
  2. 根据查询条件找到的数据页中的记录,获取该数据的版本号(即写入该记录的事务trx_id
  3. 基于快照,判断这个写入记录的事务(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);
  }
}

假设时间上有那么三个写操作,

  1. 插入记录:[id=1, name=zhangsan] ,操作的事务trx_id = 10
  2. 更新记录为:[id=1, name=lisi],操作的事务trx_id = 20
  3. 删除该记录:操作的事务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的清除:

  1. 对于运行中事务引用到的undo log,不可以清除,因为可能要用于回滚;
  2. 对于插入产生的undo log,在对应写事务结束后便可以删除了;因为对于"insert"类型的undo对于其他事务来讲等同于空(插入之前的数据自然是空的)。
  3. 对于其他类型的undo log,将对被定期清除(Purge),前提是要确定当前所有的事务快照不会再有机会用到(到达)该版本的数据了。

可以看到,事务快照不变时,看到的数据将始终停留在某一个版本的

  • 隔离级别为【可重复读】时,一旦获取快照后,会一直用这个快照,从而保证不会出现【不可重复读】。基于快照,如果插入数据的事务尚未提交,也是不可见的,这也就避免了【幻读】。
  • 隔离级别为【提交读】时,每次常规读(select)都会创建一个新的事务快照,所以每次读到的都是最新快照时刻的数据;这也就导致了【不可重复读】。
  • 隔离级别为【未提交读】时,并没有使用快照,而是无论事务有无提交,直接读数据页中的行记录作为结果(undo页的数据都不管),从而导致【脏读】。

3. 总结

  • 带锁读通过加锁保证事务特性,而常规读通过MVCC实现;
  • 当执行写SQL时,除了写数据页,还会记录undo log;undo log可用于以前版本数据的回溯;
  • 同一个事务快照,常规读 读到的数据 一直都是一致的;
  • 针对不同的隔离级别,常规读时:
    • 未提交读:直接读取当前数据页的数据(可能脏读);
    • 提交读:每次读都建立新快照,会读到已提交的、最新的数据(无脏读、可能不可重复读)
    • 可重复读:只会在第一次读时(或start transaction with consistent snapshot)建立快照,之后的常规读均基于该快照(无脏读、无不可重复读、无幻读)
    • 串行化:主动开启事务查询时,会将常规select将被转换为select .. lock in share mode,通过加锁的方式(参考可重复读)保证事务特性(无脏读、无不可重复读、无幻读)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容