事务与并发控制ACID,MVCC,四种隔离

MySQL并发事务的实现

对于 MySQL 数据库来说,事务是指以执行start transaction命令开始,到执行commit或者rollback命令结束之间的全部 SQL 操作,如果这些 SQL 操作全部执行成功,则执行commit命令提交事务,表示事务执行成功;如果这些 SQL 操作中任一操作执行失败,则执行rollback命令回滚事务,表示事务执行失败,并将数据库回滚到执行start transaction命令之前的状态

事务

用户定义的一个数据库操作序列,这些操作要么全做,要么全不做,是一个不可分割的工作单位

实现原理

原子性

原子性是事务的基本特性,保证了事务中的操作是不可拆分的整体,在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。

保证原子性是基于日志的redo/undo机制,将所有对数据的更新操作都写入日志。redo log用于保证事务持久性,undo log是事务原子性和隔离性实现的基础。InnoDB 实现回滚,靠的是undo log,当事务对数据库进行修改时,InnoDB 会生成对应的undo log。如果在执行过程中出现各种错误而无法继续,便可以利用undo log中的信息将数据回滚到修改之前的样子。当发生回滚时,InnoDB 会根据undo log的内容做与之前相反的工作:对于每个insert,回滚时会执行delete;对于每个delete,回滚时会执行insert;对于每个update,回滚时会执行一个相反的update,把数据改回去。

比如数据库在重启后,数据库处于不一致状态,恢复子系统除需要撤销所有未完成的事务外,还会执行一个redo操作,重演所有已经执行成功但没有写入磁盘的操作。再对所有到崩溃时尚未成功提交的事务进行UNDO(撤销所有执行了一部分但尚未提交的操作,保证原子性)

持久性

持久性,是指事务一旦提交,它对数据库的改变就应该是永久性的,接下来的其他操作或故障不应该对其有任何影响。

实现原理是通过redo log,InnoDB 作为 MySQL 的存储引擎,数据是存放在磁盘中的。为了加快读写效率,InnoDB 提供了缓存,当从数据库读取数据时,会首先从缓存中读取,如果缓存中没有,则从磁盘读取后放入缓存;当向数据库写入数据时,会首先写入缓存,缓存中修改的数据会定期刷新到磁盘中,这一过程称为“刷脏”。

缓存大大提高了读写数据的效率,但是也带了新的问题:如果 MySQL 宕机,而此时缓存中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

于是,redo log被引入来解决这个问题:当数据修改时,除了修改缓存中的数据,还会在redo log记录这次操作;当事务提交时,会调用redo log进行刷盘。如果 MySQL 宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是 WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到缓存,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求。

隔离性

隔离性,是指事务内部的操作与其他事务是隔离的。保证事务执行尽可能不受其他事务影响,InnoDB 默认的隔离级别是REPEATABLE READ,REPEATABLE READ的实现主要基于锁机制、数据的隐藏列、undo log日志和类next-key lock机制;

一致性

事务追求的最终目标,一致性的实现既需要数据库层面的保障,也需要应用层面的保障。

故障分类

1.事务内部的故障

事务故障意味着事务没有达到预期的终点,因此数据库可能处于不正确的状态。要回滚该事务,即撤销该事务已经做出的任何对数据库的修改,使得该事务好像根本没有启动一样,这类恢复操作称为事务撤销(UNDO)

2.系统故障

系统故障是指造成系统停止运转的任何事件,使得系统要重新启动。这类故障影响正在运行的所有事务,但不破坏数据库。系统重新启动后,恢复子系统除需要撤销所有未完成的事务外,还需要**重做(REDO)**所有已提交的事务,以将数据库真正恢复到一致状态

3.介质故障

称为硬故障,如磁盘损坏,磁头碰撞,瞬时强磁场干扰等,这类故障比前两类故障发生的可能性小很多,但破坏性很大

4.计算机病毒

恢复的实现技术

数据转储

数据转储是数据库恢复中采用的基本技术,即数据库管理员定期地将整个数据库复制到磁带,磁盘或其他存储介质上保存起来的过程

数据库遭到破坏后可以将后备副本重新装入,但重装后备副本只能将数据库恢复到转储时的状态,要想恢复到故障发生时的状态,必须重新运行自转储以后的所有更新事务

转储分类

