前言
大概两年前因为排查一个数据可见性的问题也了解过 Mysql 的 MVCC,当时觉得自己都懂了,但始终没有将这块知识串联起来,所以总感觉印象不深刻,其实本质上还是因为没有搞清楚这个东西的来龙去脉,没有真正地理解它。
现在我也没法说都懂了,只能说比以前的我更进步了些。
本文主要包含以下内容:
- 事务常见的并发问题
- 事务隔离级别,在不同程度上解决了并发问题
- 传统事务隔离级别,基于锁实现并发控制
- MVCC 无锁实现并发控制
- 幻读
并发问题
多个事务并发执行,常见的问题如下,
读/写或写/读,这种场景最复杂,可能出现如下问题:
- 一个事务读到另外一个事务未提交的修改,称为脏读
- 一个事务中前后两次对同一记录的读取结果不一致,称为不可重复读
- 一个事务中前后两次使用同样的查询条件读取数据,期间其它事务往表里插入了新数据,导致前后两次查询到的结果集不一致,称为幻读
将这些并发问题根据严重程度由高至低排序。
丢失更新 > 脏读 > 不可重复度 > 幻读
事务隔离级别
事务隔离级别在不同程度上解决了上述并发问题,保证事务本身特性(ACID),它是对并发问题的容忍性以及并发性能之间的权衡考量。
- 读未提交(RU),允许读取未提交的记录,会发生脏读、不可重复读、幻读问题
- 读已提交(RC),只允许读物已提交的记录,解决了脏读问题,但会出现不可重复读、幻读问题
- 可重复读(RR),不会发生脏读和不可重复读的问题,但会发生幻读问题
- 串行化(S),事务串行执行,不存在并发问题,当然性能也最差
以上隔离级别,除串行化不会存在丢失更新的问题,其它几种隔离级别都存在该问题,需要通过对读操作额外加锁解决。
基于锁的实现
传统的事务隔离级别是基于锁实现的。
按照锁的互斥性,可以把锁分为:
- 共享锁(Shared Locks),简称 S锁
- 排它锁(Exclusive Locks),简称 X锁
SS 不互斥,XS 互斥,XX 互斥,多个事务可以同时持有同一把 S锁。
按照锁的粒度,可以把锁分为:
- 行锁,只锁定某一行记录,其他行不受影响
- 表锁,锁住整个表
按照锁的持有时间,可以把锁分为:
- 短锁,操作完成后立刻被释放
- 长锁,直到事务提交后才被释放
四种隔离级别,对应的实现方式如下,
-
读未提交:读操作不加锁,写操作加上X锁直到事务提交后释放。 -
读提交:读操作加上S锁(且为短锁,操作完马上释放),写操作加上 X锁直到事务提交后释放。 -
可重复读:读操作加上S锁(且为长锁,事务提交后被释放),写操作加上 X锁直到事务提交后释放。 -
串行化:读写操作都加上X锁直到事务提交后释放,且锁的粒度为表锁。
可以在大脑里模拟下不同隔离级别的加锁场景,有助于加深对于并发控制的理解。
举个例子,读提交为什么能够解决脏读,因为它在读操作的时候需要加S锁,写操作需要加X锁,如果此时有其它未提交的事务修改了数据,它是没办法读取到数据的,需要等待其它事务提交或者回滚。
MVCC 实现
MVCC 英文全称 Multiversion Concurrency Control,即多版本并发控制,它通过无锁的方式实现了读提交,可重复读两种隔离级别下对于并发问题的控制,因此在系统性能方面也有显著的提升。
另外读未提交,串行化这两个隔离级别,不在 MVCC 的作用范围内,MVCC 侧重于优化读提交,可重复读隔离级别下读,写操作加锁互斥的性能问题。
MVCC 的核心实现主要包含两块内容:
多版本
为了保证在不加锁的情况下,解决读、写操作并发问题,MVCC 引入了多版本的概念,即一个数据行同一时间可能存在多个版本。
在 InnerDB 引擎下,数据行都会包含两个重要的隐藏列:
- 事务ID(DB_TRX_ID):当事务对记录进行修改时,会把当前事务的事务ID记录到DB_TRX_ID中
- 回滚指针(DB_ROLL_PTR):当事务对记录进行修改后,会把该记录的旧版本记录到undo日志中,通过回滚指针可以获取旧版本信息。
多个版本可以来源于不同事务,它们之间互不影响,这些版本按照先后顺序,被回滚指针被串联起来。
读视图
事务当前能够读取到哪个版本,则依赖读视图。
读视图本质上是一个数组,其中维护了一组事务id,例如[3, 4, 5, 6],
- 3 代表视图创建时存在的活跃事务中id最小值,称为 min_id
- 6 代表未来要创建的事务id,称为 max_id
- 其余的都是活跃事务id
事务在读取版本记录时,会拿该版本上的事务id与视图做比较:
- 如果该版本的事务id < min_id,则表示该版本在视图创建前已经提交,故该版本对当前事务可见
- 如果该版本的事务id >= max_id,则表示该版本所属的事务在视图创建时还不存在,是未来创建的事务,故该版本对当前事务不可见
- 如果 max_id > 该版本的事务id > min_id,则表示该版本所属的事务在视图创建时还未提交,故该版本对当前事务不可见
- 当然如果该版本的事务id等于当前事务id,则认为该版本是由当前事务提交的,该版本对当前事务可见
如果发现符合条件的版本,直接返回;否则会通过回滚指针继续查找上一个版本的记录,直到将版本链遍历完还未找到符合条件的版本,则返回空。
读提交,可重复读两种隔离级别主要区别在于创建读视图的时机:
- 读提交:每次读取数据都会创建新的视图,因此前后两次读取的结果可能不一致,期间别的事务提交的内容也能读到
- 可重复读:只有在事务开启后首次读取数据会创建视图
注意:由于 MVCC 是采用快照读的方式进行并发控制,读到的数据可能不是最新版本,如果业务上需要读取最新版本的数据,可以通过为读操作加锁强制当前读。
幻读
MVCC 无法直接解决幻读,只有串行化才能解决幻读,但串行化也带来极大的性能问题。
后来间隙锁出现了,于是出现了 MVCC + 间隙锁的解决方案,在避免幻读的同时,还能保证性能。
举个例子,查询某记录是否存在,不存在,准备插入此记录,但执行 插入时发现此记录已存在(其它事务插入了),导致无法插入,此时就发生了幻读。
可以通过如下方式为查询加锁:
select * from table where id = 1 for update;
如果 id = 1 的记录已经存在,则对该记录加上行锁;如果不存在,则会把 id = 1 这个间隙锁上,其它事务无法对该间隙进行插入操作,故解决了幻读问题,当然实际上间隙锁要复杂得多。
通过这次对 MVCC 相关知识点的梳理串联,让自己发现了不少之前忽略的问题,也对 MYSQL 的并发控制机制有了更系统的认识。
|