以前在 面试必问的 MySQL,你懂了吗? 中简单的介绍了 MVCC 的原理,掌握了这个原理其实在面试时是能够加分很多的。html
由于如今不少人的理解仍是停留在《高性能 MySQL》书中的版本,也就是经过建立版本号和删除版本号来判断。这个时候若是你能给出正确的理解,则会让面试官眼前一亮,这也是咱们在面试中凸显出“本身和其余候选者不同的地方”,会更有利于在众多候选者中脱颖而出。mysql
本文在此基础上,对 MVCC 展开详细的分析,同时修改了以前的一些不太准确的说法,但愿能够助你在面试中更好的发(zhuang)挥(bi)。c++
PS:本文的源码基于MySQL 8.0.16,对于现阶段生产环境经常使用的 5.7.* 版本,MVCC 部分的源码基本相同,所以能够放心参考。而 5.6.* 则有比较大的不一样,主要是一些数据结构都改变了,可是究其核心原理仍是基本一致的。程序员
脏读:一个事务读取到另外一个事务更新但还未提交的数据,若是另外一个事务出现回滚或者进一步更新,则会出现问题。面试
不可重复读:在一个事务中两次次读取同一个数据时,因为在两次读取之间,另外一个事务修改了该数据,因此出现两次读取的结果不一致。sql
幻读:在一个事务中使用相同的 SQL 两次读取,第二次读取到了其余事务新插入的行。数据库
要解决这些并发事务带来的问题,一个比较简单粗暴的方法是加锁,可是加锁必然会带来性能的下降,所以 MySQL 使用了 MVCC 来提高并发事务下的性能。安全
试想,若是没有 MVCC,为了保证并发事务的安全,一个比较容易想到的办法就是加读写锁,实现:读读不冲突、读写冲突、写读冲突,写写冲突,在这种状况下,并发读写的性能必然会收到严重影响。markdown
而经过 MVCC,咱们能够作到读写之间不冲突,咱们读的时候只须要将当前记录拷贝一份到内存中(ReadView),以后该事务的查询就只跟 ReadView 打交道,不影响其余事务对该记录的写操做。数据结构
读未提交(Read Uncommitted):最低的隔离级别,会读取到其余事务还未提交的内容,存在脏读。
读已提交(Read Committed):读取到的内容都是已经提交的,能够解决脏读,可是存在不可重复读。
可重复读(Repeatable Read):在一个事务中屡次读取时看到相同的内容,能够解决不可重复读,可是存在幻读。可是在 InnoDB 中不存在幻读问题,对于快照读,InnoDB 使用 MVCC 解决幻读,对于当前读,InnoDB 经过 gap locks 或 next-key locks 解决幻读。
串行化(Serializable):最高的隔离级别,串行的执行事务,没有并发事务问题。
trx_sys_t:事务系统中央存储器数据结构
struct trx_sys_t {
TrxSysMutex mutex; /*! 互斥锁 */
MVCC *mvcc; /*! mvcc */
volatile trx_id_t max_trx_id; /*! 要分配给下一个事务的事务id*/
std::atomic<trx_id_t> min_active_id; /*! 最小的活跃事务Id */
// 省略...
trx_id_t rw_max_trx_id; /*!< 最大读写事务Id */
// 省略...
trx_ids_t rw_trx_ids; /*! 当前活跃的读写事务Id列表 */
Rsegs rsegs; /*!< 回滚段 */
// 省略...
};
复制代码
MVCC:MVCC 读取视图管理器
class MVCC {
public:
// 省略...
/** 建立一个视图 */
void view_open(ReadView *&view, trx_t *trx);
/** 关闭一个视图 */
void view_close(ReadView *&view, bool own_mutex);
/** 释放一个视图 */
void view_release(ReadView *&view);
// 省略...
/** 判断视图是否处于活动和有效状态 */
static bool is_view_active(ReadView *view) {
ut_a(view != reinterpret_cast<ReadView *>(0x1));
return (view != NULL && !(intptr_t(view) & 0x1));
}
// 省略...
private:
typedef UT_LIST_BASE_NODE_T(ReadView) view_list_t;
/** 空闲能够被重用的视图*/
view_list_t m_free;
/** 活跃或者已经关闭的 Read View 的链表 */
view_list_t m_views;
};
复制代码
ReadView:视图,某一时刻的一个事务快照
class ReadView {
// 省略...
private:
/** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id;
/** 低水位:小于这个ID的事务都可见 */
trx_id_t m_up_limit_id;
/** 建立该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 建立视图时的活跃事务id列表*/
ids_t m_ids;
/** 配合purge,标识该视图不须要小于m_low_limit_no的UNDO LOG,
* 若是其余视图也不须要,则能够删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
复制代码
为了实现 MVCC,InnoDB 会向数据库中的每行记录增长三个字段:
DB_ROW_ID:行ID,6字节,随着插入新行而单调递增,若是有主键,则不会包含该列。
DB_TRX_ID:事务ID,6字节,记录插入或更新该行的最后一个事务的事务标识,也就是事务ID。
DB_ROLL_PTR:回滚指针,7字节,指向写入回滚段的 undo log 记录。每次对某条记录进行更新时,会经过 undo log 记录更新前的行内容,更新后的行记录会经过 DB_ROLL_PTR 指向该 undo log 。当某条记录被屡次修改时,该行记录会存在多个版本,经过DB_ROLL_PTR 连接造成一个相似版本链的概念,大体以下图所示。
源码分析
在源码中,添加这3个字段的方法在:/storage/innobase/dict/dict0dict.cc 的 dict_table_add_system_columns 方法中,核心部分以下图。
当咱们更新一条数据,InnoDB 会进行以下操做:
加锁:对要更新的行记录加排他锁
写 undo log:将更新前的记录写入 undo log,并构建指向该 undo log 的回滚指针 roll_ptr
更新行记录:更新行记录的 DB_TRX_ID 属性为当前的事务Id,更新 DB_ROLL_PTR 属性为步骤2生成的回滚指针,将这次要更新的属性列更新为目标值
写 redo log:DB_ROLL_PTR 使用步骤2生成的回滚指针,DB_TRX_ID 使用当前的事务Id,并填充更新后的属性值
处理结束,释放排他锁
删除操做:在底层实现中是使用更新来实现的,逻辑基本和更新操做同样,几个须要注意的点:1)写 undo log 中,会经过 type_cmpl 来标识是删除仍是更新,而且不记录列的旧值;2)这边不会直接删除,只会给行记录的 info_bits 打上删除标识(REC_INFO_DELETED_FLAG),以后会由专门的 purge 线程来执行真正的删除操做。
插入操做:相比于更新操做比较简单,就是新增一条记录,DB_TRX_ID 使用当前的事务Id,一样会有 undo log 和 redo log。
源码分析
更新行记录的核心源码在:/storage/innobase/btr/btr0cur.cc/btr_cur_update_in_place 方法,核心部分以下图。
当咱们的隔离级别为 RR 时:每开启一个事务,系统会给该事务会分配一个事务 Id,在该事务执行第一个 select 语句的时候,会生成一个当前时间点的事务快照 ReadView,核心属性以下:
m_ids:建立 ReadView 时当前系统中活跃的事务 Id 列表,能够理解为生成 ReadView 那一刻还未执行提交的事务,而且该列表是个升序列表。
m_up_limit_id:低水位,取 m_ids 列表的第一个节点,由于 m_ids 是升序列表,所以也就是 m_ids 中事务 Id 最小的那个。
m_low_limit_id:高水位,生成 ReadView 时系统将要分配给下一个事务的 Id 值。
m_creator_trx_id:建立该 ReadView 的事务的事务 Id。
源码分析
MVCC 模式下的普通查询主方法入口在:/storage/innobase/row/row0sel.cc 的 row_search_mvcc 方法中,以后的全部源码分析基本都在该方法内。
具体建立视图的方法在 ReadView::prepare,调用链以下:
row_search_mvcc -> trx_assign_read_view -> MVCC::view_open ->
ReadView::prepare,源码以下:
最后,会将这个建立的 ReadView 添加到 MVCC 的 m_views 中。
视图可见性判断:SQL 查询走聚簇索引
有了这个 ReadView,这样在访问某条记录时,只须要按照下边的步骤判断记录的某个版本是否可见:
若是被访问版本的 trx_id 与 ReadView 中的 m_creator_trx_id 值相同,意味着当前事务在访问它本身修改过的记录,因此该版本能够被当前事务访问。
若是被访问版本的 trx_id 小于 ReadView 中的 m_up_limit_id(低水位),代表被访问版本的事务在当前事务生成 ReadView 前已经提交,因此该版本能够被当前事务访问。
若是被访问版本的 trx_id 大于等于 ReadView 中的 m_low_limit_id(高水位),代表被访问版本的事务在当前事务生成 ReadView 后才开启,因此该版本不能够被当前事务访问。
若是被访问版本的 trx_id 属性值在 ReadView 的 m_up_limit_id 和 m_low_limit_id 之间,那就须要判断 trx_id 属性值是否是在 m_ids 列表中,这边会经过二分法查找。若是在,说明建立 ReadView 时生成该版本的事务仍是活跃的,该版本不能够被访问;若是不在,说明建立 ReadView 时生成该版本的事务已经被提交,该版本能够被访问。
在进行判断时,首先会拿记录的最新版原本比较,若是该版本没法被当前事务看到,则经过记录的 DB_ROLL_PTR 找到上一个版本,从新进行比较,直到找到一个能被当前事务看到的版本。
而对于删除,其实就是一种特殊的更新,InnoDB 在 info_bits 中用一个标记位 delete_flag 标识是否删除。当咱们在进行判断时,会检查下 delete_flag 是否被标记,若是是,则会根据状况进行处理:1)若是索引是聚簇索引,而且具备惟一特性(主键、惟一索引等),则返回 DB_RECORD_NOT_FOUND;2)不然,会寻找下一条记录继续流程。
其实很容易理解,若是是惟一索引查询,必然只有一条记录,若是被删除了则直接返回空,而若是是普通索引,可能存在多个相同值的行记录,该行不存在,则继续查找下一条。
以上内容是对于 RR 级别来讲,而对于 RC 级别,其实整个过程几乎同样,惟一不一样的是生成 ReadView 的时机,RR 级别只在事务第一次 select 时生成一次,以后一直使用该 ReadView。而 RC 级别则在每次 select 时,都会生成一个 ReadView。
源码分析
走聚簇索引的核心流程在 row_search_mvcc 方法,以下:
视图可见性判断在方法:changes_visible,调用链以下:
row_search_mvcc -> lock_clust_rec_cons_read_sees ->
changes_visible,源码以下:
判断记录是否被打上 delete_flag 标的方法在:/storage/innobase/include/rem0rec.ic 的 rec_get_deleted_flag 方法中,以下图。
获取记录的上一个版本
获取记录的上一个版本,主要是经过 DB_ROLL_PTR 来实现,核心流程以下:
获取记录的回滚指针 DB_ROLL_PTR、获取记录的事务Id
经过回滚指针拿到对应的 undo log
解析 undo log,并使用 undo log 构建用于更新向量 UPDATE
构建记录的上一个版本:先用记录的当前版本填充,而后使用 UPDATE(undo log)进行覆盖。
源码解析
构建记录的上一个版本:trx_undo_prev_version_build,调用链以下:
row_search_mvcc -> row_sel_build_prev_vers_for_mysql -> row_vers_build_for_consistent_read -> trx_undo_prev_version_build,源码以下:
面试必问的 MySQL,你懂了吗? 只分析了走聚簇索引的状况,本文简单的介绍下走普通(二级)索引的状况。
当走普通索引时,判断逻辑以下:
判断被访问索引记录所在页的最大事务 Id 是否小于 ReadView 中的 m_up_limit_id(低水位),若是是则表明该页的最后一次修改事务 Id 在 ReadView 建立前之前已经提交,则必然能够访问;若是不是,并不表明必定不能够访问,道理跟走聚簇索引同样,事务 Id 大的也可能提交比较早,因此须要作进一步判断,见步骤2。
使用 ICP(Index Condition Pushdown)根据索引信息来判断搜索条件是否知足,这边主要是在使用聚簇索引判断前先进行过滤,这边有三种状况:a)ICP 判断不知足条件但没有超出扫描范围,则获取下一条记录继续查找;b)若是不知足条件而且超出扫描返回,则返回 DB_RECORD_NOT_FOUND;c)若是 ICP 判断符合条件,则会获取对应的聚簇索引来进行可见性判断。
源码分析
普通(非聚簇)索引的视图可见性判断在方法:lock_sec_rec_cons_read_sees,调用链以下:
row_search_mvcc -> lock_sec_rec_cons_read_sees,源码以下:
ICP 是 MySQL 5.6 引入的一个优化,根据官方的说法:ICP 能够减小存储引擎访问基表的次数 和 MySQL 访问存储引擎的次数,这边涉及到 MySQL 底层的处理逻辑,不是本文重点,这边不进行细讲。
这边用官方的例子简单介绍下,咱们有张 people 表,索引定义为:INDEX (zipcode, lastname, firstname),对于如下这个 SQL:
SELECT * FROM people
WHERE zipcode='95054'
AND lastname LIKE '%etrunia%'
AND address LIKE '%Main Street%';
复制代码
当没有使用 ICP 时:此查询会使用该索引,可是必须扫描 people 表全部符合 zipcode='95054' 条件的记录。
当使用 ICP 时:不只会使用 zipcode 的条件来进行过滤,还会使用 (lastname LIKE '%etrunia%')来进行过滤,这样能够避免扫描符合 zipcode 条件而不符合 lastname 条件匹配的记录行 。
ICP 的官方文档:dev.mysql.com/doc/refman/…
当前读:官方叫作 Locking Reads(锁定读取),读取数据的最新版本。常见的 update/insert/delete、还有 select ... for update、select ... lock in share mode 都是当前读。
官方文档:dev.mysql.com/doc/refman/…
快照读:官方叫作 Consistent Nonlocking Reads(一致性非锁定读取,也叫一致性读取),读取快照版本,也就是 MVCC 生成的 ReadView。用于普通的 select 的语句。
官方文档:dev.mysql.com/doc/refman/…
MVCC 解决了部分幻读,但并无彻底解决幻读。
对于快照读,MVCC 由于由于从 ReadView 读取,因此必然不会看到新插入的行,因此自然就解决了幻读的问题。
而对于当前读的幻读,MVCC 是没法解决的。须要使用 Gap Lock 或 Next-Key Lock(Gap Lock + Record Lock)来解决。
其实原理也很简单,用上面的例子稍微修改下以触发当前读:select * from user where id < 10 for update,当使用了 Gap Lock 时,Gap 锁会锁住 id < 10 的整个范围,所以其余事务没法插入 id < 10 的数据,从而防止了幻读。
SQL 标准中规定的 RR 并不能消除幻读,可是 MySQL InnoDB 的 RR 能够,靠的就是 Gap 锁。在 RR 级别下,Gap 锁是默认开启的,而在 RC 级别下,Gap 锁是关闭的。
解析:RR 生成 ReadView 的时机是事务第一个 select 的时候,而不是事务开始的时候。右边的例子中,事务1在事务2提交了修改后才执行第一个 select,所以生成的 ReadView 中,a 的是 100 而不是事务1刚开始时的 50。
解析:RR 级别只在事务第一次 select 时生成一次,以后一直使用该 ReadView。而 RC 级别则在每次 select 时,都会生成一个 ReadView,因此 在第二次 select 时,读取到了事务2对于 a 的修改值。
MySQL 的源码主要是 c++ 写的,所以本身看起来比较吃力,花了挺多时间学习整理的。若是你能掌握本文的内容,面试 Java 岗位,不管是哪一个公司,相信都能让面试官眼前一亮。
如今互联网的竞争愈来愈激烈,若是不少东西都只停留在表面,很难取得面试官的“芳心”,只有在适当的时候亮出本身的“长剑”,才能在众多候选人中凸显出本身的不同凡响。你须要向面试官证实,为何是你而不是其余人。
5 年 Java 经验,字节、美团、快手核心部门面试总结(真题解析)
做者:程序员囧辉
连接:juejin.cn/post/694990… 来源:掘金 著做权归做者全部。商业转载请联系做者得到受权,非商业转载请注明出处。