静态转储:系统中无运行事务时进行的转储操作,转储必须等待正运行的用户事务结束才能进行,同样,新的事务必须等待转储结束才能执行。

动态转储:转储期间允许对数据库进行存取或修改,即转储和用户事务可以并发执行。但是必须把转储期间各事务对数据库的修改活动登记下来,建立日志文件

登记日志文件

日志文件是用来记录事务对数据库的更新操作的文件

日志文件作用

1.事务故障恢复和系统故障恢复必须用日志文件

2.在动态转储方式中必须建立日志文件,后备副本和日志文件结合起来才能有效地恢复数据库

3.在静态转储方式中也可以建立日志文件,当数据库毁坏后可重新装入后援副本把数据库恢复到转储结束时刻的正确状态,然后利用日志文件把已完成的事务进行重做处理,对故障发生时尚未完成的事务进行撤销处理。

不一致性

1.丢失修改(Lost Update)

当一个事务修改了数据,并且这种修改还没有还没有提交到数据库中时,另外一个事务又对同样的数据进行了修改,并且把这种修改提交到了数据库中。这样,数据库中没有出现第一个事务修改数据的结果,好像这种数据修改丢失了一样。
2.脏读(Dirty Read)

当一个事务正在访问数据,并对数据进行了修改,而这种修改还没有提交到数据库中,这时,另一个事务也访问这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另一个事务读到的这个数据是脏数据,依据脏数据所做的操作可能是不正确的。
3.不可重复读(Non-Repeatable Read)

(1)在一个事务内,多次读同一数据。在这个事务还没有结束时,另一个事务也访问该同一数据,那么,在第一个事务中的两次读数据之
间,由于第二个事务的修改,第一个事务两次读到的数据可能是不一样的。

(2)一个事务按一定条件从数据库中读取了某些数据记录后,另一个事务删除了其中部分记录,当该事务再次按相同条件读取数据时,发现某些记录神秘的消失了

(3)一个事务按一定条件从数据库中读取了某些数据记录后,另一个事务插入了一些记录,当该事务再次按相同条件读取数据时,发现多了一些记录

产生上述不一致性的主要原因就是并发操作破坏了事务的隔离性,并发控制机制就是要用正确的方式调度并发操作,使一个用户事务的执行不受其他事务的干扰

并发控制的主要技术有*,时间戳,乐观控制法,多版本并发控制(MVCC).

*

MySQL 中的锁可以按照粒度分为锁定整个表的表级锁(table-level locking)和锁定数据行的行级锁(row-level locking):

  • 表级锁具有开销小、加锁快的特性;表级锁的锁定粒度较大,发生锁冲突的概率高,支持的并发度低;
  • 行级锁具有开销大,加锁慢的特性;行级锁的锁定粒度较小,发生锁冲突的概率低,支持的并发度高。

排他锁(X锁):又称为写锁,若事务T对数据对象A加上X锁,则只允许T读取和修改A,其他任何事务都不能对A加任何类型的锁,直到T释放A上的锁为止

共享锁(S锁):又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁,这就保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。

死锁

产生死锁的原因是两个或多个事务都已*了一些数据对象,然后又都请求对已被其他事务*的数据对象加锁,从而出现死等待

死锁的预防

1.一次*法

要求每个事务必须一次将所有要使用的数据全部加锁,否则就不能继续执行,虽然能有效地防止死锁的发生,但一次将以后要用到的数据全部加锁,势必扩大了*的范围,从而降低了系统的并发度,同时很难事先精确地确定每个事务都要*的数据对象

2.顺序*法

预先对数据对象规定一个*顺序,所有事务都按这个顺序实施*,但也存在问题。第一,数据库系统中*的数据对象极多,并且随数据的插入,删除等操作而不断地变化,要维护这样的资源的*顺序非常困难,成本很高。第二,事务的*请求可以随着事务的执行而动态地决定,很难事先确定每一个事务要*哪些对象,因此也很难按规定的顺序去实施*

两段锁协议

事务分为两个阶段,第一个阶段是获得*,也称为扩展阶段,在这个阶段,事务可以申请获得任何数据项上的任何类型的锁,但是不能释放任何锁,第二阶段是释放*,也称为收缩阶段,在这个阶段,事务可以释放任何数据项上的任何类型的锁,但是不能再申请任何锁。若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的

