MySQL事务


MySQL事务

引言

我们前面讨论了⼏种⽇志的物理结构和各种的作⽤,现在可以来看看MySQL事务了;

MySQL事务的话,我想⼤家应该都很熟悉了,在⽇常开发的过程中肯定都使⽤过,所以具体的⽤法这些这⾥就不再讲了;

我们这⾥就只根据实际情况来讲讲事务的原理和它对应的底层实现;

我们先来看看在系统中多线程并发执⾏事务的是怎样运⾏的

  • 系统中执⾏⼀个⼜⼀个事务,⼀个事务⾥⾯可能包含了多个增删改查的操作,⼀个事务⾥⾯的这些所有操作要不同时成功,要不同时失败;
  • 我们前⾯讨论过,执⾏增删改这些操作的时候,需要:
    • 先写⼊Undolog,⽤于⽀持回滚,事务等;
    • 再从磁盘中加载数据⻚到buffer pool中,更新内存中的内容;
    • 再写⼊redolog,⽤于⽀持崩溃恢复;
    • 最后还要写⼊binlog,⽤于⽀持归档和主从同步;
  • 所以这⾥的每个事务中执⾏的增删改查的操作,都会去各⾃执⾏上⾯的这些步骤;
  • 但是在⼀个系统中,不是单线程去执⾏事务的,⽽是多线程去执⾏的;那在多线程执⾏的时候,就可能会:
    • 在同⼀时间对bufferpool的缓存⻚中的同⼀⾏进⾏更新;
    • 有的事务在对⼀⾏数据进⾏更新,其他事务在对这⾏数据进⾏查询;
  • 所以,我们要考虑这些冲突是怎么解决的…

这些冲突的解决,也就是我们这儿要讨论的核⼼内容:

  • 多个事务并发执⾏时,InnoDB是怎么解决同时写和同时读写时的⼀些并发冲突;

具体包括了:MySQL的事务隔离级别、MVCC多版本并发控制、锁机制等;

1. 事务特性

事务的⼏个特性ACID,应该⼤家也⽐较熟悉了,我们先简单过⼀下:

  • 原⼦性(Atomicity):⼀个事务是不可分割的整体,要不全部执⾏成功,要不全部执⾏失败;跟Java或者OS⾥⾯的原⼦操作类似;(⽐如Redis的原⼦操作有哪些,怎么实现的)
  • ⼀致性(Consistency):这个⼀致性解释起来有⼀点抽象,⼀致性的意思就是在事务执⾏前后,数据要满足业务的规范。
    • ⽐如你要⼀个更新语句,在对某⾏数据进⾏更新之后,再进⾏查询,查出来的值是你期望更新成为的值,那就是合法的;
    • ⽐如你的⼀个查询语句,在⼀个事务中多次查询,你想要他们查出来的结果都⼀样,那这个也就是⼀个合法状态;(当然这⾥跟隔离级别有关)
  • 隔离性(Isolation):多个事务在并发执⾏的时候,不会受到其他事务的影响;也就是每个事务都其他事务都是隔离的,各个事务之间不能相互影响;
  • 持久性(Durability):事务⼀旦提交之后,这个事务操作的数据对于数据库的改变就应该是永久性的,不管接下来发⽣了什么,它都需要存在;

我们来分析⼀下InnoDB都是怎么实现这些特性的

  • 原⼦性:通过Undolog实现;
    • 原⼦性要保证事务可以提交,也可以回滚;因为Undolog中记录了更新前的所有信息,当事务执⾏失败或者⼿动执⾏回滚的时候,利⽤Undolog中记录的旧信息就可以实现回滚了;
  • 隔离性:通过锁和MVCC实现;这个是我们待会儿讨论;
  • 持久性:通过Redolog实现;
    • InnoDB中所有的更新操作都是先更新在内存的bufferpool中,后⾯再刷⼊磁盘的;
    • 为了避免在刷⼊磁盘之前由于服务挂掉等丢失数据,数据将这些更新也记录在了Redolog了;
  • ⼀致性:通过上⾯的所有⼀起来实现;
    • 其实这⾥,就是以另外⼀个⻆度来让⼤家认识这个四个特性了;
    • 在ACID四个特性⾥⾯,⼀致性是⽬的,另外的原⼦性、隔离性、持久性是⼿段
    • 也就是为了实现⼀致性这个⽬的,数据库提供了那三种⼿段,必须要每⼀种都达到,才能实现⼀致性;(可以思考⼀下,失去了三个中的任意⼀个,⼀致性还能保证吗?)

