MVCC

MVCC,即Multi Version Concurrent Control,多版本并发控制,是MySQL中处理事务并发的机制

  • 多事务并发会有什么问题
  • 脏写、脏读、不可重复读、可重复读、幻读
  • SQL标准中的事务隔离机制
  • MySQL中事务隔离机制如何表现

多事务并发会有什么问题

多个事务并发就是多个事务同时开启,它们可能同时对一条数据进行CRUD操作,那么这个过程如果不通过手段进行控制,肯定会产生类似于线程安全的问题

脏写、脏读、不可重复读、可重复读、幻读

脏写

假设有事务A和事务B同时要更新数据C,数据C原值为oldValue

接着事务A开启了,并且更新了数据C的值为newValueA,并写入undo log,事务还未提交,此时事务B也来更新数据C的值为newValueB,也写入undo log

然后事务A中可能出现了问题,对数据C进行了回滚,因为事务A中的undo log记录的数据C的值为oldValue,因此数据C被回滚为oldValue,此时事务B其实是正常的,但是它对数据C写入的值被事务A回滚为了oldValue

这个过程就是脏写,简单的总结一下就是在事务并发的情况下,某一事务对数据写入的新值被其他事务回滚了

脏读

假设有事务A和事务B同时操作数据C,事务A先写入数据C的值为valueA,然后事务B查得数据C的值为valueA,接着事务A执行出错了,进行了回滚,数据C的值被回滚为了原值,然后事务B再次读取数据C时会发现这次查询到的值和上一次不一样了

这个过程就是脏读,本质上就是事务并发的情况下,某一事务读取到了其他事务回滚前对数据更新的值

不可重复读和可重复读

假设有事务A和事务B并发操作数据C,事务A在多次读取数据C,同时事务B在修改数据C,事务A先读取数据C的值为oldValue,然后事务B修改了数据C的值为newValueC,事务A又来读取数据C的值,发现读到的值和上次的不一致

这个过程就是不可重复读,意思是,事务提前交,多次读取的值不一样

可重复读就相反,在这种情况下,事务多次读取同一数据的值应该一致

幻读

假设有事务A和事务B并发操作一张表C,事务A首先查询select * from table_c where id > 10,得到了10条数据,此时事务A还未提交事务,接着事务B插入了两条数据到该表中,事务A又执行同样的SQL查询到了12条数据

这个过程就是幻读,本质上就是在事务并发的情况下,某一事务多次执行同一条件的查询语句,会查到之前没有看到过的数据

SQL标准中的事务隔离机制

SQL标准中定义的事务隔离级别有4种:

  • Read Uncommitted,读未提交
  • Read Committed(RC),读已提交
  • Repeatable Read(RR),可重复读
  • Serializable,串行

Read Uncommitted

在该事务隔离级别下,不允许两个事务同时在未提交的状态下写入同一数据,因此不会发生脏写,但是会发生脏读、不可重复读和幻读

Read Committed

事务可以读取到其他事务已经提交的数据,因此不会发生脏写、脏读,但是会发生不可重复读和幻读

Repeatable Read

事务一旦开始,多次读取同一数据结果都是一致的,因此不会发生不可重复读,但是还是会有幻读的问题

Serializable

事务不能并发执行,因此什么问题也没有,但是数据库的并发能力也被剥夺了,QPS,TPS显著下降

MySQL中事务隔离机制如何表现

MySQL中默认的事务隔离级别为RR,它通过MVCC机制来保证了在该级别下也不会出现幻读

  • undo log版本链
  • ReadView机制
  • RC的实现
  • RR的实现

undo log版链

什么是undo log版本链

每次我们对数据进行修改时都会先对该数据生成一条undo log,它记录了该数据的主键id,修改前的值,比如如果你是insert操作,那么这条undo log应该记录下该数据的主键ID,并且类型为delete

在MySQL中每条数据都会有两个隐藏字段,分别为

  • trx_id:修改该数据的事务ID
  • roll_pointer:指向上一个修改该数据的事务所留下的undo log

假设事务A(trx_id=1)新增了数据D,会记录一条undo log,数据D的roll_pointer字段就指向了该log,trx_id就是事务A的id,1