一次*法遵守两段锁协议,但是两段锁协议并不要求事务必须一次将所有要使用的数据全部加锁,因此遵守两段锁协议的事务可能发生死锁

多版本并发控制MVCC

MVCCMySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读

  • 当前读
    像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁
  • 快照读
    像普通的Select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本

MVCC就是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

MVCC模型在MySQL中的具体实现则是由 3个隐式字段undo日志Read View 等去完成的

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_TRX_ID
    6byte,表示最近一次对本记录行作修改(insert | update)的事务ID。至于delete操作,InnoDB认为是一个update操作,不过会更新一个另外的删除位,将行表示为deleted。并非真正删除。
  • DB_ROLL_PTR
    7byte,回滚指针,指向当前记录行的undo log信息
  • DB_ROW_ID
    6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
  • 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了

Read View(读视图)

Read View就是事务进行快照读操作的时候生成的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)

low_limit_id:目前出现过的最大的事务ID+1,即下一个将被分配的事务ID

up_limit_id:活跃事务列表trx_ids中最小的事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id

***trx_ids:***Read View创建时其他未提交的活跃事务ID列表。意思就是创建Read View时,将当前未提交事务ID记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。

Undo log

Undo log中存储的是老版本数据,当一个事务需要读取记录行时,如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本。
大多数对数据的变更操作包括 insert/update/delete,在InnoDB里,undo log分为如下两类:
①insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
②update undo log : 事务对记录进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。

可见性比较算法

在innodb中,创建一个新事务后,执行第一个select语句的时候,innodb会创建一个快照(read view),快照中会保存系统当前不应该被本事务看到的其他活跃事务id列表(即trx_ids)。当用户在这个事务中要读取某个记录行的时候,innodb会将该记录行的DB_TRX_ID与该Read View中的一些变量进行比较,判断是否满足可见性条件。

​ 假设当前事务要读取某一个记录行,该记录行的DB_TRX_ID(即最新修改该行的事务ID)为trx_id,Read View的活跃事务列表trx_ids中最早的事务ID为up_limit_id,将在生成这个Read Vew时系统出现过的最大的事务ID+1记为low_limit_id(即还未分配的事务ID)。

具体的比较算法如下:

​ 1. 如果 trx_id < up_limit_id, 那么表明“最新修改该行的事务”在“当前事务”创建快照之前就提交了,所以该记录行的值对当前事务是可见的。跳到步骤5。

​ 2. 如果 trx_id >= low_limit_id, 那么表明“最新修改该行的事务”在“当前事务”创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤4。

​ 3. 如果 up_limit_id <= trx_id < low_limit_id, 表明“最新修改该行的事务”在“当前事务”创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表trx_ids进行查找(源码中是用的二分查找,因为是有序的):

​ (1) 如果在活跃事务列表trx_ids中能找到 id 为 trx_id 的事务,表明①在“当前事务”创建快照前,“该记录行的值”被“id为trx_id的事务”修改了,但没有提交;或者②在“当前事务”创建快照后,“该记录行的值”被“id为trx_id的事务”修改了(不管有无提交);这些情况下,这个记录行的值对当前事务都是不可见的,跳到步骤4;

​ (2)在活跃事务列表中找不到,则表明“id为trx_id的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见,跳到步骤5。

​ 4. 在该记录行的 DB_ROLL_PTR 指针所指向的undo log回滚段中,取出最新的的旧事务号DB_TRX_ID, 将它赋给trx_id,然后跳到步骤1重新开始判断。

​ 5. 将该可见行的值返回。

四种隔离级别

不同的隔离级别是在数据可靠性和并发性之间的均衡取舍,隔离级别越高,对应的并发性能越差,数据越安全可靠

事务与并发控制ACID,MVCC,四种隔离

READ UNCOMMITTED

事务之间可以读取彼此未提交的数据, 排他锁会阻止其它事务再对其锁定的数据加读或写的锁,但是对不加锁的读就不起作用了,所以在read uncommitted的隔离级别下,读操作不加任何锁,而写会加排他锁,并到事务结束之后释放,RU会导致脏读、不可重复读和幻读的问题的出现

事务A 事务B
Xlock money
read(money);money=0
money=money+2000;
write(money)
read(money) ;money=2000;
rollback;money=0;
money=money+5000;
write(money)
Commit
Unlock money
read(money) ;money=5000;