2. 多事务并发执⾏时的问题

我们前⾯说过,⼀个系统中可能是会有多个线程⼀起执⾏多个事务的;在这种多事务并发执⾏的时候,可能会在同⼀时间对bufferpool的缓存⻚中的同⼀⾏进⾏更新,也可能有的事务在对⼀⾏数据进⾏更新,其他事务在对这⾏数据进⾏查询;

那在这种情况下,就会产⽣并发冲突问题,具体表现为:脏读、不可重复读、幻读

  • 脏读:
    • 事务A去将⼀⾏数据的字段a更新成为了10,但是还没有提交;
    • 此时事务B读到了这个未提交的10去做业务处理了,在这个业务处理的过程中,事务A将字段a的值回滚为了0;
    • 那这个时候,事务B就是基于⼀个错误的值去做业务处理了,也就是读到了脏数据;
  • 幻读:事务A根据条件whereid>10来执⾏范围查询,多次查询的结果不⼀致;
    • 假设事务A第⼀次读取到了10条数据,此时事务B插⼊了满⾜这个条件的5条数据;
    • 事务A再次查询的时候,就会读取到15条数据,跟第⼀次的结果条数不⼀致;就好像第⼀次的查询是幻觉?(幻读的由来)
    • 或者whered=5进⾏查询的时候,其他事务插⼊了(1,1,5),(2,2,5),(3,3,5),d=5的数量也会变化
  • 不可重复读:这⾥是需要假设没有脏读的,也就是事务都只能读到已经提交了的事务更新的数据;
    • 事务A多次读取同⼀条数据,在读取的间隔中,事务B和事务C对这条数据进⾏了更新;
    • 然后事务A的第⼆次、第三次读取到的值跟第⼀次都不⼀样;(也就是不能重复读取,值变了)(这⾥注意,不可重复读针对的是对于数据的编辑,可以认为是对⼀条数据的操作)

3. 多事务并发问题的解决⽅式

为了解决这些事务并发执⾏时出现的问题,定义了⼀些隔离级别;这⾥有两种不同的定义⽅式:

  • SQL标准中定义的隔离级别
    • 读未提交(ReadUncommitted):事务能够读取到其他事务未提交的更新;
      • 脏读、不可重复读、幻读都可能发⽣;
    • 读已提交(ReadCommitted):事务只能读取到其他事务已经提交的更新;
      • 它解决了脏读,但是不可重复读、幻读是可能发⽣的;
    • 可重复读:(RepeatableRead):⼀个事务多次查询⼀条数据的结果都是相同的(即使其他事务更新过它);
      • 它解决了脏读、不可重复读,但是幻读是可能发⽣的;
    • 串⾏化(Serializable):不允许多个事务并发执⾏,也就是让所有事务都串⾏执⾏;
      • 都没有并发执⾏的场景了,所以可以解决⼀切的脏读、不可重复读、幻读等;
  • MySQL中定义的隔离级别:MySQL中默认的隔离级别是可重复读(RR);
    • 但是MySQL可重复读级别的实现解决了幻读,也就是说多事务并发执⾏的问题都被解决了;(这⾥跟SQL标准的定义不同)
    • 其他隔离级别的定义和SQL标准相同;

4. InnoDB中的MVCC

InnoDB中的MVCC(多版本并发控制机制,基于undolog版本链实现):维持一个数据的多个版本,使得读与写操作没有冲突(这里的读为快照读)。

4.1 当前读和快照读

⾸先来看看这两个概念:

  • 当前读:要求读取到的数据需要是最新版本,并且读取或者操作数据时要保证其他并发事务不能修改相应的数据,所以会对数据进⾏加锁;

    当前读有:

    • select…lockinsharemode;(共享锁)
    • select…forupdate;(排他锁)
    • update、insert、delete等更新操作;(排他锁)
  • 快照读:不加锁的select即为快照读,也就是说读取到的是数据的⼀个快照;

    • 快照读是为了提⾼多事务并发读写的性能;
    • 是通过避免加锁操作来实现的(具体就是MVCC),降低了加锁的开销;
    • 所以快照读,可能读到的是历史版本;

4.2 MVCC的实现