接着事务B(trx_id=2)修改了数据D,同样的它也会记录一条undo log,此时数据D的trx_id为事务B的id,2,并且数据D的roll_pointer指向了刚刚事务A生成的undo log

然后又来了个事务C(trx_id=3)修改了数据D,它会把数据D的trx_id字段改为自己的id,3,并且将其roll_pointer指向事务B生成的undo log

这样一连串的roll_pointer的指向就是undo log版本链

ReadView机制

ReadView是基于undo log版本链实现的,它主要的功能就是记录下事务并发时未提交的事务id,最小id,最大id,创建ReadView的事务id

ReadView的结构大致由几个部分组成:

  • m_ids:未提交的事务ID集合
  • min_trx_id:m_ids中trx_id最小值
  • max_trx_id:m_ids中trx_id最大值+1
  • creator_trx_id:创建该ReadView的事务ID

当事务执行的时候,会创建一个ReadView,然后会将所有还未提交的事务的ID放入到m_ids中,比如当前有两个事务未提交,分别是读事务A(trx_id=5),写事务B(trx_id=10),那么ReadView中的数据为:

  • m_ids:[5, 10]
  • min_trx_id:5
  • max_trx_id:11
  • creator_trx_id:5

然后有数据C,trx_id=3,值为oldValue

事务B首先把数据C的值更新为newValueC,trx_id=10,然后事务A读取了数据C,发现数据C的trx_id=10 大于min_trx_id,并且小于max_trx_id,那么trx_id=10的这个事务肯定和自己并发执行的事务,然后就去查询m_ids中是否有trx_id=10的事务,发现存在该事务,那么这条数据不应该被事务A读取,接着事务A就会通过数据C的roll_pointer寻找到比min_trx_id小的最大的事务ID的这条数据,并读取出来

这个机制就保证了事务并发执行时,不会读取到并发中的其他事务修改后的数据(除非这个值是自己修改的,即数据的trx_id等于自己的trx_id)

MySQL中基于undo log版本链和ReadView机制的事务隔离实现

  • RC的实现
  • RR的实现

RC

在这个隔离级别中,每次开启查询都会创建一个ReadView

假设有读事务A(trx_id=5),写事务B(trx_id=10)并发操作数据C(trx_id=3,值=oldValue),此时事务A会创建一个ReadView[m_ids=[5, 10],min_trx_id=5,max_trx_id=11,creator_trx_id=5],事务A首先读取C,此时肯定是无法读取到事务B对数据C修改的值的,然后事务B修改完毕后提交了,事务A再次执行查询,又会创建一个ReadView[m_ids=[5],min_trx_id=5,max_trx_id=11,creator_trx_id=5],再次读取数据C,发现其trx_id=10,然后就在ReadView中查找是否存在trx_id=10的事务,发现没有,那么就直接读取数据C(trx_id=10)这条数据了

这样就能够读取到其他事务已经修改的数据

RR

相比于RC,在RR中,一个事务的多次查询操作只会创建一个ReadView

不可重复读

假设有读事务A(trx_id=5),写事务B(trx_id=10)并发操作数据C(trx_id=3,值=oldValue),此时事务A会创建一个ReadView[m_ids=[5, 10],min_trx_id=5,max_trx_id=11,creator_trx_id=5],事务A首先读取C,此时肯定是无法读取到事务B对数据C修改的值的,然后事务B修改完毕后提交了,事务A再次执行查询,但此时不会再创建ReadView了,还是之前的ReadView,因此查询到数据C的trx_id=10,发现ReadView中存在trx_id=10的事务,因此它不会查询到这条数据,会通过roll_pointer继续查找trx_id更小的数据

这样就避免了不可重复读的问题

幻读

同时你应该能发现,它也避免了幻读问题的出现

假设有读事务A(trx_id=5),写事务B(trx_id=10)并发操作表D,事务B向表D中写入新的数据,事务A查询表D中的数据select * from table_d where id > 10,查得11条数据,然后发现其中有一条数据是事务B写入(trx_id=10),因此它不会将该条数据返回,然后又有写事务C(trx_id=15),插入一条数据E(trx_id=15),然后事务A再次执行查询,发现又查到了11条数据,发现有一条数据的trx_id=15,大于ReadView中的max_trx_id,表明该事务是在自己之后出现的,因此不会返回该条数据