脏读

事务A 事务B
read(money) ;money=2000;
Xlock money
read(money);money=2000;
money=money-2000;
write(money)
Commit
Unlock money
read(money) ;money=0;

不可重复读

事务A 事务B
read(消费记录);
消费金额80元
Xlock money
read(money);
money=money-1000;(消费)
write(money)
Commit
Unlock money
read(消费记录);
增加消费1000元的记录

幻读

READ COMMITTED和REPEATABLE READ

简称RC和RR,首先对于RC,它可以避免脏读,那么如果单纯利用锁,即事务在修改数据前对其加X锁,直到事务结束释放;事务在读取数据前必须加S锁,读完后立即释放S锁。这样确实能实现RC的隔离级别,但是这种做法很低效,因为对于大部分应用来说,读操作是多于写操作的,当写操作加锁时,那么读操作全部被阻塞,这样会导致应用的相应能力受数据库的牵制。同理,如果利用锁机制来实现RR,即事务在读取数据前必须对其加S锁直到事务结束后释放,这种方法可以实现RR的可重复读,但是效率很低。同时这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。

我们采取的方法是使用MVCC来实现,只是RC和RR二者存在差异,差异如下:

①在innodb中的RR级别, 只有事务在begin之后,执行第一条select(读操作)时, 才会创建一个快照(read view),将当前系统中活跃的其他事务记录起来;并且事务之后都是使用的这个快照,不会重新创建,直到事务结束。

②在innodb中的RC级别, 事务在begin之后,执行每条select(读操作)语句时,快照会被重置,即会重新创建一个快照(read view)。

事务与并发控制ACID,MVCC,四种隔离

​ 从上表可以看出RC和RR的差异,即RC不能实现可重复度,RR可实现可重复读。同时二者都实现了避免脏读,因为二者读取的数据,都是已经提交,实实在在存在的最新或者旧数据。

​ 只靠 MVCC 实现RR隔离级别,可以保证可重复读,还能防止部分幻读,但并不是完全防止。

​ 比如事务A开始后,执行普通select语句,创建了快照;之后事务B执行insert语句;然后事务A再执行普通select语句,得到的还是之前B没有insert过的数据,因为这时候A读的数据是符合快照可见性条件的数据。这就防止了部分幻读,此时事务A是快照读

​ 但是,如果事务A执行的不是普通select语句,而是select … for update等语句,这时候,事务A是当前读,每次语句执行的时候都是获取的最新数据。也就是说,在只有MVCC时,A先执行 select … where nid between 1 and 10 … for update;然后事务B再执行 insert … nid = 5 …;然后 A 再执行 select … where nid between 1 and 10 … for update,就会发现,多了一条B insert进去的记录。这就产生幻读了,所以单独靠MVCC并不能完全防止幻读。

​ 因此,InnoDB在实现RR隔离级别时,不仅使用了MVCC,还会对“当前读语句”读取的记录行加记录锁(record lock)和间隙锁(gap lock)禁止其他事务在间隙间插入记录行,形成一个闭区间Next-Key Locks(临界锁),来防止幻读。也就是"行级锁+MVCC"。

SERIALISABLE

该级别下,会自动将所有普通select转化为select … lock in share mode执行,即事务在读取数据之前必须先对其加S锁,直到事务结束才释放。即针对同一数据的所有读写都变成互斥的了,使用悲观锁的理论,实现简单,数据更加安全,可靠性大大提高,并发性大大降低。

从而有效地解决了脏读,不可重复读,幻读的问题。

事务T1 事务T2
Xlock 消费记录
R©=100
C=C*2
W©=200
Slock C
等待
rollback;C=100; 等待
Unlock C 等待
获得Slock C
R©=100
Unlock C

避免脏读

事务T1 事务T2
Slock A
R(A)=50
Xlock A
等待
等待
等待
R(A)=50 等待
Commit 等待
Unlock A 等待
获得Xlock A
R(A)=50
A=A*2
W(A)=100
Commit
Unlock A

可重复读

事务A **事务B **
Slock money
R(消费记录)
消费金额80元
Xlock money
等待
等待
等待
等待 R(消费记录)
等待 消费金额80元
等待 Commit
等待 Unlock money
获得Xlock money
R(money);
money=money-1000;(消费)
write(money)
Commit
Unlock money

避免幻读