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):不允许多个事务并发执⾏,也就是让所有事务都串⾏执⾏;
- 都没有并发执⾏的场景了,所以可以解决⼀切的脏读、不可重复读、幻读等;
- 读未提交(ReadUncommitted):事务能够读取到其他事务未提交的更新;
- 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版本链可以回溯这个数据的所有版本,用于支持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比较得出
对于一个事务进行快照读时,当前版本数据的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大于等于max_trx_id,表明这个版本是由未来启动的事务生成的,数据不可见
数据版本的min_trx_id<trx_id<max_trx_id,有两种情况:
a.若trx_id在m_ids中,表示这个版本是由未提交事务生成的,数据不可见。
原始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中,表示这个版本是由已提交事务生成的,数据可见。
原始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也可以解决幻读