20 张图带你彻底搞懂MySQL MVCC原理! MySQL MVCC 实现原理是一道非常高频的面试题最近我一直在面试能够清晰准确回答这个问题的人不多今天我们一起来聊聊。1. 相关数据库知识点回顾1.1 什么是数据库事务为什么要有事务事务由一个有限的数据库操作序列构成这些操作要么全部执行,要么全部不执行是一个不可分割的工作单位。假如A转账给B 100 元先从A的账户里扣除 100 元再在 B 的账户上加上 100 元。如果扣完A的100元后还没来得及给B加上银行系统异常了最后导致A的余额减少了B的余额却没有增加。所以就需要事务将A的钱回滚回去就是这么简单。为什么要有事务呢就是为了保证数据的最终一致性。1.2 事务包括哪几个特性事务四个典型特性即ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。原子性事务作为一个整体被执行包含在其中的对数据库的操作要么全部都执行要么都不执行。一致性指在事务开始之前和事务结束以后数据不会被破坏假如A账户给B账户转10块钱不管成功与否A和B的总金额是不变的。隔离性多个事务并发访问时事务之间是相互隔离的一个事务不应该被其他事务干扰多个并发事务之间要相互隔离。。持久性表示事务完成提交后该事务对数据库所作的操作更改将持久地保存在数据库之中。1.3 事务并发存在的问题事务并发会引起脏读、不可重复读、幻读问题。1.3.1 脏读如果一个事务读取到了另一个未提交事务修改过的数据我们就称发生了脏读现象。假设现在有两个事务A、B假设现在A的余额是100事务A正在准备查询Jay的余额事务B先扣减Jay的余额扣了10但是还没提交最后A读到的余额是90即扣减后的余额脏读因为事务A读取到事务B未提交的数据,这就是脏读。1.3.2 不可重复读同一个事务内前后多次读取读取到的数据内容不一致假设现在有两个事务A和B事务A先查询Jay的余额查到结果是100这时候事务B 对Jay的账户余额进行扣减扣去10后提交事务事务A再去查询Jay的账户余额发现变成了90不可重复读事务A被事务B干扰到了在事务A范围内两个相同的查询读取同一条记录却返回了不同的数据这就是不可重复读。1.3.3 幻读如果一个事务先根据某些搜索条件查询出一些记录在该事务未提交时另一个事务写入了一些符合那些搜索条件的记录如insert、delete、update就意味着发生了幻读。假设现在有两个事务A、B事务A先查询id大于2的账户记录得到记录id2和id3的两条记录这时候事务B开启插入一条id4的记录并且提交了事务A再去执行相同的查询却得到了id2,3,4的3条记录了。幻读事务A查询一个范围的结果集另一个并发事务B往这个范围中插入新的数据并提交事务然后事务A再次查询相同的范围两次读取到的结果集却不一样了这就是幻读。1.4 四大隔离级别为了解决并发事务存在的脏读、不可重复读、幻读等问题数据库大叔设计了四种隔离级别。分别是读未提交读已提交可重复读串行化Serializable。1.4.1 读未提交读未提交隔离级别只限制了两个数据不能同时修改但是修改数据的时候即使事务未提交都是可以被别的事务读取到的这级别的事务隔离有脏读、重复读、幻读的问题1.4.2 读已提交读已提交隔离级别当前事务只能读取到其他事务提交的数据所以这种事务的隔离级别解决了脏读问题但还是会存在重复读、幻读问题1.4 3 可重复读可重复读隔离级别限制了读取数据的时候不可以进行修改所以解决了重复读的问题但是读取范围数据的时候是可以插入数据所以还会存在幻读问题1.4.4 串行化事务最高的隔离级别在该级别下所有事务都是进行串行化顺序执行的。可以避免脏读、不可重复读与幻读所有并发问题。但是这种事务隔离级别下事务执行很耗性能。1.4.5 四大隔离级别都会存在哪些并发问题呢隔离级别脏读不可重复读幻读读未提交√√√读已提交×√√可重复读××√串行化×××1.5 数据库是如何保证事务的隔离性的呢数据库是通过加锁来实现事务的隔离性的。这就好像如果你想一个人静静不被别人打扰你就可以在房门上加上一把锁。加锁确实好使可以保证隔离性。比如串行化隔离级别就是加锁实现的。但是频繁的加锁导致读数据时没办法修改修改数据时没办法读取大大降低了数据库性能。那么如何解决加锁后的性能问题的答案就是,MVCC多版本并发控制它实现读取数据不用加锁可以让读取数据同时修改。修改数据时同时可读取。2. 什么是 MVCCMVCC即Multi-Version Concurrency Control 多版本并发控制。它是一种并发控制的方法一般在数据库管理系统中实现对数据库的并发访问在编程语言中实现事务内存。通俗的讲数据库中同时存在多个版本的数据并不是整个数据库的多个版本而是某一条记录的多个版本同时存在在某个事务对其进行操作的时候需要查看这一条记录的隐藏列事务版本id比对事务id并根据事物隔离级别去判断读取哪个版本的数据。数据库隔离级别读已提交、可重复读都是基于MVCC实现的相对于加锁简单粗暴的方式它用更好的方式去处理读写冲突能有效提高数据库并发性能。3. MVCC实现的关键知识点3.1 事务版本号事务每次开启前都会从数据库获得一个自增长的事务ID可以从事务ID判断事务的执行先后顺序。这就是事务版本号。3.2 隐式字段对于InnoDB存储引擎每一行记录都有两个隐藏列trx_id、roll_pointer如果表中没有主键和非NULL唯一键时则还会有第三个隐藏的主键列row_id。列名是否必须描述row_id否单调递增的行ID不是必需的占用6个字节。trx_id是记录操作该数据事务的事务IDroll_pointer是这个隐藏列就相当于一个指针指向回滚段的undo日志3.3 undo logundo log回滚日志用于记录数据被修改前的信息。在表记录修改之前会先把数据拷贝到undo log里如果事务回滚即可以通过undo log来还原数据。可以这样认为当delete一条记录时undo log 中会记录一条对应的insert记录当update一条记录时它记录一条对应相反的update记录。undo log有什么用途呢事务回滚时保证原子性和一致性。用于MVCC快照读。3.4 版本链多个事务并行操作某一行数据时不同事务对该行数据的修改会产生多个版本然后通过回滚指针roll_pointer连成一个链表这个链表就称为版本链。如下版本链其实通过版本链我们就可以看出事务版本号、表格隐藏的列和undo log它们之间的关系。我们再来小分析一下。假设现在有一张core_user表表里面有一条数据,id为1名字为孙权现在开启一个事务A对core_user表执行update core_user set name 曹操 where id1,会进行如下流程操作首先获得一个事务ID100把core_user表修改前的数据,拷贝到undo log修改core_user表中id1的数据名字改为曹操把修改后的数据事务Id101改成当前事务版本号并把roll_pointer指向undo log数据地址。3.5 快照读和当前读快照读读取的是记录数据的可见版本有旧的版本。不加锁,普通的select语句都是快照读,如select * from core_user where id 2;当前读读取的是记录数据的最新版本显式加锁的都是当前读select * from core_user where id 2 for update; select * from account where id2 lock in share mode;3.6 Read ViewRead View是什么呢它就是事务执行SQL语句时产生的读视图。实际上在innodb中每个SQL语句执行前都会得到一个Read View。Read View有什么用呢它主要是用来做可见性判断的即判断当前事务可见哪个版本的数据~Read View是如何保证可见性判断的呢我们先看看Read view 的几个重要属性m_ids:当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。min_limit_id:表示在生成Read View时当前系统中活跃的读写事务中最小的事务id即m_ids中的最小值。max_limit_id:表示生成Read View时系统中应该分配给下一个事务的id值。creator_trx_id: 创建当前Read View的事务IDRead view 匹配条件规则如下如果数据事务IDtrx_id min_limit_id表明生成该版本的事务在生成Read View前已经提交(因为事务ID是递增的)所以该版本可以被当前事务访问。如果trx_id max_limit_id表明生成该版本的事务在生成ReadView后才生成所以该版本不可以被当前事务访问。如果min_limit_id trx_id max_limit_id,需腰分3种情况讨论1.如果m_ids包含trx_id,则代表Read View生成时刻这个事务还未提交但是如果数据的trx_id等于creator_trx_id的话表明数据是自己生成的因此是可见的。2如果m_ids包含trx_id并且trx_id不等于creator_trx_id则Read View生成时事务未提交并且不是自己生产的所以当前事务也是看不见的3.如果m_ids不包含trx_id则说明你这个事务在Read View生成之前就已经提交了修改的结果当前事务是能看见的。4. MVCC实现原理分析4.1 查询一条记录基于MVCC是怎样的流程获取事务自己的版本号即事务ID获取Read View查询得到的数据然后Read View中的事务版本号进行比较。如果不符合Read View的可见性规则 即就需要Undo log中历史快照;最后返回符合规则的数据InnoDB 实现MVCC是通过Read View Undo Log实现的Undo Log 保存了历史快照Read View可见性规则帮助判断当前版本的数据是否可见。4.2 读已提交RC隔离级别存在不可重复读问题的分析历程创建core_user表插入一条初始化数据,如下隔离级别设置为读已提交RC事务A和事务B同时对core_user表进行查询和修改操作。事务A: select * fom core_user where id1 事务B: update core_user set name ”曹操”执行流程如下最后事务A查询到的结果是name曹操的记录我们基于MVCC来分析一下执行流程(1). A开启事务首先得到一个事务ID为100(2).B开启事务得到事务ID为101(3).事务A生成一个Read Viewread view对应的值如下变量值m_ids100101max_limit_id102min_limit_id100creator_trx_id100然后回到版本链开始从版本链中挑选可见的记录版本链由图可以看出最新版本的列name的内容是孙权该版本的trx_id值为100。开始执行read view可见性规则校验min_limit_id(100)trx_id100102; creator_trx_id trx_id 100;由此可得trx_id100的这个记录当前事务是可见的。所以查到是name为孙权的记录。4). 事务B进行修改操作把名字改为曹操。把原数据拷贝到undo log,然后对数据进行修改标记事务ID和上一个数据版本在undo log的地址。(5) 提交事务(6) 事务A再次执行查询操作新生成一个Read ViewRead View对应的值如下变量值m_ids100max_limit_id102min_limit_id100creator_trx_id100然后再次回到版本链从版本链中挑选可见的记录从图可得最新版本的列name的内容是曹操该版本的trx_id值为101。开始执行Read View可见性规则校验min_limit_id(100)trx_id101max_limit_id102); 但是,trx_id101不属于m_ids集合因此trx_id101这个记录对于当前事务是可见的。所以SQL查询到的是name为曹操的记录。综上所述在读已提交RC隔离级别下同一个事务里两个相同的查询读取同一条记录id1却返回了不同的数据第一次查出来是孙权第二次查出来是曹操那条记录因此RC隔离级别存在不可重复读并发问题。4.3 可重复读RR隔离级别解决不可重复读问题的分析在RR隔离级别下是如何解决不可重复读问题的呢我们一起再来看下还是4.2小节那个流程还是这个事务A和事务B如下4.3.1 不同隔离级别下Read View的工作方式不同实际上各种事务隔离级别下的Read view工作方式是不一样的RR可以解决不可重复读问题就是跟Read view工作方式有关。在读已提交RC隔离级别下同一个事务里面每一次查询都会产生一个新的Read View副本这样就可能造成同一个事务里前后读取数据可能不一致的问题不可重复读并发问题。beginselect * from core_user where id 1生成一个Read View////select * from core_user where id 1生成一个Read View在可重复读RR隔离级别下一个事务里只会获取一次read view都是副本共用的从而保证每次查询的数据都是一样的。beginselect * from core_user where id 1生成一个Read View//select * from core_user where id 1共用一个Read View副本4.3.2 实例分析我们穿越下回到刚4.2的例子然后执行第2个查询的时候事务A再次执行查询操作复用老的Read View副本Read View对应的值如下变量值m_ids100101max_limit_id102min_limit_id100creator_trx_id100然后再次回到版本链从版本链中挑选可见的记录从图可得最新版本的列name的内容是曹操该版本的trx_id值为101。开始执行read view可见性规则校验min_limit_id(100)trx_id101max_limit_id102); 因为m_ids{100,101}包含trx_id101 并且creator_trx_id (100) 不等于trx_id101所以trx_id101这个记录对于当前事务是不可见的。这时候呢版本链roll_pointer跳到下一个版本trx_id100这个记录再次校验是否可见min_limit_id(100)trx_id100 max_limit_id102); 因为m_ids{100,101}包含trx_id100 并且creator_trx_id (100) 等于trx_id100所以trx_id100这个记录对于当前事务是可见的所以两次查询结果都是name孙权的那个记录。即在可重复读RR隔离级别下复用老的Read View副本解决了不可重复读的问题。4.4 网络江湖传说MVCC是否解决了幻读问题呢网络江湖有个传说说MVCC的RR隔离级别解决了幻读问题我们来一起分析一下。4.4.1 RR级别下一个快照读的例子不存在幻读问题由图可得步骤2和步骤6查询结果集没有变化看起来RR级别是已经解决幻读问题啦~4.4.2 RR级别下一个当前读的例子假设现在有个account表表中有4条数据RR级别。开启事务A执行当前读查询id2的所有记录。再开启事务B插入id5的一条数据。流程如下显然事务B执行插入操作时阻塞了~因为事务A在执行select ... lock in share mode当前读的时候不仅在id 3,4 这2条记录上加了锁而且在id 2这个范围上也加了间隙锁。因此我们可以发现RR隔离级别下加锁的select, update, delete等语句会使用间隙锁 临键锁锁住索引记录之间的范围避免范围间插入记录以避免产生幻影行记录那就是说RR隔离级别解决了幻读问题4.4.3 这种特殊场景似乎有幻读问题其实上图事务A中多加了update account set balance200 where id5;这步操作同一个事务相同的sql查出的结果集不同了这个结果就符合了幻读的定义~