主要依赖于数据行记录中的三个隐藏字段、undo log、ReadView实现

  • 当前数据行记录中的三个隐藏字段

    • DB_TRX_ID:6-byte的事务ID。插入或更新行的最后一个事务的事务ID

    • DB_ROLL_PTR:7-byte的回滚指针。就是指向对应某行记录的上一个版本,在undo log中使用。

    • DB_ROW_ID:6-byte的隐藏主键。如果数据表中没有主键,那么InnoDB会自动生成单调递增的隐藏主键(表中有主键或者非NULL的UNIQUE键时都不会包含 DB_ROW_ID列)。

  • undo log版本链:

    • 同一条数据记录在系统中会有多个版本

    • 这些版本不是真实存在的,而是通过当前记录加上undo log计算得出,

    • 多个事务执行时生成的多个版本(多个快照)是由undo log的DB_ROLL_OTR指针所串联起来的,形成了一个undo log版本链

      undo log版本链

    • 版本链的头节点就是当前记录的最新值

    • 根据最后版本的数据记录+undo log版本链可以回溯这个数据的所有版本,用于支持ReadView的可见性比较

  • ReadView:是事务快照读的时候产生的数据读视图,ReadView主要用于可见性(当前事务能看到哪个版本的数据)判断,主要内容:

    • m_ids(视图数组):表示在生成ReadView时,当前系统中活跃(未提交)事务的事务id列表。

    • min_trx_id(低水位线):m_ids中的最小值,也就是最先启动的事务trx_id

    • max_trx_id(高水位线):是指下一个要生成的事务 id。下一个要生成的事务id(m_id最大值+1);。

    • creator_trx_id:当前事务的trx_id

    数据版本的可见性规则基于数据各版本的trx_id和ReadView比较得出

    ReadView

    对于一个事务进行快照读时,当前版本数据的trx_id有如下几种可能:

    • 数据版本的trx_id小于min_trx_id,表明这个版本是已提交事务(或当前事务)生成的,数据可见

      原始value =0 ,trx_id=0
      事务A(trx_id=1)更新value=1并提交,则数据版本中value=1,trx_id=1
      事务B(trx_id=2)开启进行快照读,ReadView:
              m_ids=[2],min_trx_id=2,min_trx_id=3(m_ids最大值+1)
      此时:数据版本中 trx_id=1<min_trx_id,则可见此版本,读取value=1

      trx_id小于min_trx_id

    • 数据版本的trx_id大于等于max_trx_id,表明这个版本是由未来启动的事务生成的,数据不可见

    • 数据版本的min_trx_id<trx_id<max_trx_id,有两种情况:

      • a.若trx_id在m_ids中,表示这个版本是由未提交事务生成的,数据不可见。

        min_trx_id<trx_id<max_trx_id

        原始value =0 ,trx_id=0
        事务A(trx_id=1)更新value=1未提交,数据版本中value=1,trx_id=1
        事务B(trx_id=2)更新value=2未提交(配图事务B描述有误,应为事务B未提交版本),数据版本中value=2,trx_id=2(undo log的ptr指向上一个版本)
        事务C(trx_id=3)开启进行快照读,ReadView:
                m_ids=[2,1,3],min_trx_id=1,min_trx_id=4(m_ids最大值+1)
        此时:数据版本中 min_trx_id<trx_id=2<max_trx_id,处于min和max之间,则不可见此版本,根据当前数据中的DB_ROLL_PTR找到undo log,undo log中上一条的记录的trx_id=1,同样也处于处于min和max之间,版本不可见,再根据undo log的DB_ROLL_PTR找到上一条undolog,这条记录的trx_id=0,小于min,则此版本可见,读取value=0
      • b.若trx_id不在m_ids中,表示这个版本是由已提交事务生成的,数据可见。

      • min_trx_id<trx_id=2<max_trx_id

      原始value =0 ,trx_id=0
      已存在一个事务A(trx_id=1)
      事务B(trx_id=2)更新value=2并提交,数据版本中value=2,trx_id=2
      事务C(trx_id=3)开启进行快照读,ReadView:
              m_ids=[1,3],min_trx_id=1,min_trx_id=4(m_ids最大值+1)
      此时:数据版本中 trx_id=2>=min_trx_id,处于min和max之间,但是不在m_ids中,数据可见
  • ReadView可见性总结

    • 在生成ReadView之前已提交事务更新的值,可以读到
    • 在生成ReadView之前已经开启却未提交事务的更新的值,不能读到
    • 在生成ReadView之后才开启的事务更新的值,不能读到

5.基于MVCC实现读已提交(RC)

前文介绍MySQL的隔离级别时描述过:MySQL可重复读级别的实现解决了脏读,核心实现就是,在读已提交的隔离级别下,每一次查询(快照读)都生成一个新的ReadView

For example:原始记录中value=0,trx_id=0,DB_ROLL_PTR=null存在两个事务:A(trx_id=1) B(trx_id=2)

  • 事务B更新value=2,未提交,此时数据版本中value=2,trx_id=2,DB_ROLL_PTR=0;
  • 事务A查询value,生成ReadView:min_trx_id=1,m_ids=[1,2],max_trx_id=3,此时数据版本中trx_id=2在高低水位线之间,且在m_ids数组中,所以当前数据版本不可见;根据DB_ROLL_PTR=0回溯上一条记录,此时数据版本中trx_id=0小于低水位线,则此数据版本可见,读取value=0;
  • 事务B提交,此时数据版本value=2,trx_id=2,DB_ROLL_PTR=0;
  • 事务A再次查询,生成了一个新的ReadView:min_trx_id=1,m_ids=[1],max_trx_id=3,此时当前数据版本中的trx_id虽然同样处于高低水位线之间,但不在m_ids中(说明生成Readview之前,事务B已提交),当前数据版本可见,value=2。

综上,通过每次查询生成一个新的ReadView可以实现读已提交(RC),但不能实现可重复读(RR)

6.基于MVCC实现可重复读(RR)

MySQL的可重复读解决了幻读,核心实现是:只在事务开启时生成一个ReadView,之后此事务中的所有查询都采用这个ReadView

For example:原始记录中value=0,trx_id=0,DB_ROLL_PTR=null存在两个事务:A(trx_id=1) B(trx_id=2)

  • 事务B更新value=2,未提交,此时数据版本中value=2,trx_id=2,DB_ROLL_PTR=0;
  • 事务A查询value,生成ReadView:min_trx_id=1,m_ids=[1,2],max_trx_id=3,此时数据版本中trx_id=2在高低水位线之间,且在m_ids数组中,所以当前数据版本不可见;根据DB_ROLL_PTR=0回溯上一条记录,此时数据版本中trx_id=0小于低水位线,则此数据版本可见,读取value=0;
  • 事务B提交,此时数据版本value=2,trx_id=2,DB_ROLL_PTR=0;
  • 事务A再次查询,仍然使用初次生成的ReadView:读取value=2,结果与上次读取相同。

综上,通过每次查询都用初次生成的ReadView,可以实现可重复读(RR)

解决幻读:其它事务插入了记录,也需要不能被读到

For example::原始记录中value=0,trx_id=0,DB_ROLL_PTR=null存在两个事务:A(trx_id=1) B(trx_id=2) C(trx_id=3)

  • 事务B更新value=2,未提交,此时数据版本中value=2,trx_id=2,DB_ROLL_PTR=0;
  • 事务A查询value,生成ReadView:min_trx_id=1,m_ids=[1,2],max_trx_id=3,此时数据版本中trx_id=2在高低水位线之间,且在m_ids数组中,所以当前数据版本不可见;根据DB_ROLL_PTR=0回溯上一条记录,此时数据版本中trx_id=0小于低水位线,则此数据版本可见,读取value=0;
  • 事务C插入了一条记录,此时数据版本value=3,trx_id=3,DB_ROLL_PTR=1;
  • 事务A再次查询,仍然使用初次生成的ReadView:min_trx_id=1,m_ids=[1,2],max_trx_id=3,此时数据版本中trx_id=3大于max_trx_id(这条数据是在事务A生成ReadView后开启的),所以当前数据版本不可见,继续回溯。。。,读取value=0

所以,通过每次查询都用初次生成的ReadView也可以解决幻读


 上一篇
MySQL的锁 MySQL的锁
MySQL的锁1. MySQL 锁结构我们前⾯讨论了对于当前读是需要通过加锁来实现的,那这个加锁,到底是加的⼀个什么锁呢?或者说这个锁的结构是什么样的 ⾸先对于这样⼀条记录: 最开始它是没有锁的; 当⼀个事务想要对这条记录做编辑时,就得
2022-07-28
下一篇 
MySQL ⽇志(Redolog Undolog Binlog) MySQL ⽇志(Redolog Undolog Binlog)
MySQL ⽇志(Redolog Undolog Binlog)1. Redolog在前⾯我们对Redolog下过⼀个定义:它是崩溃⽇志,⽤来⽀持崩溃恢复的;为什么需要它呢? 因为MySQL为了提⾼⾃⼰的性能,避免⼤量的磁盘随机IO的发
2022-07-19
  目录