MySQL事务隔离性与MVCC

·  阅读 128

写在前面

  1. 事务缺乏隔离性会导致什么问题?
  2. 什么是事务隔离级别,为什么需要隔离级别?
  3. MVCC是什么,能解决什么问题?
  4. MVCC是怎么实现的?

背景

mysql作为一个基础服务,同一时刻一般会有多个应用服务作为客户端与之交互。每个应用服务在执行一个事务中的多条语句时,肯定不希望被其他客户端的事务影响,或影响其他客户端的事务

最简单的办法就是,所有事务排他性执行,当一个事务的所有语句执行完成(提交或回滚)后,其他事务的语句才能开始执行。但这样对性能的影响太大。实际上在大部分业务场景下,不需要这么强的隔离性,因此可以舍弃一部分隔离性来换取数据库服务的性能

隔离性导致的问题

如果不同的事务之间缺乏隔离性,可能会造成什么问题?

我们先定义什么是没有问题:事务执行结果和串行执行每个事务一样

脏写

脏写,即一个事务对另一个事务未提交的修改记录进行了修改

举个例子:两个事务A,B,当事务B对一条记录更新后,还没有提交,此时事务A更新该记录并提交。若之后事务B执行回滚,该记录变为B更新前的状态,不仅B的更新回滚了,事务A的更新也会回滚掉

这有什么问题呢?

  • 这与完全保证事务的隔离性即串行执行的结果不符,若串行执行,不管A,B谁先执行,在B回滚的情况下,该记录的最终结果一定是A修改后的结果,而不是现在B更新前的结果
  • 其次事务A明明在事务B的修改之后修改,最后结果是回到了B修改之前,好像更新丢失了一样,不符合大部分的业务预期

所幸Mysql Innodb的所有事务隔离级别中都能避免脏写

脏读

脏读,即一个事务读到了另一个事务未提交的修改

举个例子:两个事务A,B,当事务 B修改了一条数据后,还未提交,事务A读取该记录,并依据此记录进行一些操作。若之后事务B进行回滚操作,B修改的记录也就恢复原状,则称A之前读该记录的操作为脏读

这有什么问题呢?

  • 和脏写一样,若事务A,B串行执行,则一定不会出现脏读的情况,该操作和具有完全隔离性的操作表现出来的结果不一致
  • 若事务B执行到后面发现需要回滚,则该修改过记录也需要回滚。该修改后的值其实从一开始就不应该存在,事务A若依赖一个本不应该存在的数据进行一些后续的操作,可能造成一些不良后果。也就是错误的因导致错误的果

不可重复读

不可重复读,即一个事务对一条记录的读,不是每次都一样

举个例子:事务A一开始读一条记录X,结果为M,此时另一个事务B修改了记录X,将记录值M改为N并提交,事务A并未提交,再读记录X,发现X的值变为了N,也就是一个事务对一条记录的前后两次读结果不一样

这里的不可重复读只能读到已经提交的数据,避免了脏读的情况

那这样有什么问题呢?

  • 要说没有问题也是可以的,毕竟读的数据都是已经提交的,没有脏读的问题,只是在一个事务内多次查询一条记录的值结果不一样
  • 要说有问题也是有问题的,还是那句话,这和完全串行执行,也就是完全保证隔离性的执行结果不一样。若完全串行,当一个事务开始任何操作时,都不会都其他事务穿插执行,也就不会有其他事务修改任何记录,当然原事务每次读到的数据都是一样的。若用户期望的就是在一个事务中每次读出来结果都一样,那这就有问题

幻读

幻读,即一个事务根据某个条件查询一些记录,之后另一个事务插入了一些服务该条件的记录,原先的事务按照原先的条件再次查询时,会将后面插入的记录也读出来,称这一现象为幻读

虽然对之前读出过记录是可重复读的,但对新插入的记录没有这个保证

小总结

从脏写,脏读到不可重复读,幻读,每种隔离性问题的严重程度依次递减

其实每种隔离性问题你可以说他有问题,因为若比照完全隔离性,这些隔离性问题或多或少都不符合完全隔离性的定义

但也可以说没有问题,例如不可重复读:如果用户就是期望在事务中每次都读到最新且已提交的数据,那不可重复读这一特性也没什么问题

因此数据库通常支持用户自定义隔离级别,根据自己的需求进行选择,且在性能隔离性之间达到平衡

事务的隔离级别

SQL标准中定义了四中隔离级别,每种隔离级别及其可能发生的隔离性问题如下所示: ​

隔离级别脏读不可重复读幻影读
未提交读可能发生可能发生可能发生
已提交读-可能发生可能发生
可重复读--可能发生
可序列化---

​​

mysql在可重复读隔离级别下能禁止幻读发生

MVCC

未提交读隔离级别会产生除脏读以外的所有隔离性问题,其实现比较简单,每次读某条记录最新的值就行,不管产生该值的事务有没有提交

可序列化可以实现为串行执行每个事务,也比较简单

那已提交读和可重复读隔离级别是怎么实现避免脏读,和避免脏读及可重复读问题的呢?

Mysql Innodb使用MVCC (Multi Version Concurrency Control)多版本并发控制来实现,即当普通读(没有加锁读)其他事务正在写的记录时,选择读该记录的历史版本,以支持读-写操作并发执行,提升系统性能

什么是历史版本呢?

Mysql Innodb的聚集索引记录中每条记录都有两个隐藏列:

  • trx_id :每次某事务对某条聚簇索引记录进行改动(插入,更新,删除)时,都会把该事务的事务id 赋值给 trx_id 隐藏列

Innodb中每个事务都会被分配一个事务id,按从小到大递增分配,例如当前有两个事务id分别为1,2。那下次有新事务开启时其事务id就为3

  • roll_pointer :每次对某条聚簇索引记录进行改动时,不会将该记录的旧值丢弃,而是把该记录旧版本写入到 undo日志 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息

如下所示:该记录按时间先后顺序做了如下修改:

  1. 事务100插入了一条namejames的记录
  2. 事务101将该记录的name列从james更新为jerry
  3. 事务102将该记录的name列从jerry改为tom

image.png 为了支持MVCC的访问,这些历史版本都会保留,直到系统判断再也用不到这些历史版本为止

避免脏读

避免脏读的核心:保证每次读到记录的版本所属的事务都是已提交的

Innodb设计了一个叫做ReadView的结构,主要包含以下4个字段

  • m_ids:在进行读操作开始时,系统中活跃的事务id列表
  • min_trx_idm_ids中最小的值
  • max_trx_id:在进行读操作开始时,系统应该分配给下一个事务的id
  • creator_trx_id:执行读操作所属事务的事务id

有了ReadView,我们就能判断在读某一条记录时,应该读该记录的哪个版本,也就是哪个版本对当前事务是可见的:

  • 若某条记录某个版本的trx_id和当前ReadViewcreator_trx_id相同,说明该版本就是当前事务创建的,那一定可见

  • 若某条记录某个版本的trx_id小于当前ReadViewmin_trx_id

    • 首先该版本所属的事务id不在m_ids中,说明该事务是不活跃的,则要么已经提交,要么还没开始
    • m_ids中的事务id都大于该版本的trx_id,且事务id是递增分配,说明不可能没开始,那一定是已提交的,已提交的版本是可以读的
  • 若某条记录某个版本的trx_id大于当前ReadViewmax_trx_id

    • 说明该版本在ReadView生成后才出现,该版本不能被当前事务访问
  • 否则只剩下一种情况:若某条记录某个版本的trx_id[min_trx_id,max_trx_id-1]之间,则需要看该trx_id在不在m_ids

    • 若在,说明记录的版本是当前活跃,也就是未提交的事务创建的,既然未提交,当然是不可见
    • 若不在,说明该记录的版本是已经提交的事务生成的,已经已经提交,那就是可见

注意区间[m_ids中的最大值,max_trx_id-1]中的事务也可能是可见的,例如当前系统中有活跃事务1,2,3,4此时max_trx_id=5,事务3,4提交

此时创建ReadView,m_ids=[1,2],区间[m_ids中的最大值,max_trx_id-1]为[3,4],3,4在创建当前ReadView之前已经提交,因此可见

image.png 每次读从目标记录的最新版本开始判断,若某个版本不可见,则顺着该记录版本链往下寻找,直到找到某个可见的版本为止。若该记录的所有版本都不可见,说明在记录在当前事务创建ReadView前还不存在,则返回空即可

在每次读之前,根据系统当前所有事务的活跃或提交情况,创建一个ReadView,依据该ReadView判断目标记录的哪个版本可读,即可避免脏读,因为读到的版本一定是已经提交的,且此刻系统中所有提交的都可读。已提交读隔离级别采用该方式实现

避免不可重复读

我们再看怎么避免不可重复读:

和避免脏读的操作不一样的是,只在事务中第一次读时创建一份ReadView,该事务中后续的读操作都根据该ReadView判断需要读的记录的某个版本是否可见

第一次生成ReadView时相当于给当前系统中的事务执行情况打了快照,该事务中后续的读都基于这个快照来判断某记录的版本是否可见,当然每次读出来的结果都是一样的,可重复读隔离级别采用该方式实现

总结

  • 事务缺乏隔离性,会造成脏写,脏读,不可重复读,幻读的隔离性问题
  • 数据库支持用户设置事务隔离级别,在隔离性和性能直接权衡
  • Mysql Innodb通过MVCC机制来实现已提交读可重复读隔离级别
分类:
后端
标签: