MySQL之事务

109 阅读14分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。 ​

事务简介

事务可以理解为一组操作的集合,当我们把一组操作作为事务提交时,这些操作要么全部成功,要么全部失败。

事务的提出主要是为了解决并发情况下保持数据一致性的问题

应用场景

现在A要给B转账1000,那么一般操作分为以下3步:

  1. 查询A的账户余额是否有1000元。

  2. 给A的账户扣款1000元。

  3. 给B的账户增加1000元。

此时如果代码执行完操作2后报错后就不往下执行了,那么相当于A扣了款没有B增加款。此时就需要把这三个操作用事务来执行(同时成功或同时失败)。

事务操作

通过 START TRANSACTION 或 BEGIN 开启事务

事务执行完成后通过 COMMIT 手动提交事务

或者事务执行出错通过 ROLLBACK 回滚事务

事务的四大特性(ACID)

A:原子性。事务执行要么全部成功,要么全部失败,对外界来说事务中的若干操作只相当于一个操作。

I:隔离性。事务执行过程中不会被其它操作打断(保证事务在不受外部并发条件的情况下独立运行,由锁机制和MVCC机制实现)

D:持久性。事务提交或回滚后对数据库的修改是永久的。

C:一致性。事务从一种正确状态到另一种正确状态。什么是正确状态?比如取款时余额不能为负数,那么当余额变为了负数就不是一种正确状态。可以说AID都是手段,而C是要达到的目的。

并发事务问题

  1. 脏读

事务A读到了事务B没有提交的数据,就是说事务B对一个数据进行了修改,事务A读取到了修改后的数据,但是事务B最后回滚了,这样事务A读的数据就是脏的。下面以存款和取款为例进一步说明(初始余额1000)。

存款事务A取款事务B
1开始事务
2开始事务
3查询余额(余额1000)
4取款100(余额900)
5查询余额(900)
6执行出现错误,操作回滚(余额变为1000)
7存款100
此时余额变为1000,但正确情况是1100
  1. 不可重复读

事务A读取数据后事务B对该数据进行了修改,后面事务A又读了一次该数据,两次读取的数据不一致导致系统读不到重复的数据就是不可重复读。

事务A事务B
1开始事务
2开始事务
3读取余额=1000
4取款100
5提交
6读取余额=900
事务A读取的两次余额不同,出现了不可重复读
  1. 幻读

事务A查询id=1的用户发现没有该用户,此时事务B插入了id=1的用户数据,事务A接着去插入id=1的用户数据就报错了(主键重复),事务A再次查询发现还是查不到该用户数据(因为要保证重复读,事务A没结束是查不到新数据的),就像出现了幻觉,所以叫幻读。

事务A事务B
1开始事务
2开始事务
3第一次查询id=1的用户为空
4插入id为1的用户数据
5提交
6插入id为1的用户数据提示主键重复插入失败
7第二次查询id=1的用户还是空
查询提示没有该数据,但插入时又系统提示有了该数据,插入有查询没有就好像产生了幻觉。

事务隔离级别

脏读不可重复读幻读
读不可提交存在存在存在
读可提交(RC)不存在存在存在
可重复读(RR)不存在不存在存在
串行化不存在不存在不存在

如上表所示,事务隔离级别一共分四类,其中MySQL默认是可重复读级别,我们可以根据具体业务修改事务隔离级别,从上往下性能越差但是也越安全,一般第一个和最后一个不用,如果对数据一致性要求很高可以改为读可提交,因为一个事务提交了另一个事务能马上知道。

另外MySQL中由于有间隙锁,其实幻读也不会发生。

​ ​

事务原理

undo log

 undo log有两个作用:用于事务回滚,保证原子性;MVCC

原子性为什么需要undo log?数据的修改都是在内存中,commit之前断电了磁盘数据也没有被影响,为什么要undo log来回滚?

其实事务在commit之前就会有落盘操作 

MVCC就是说在回滚时要回滚到update1后还是update2后

redo log

聚集索引、二级索引、undo页面的修改,均需要记录Redo日志。

修改数据涉及的流程如下:

开始先把磁盘的数据拷贝一份到内存中,修改时先改内存中的数据,然后写入redo log buffer,事务commit后遵循WAL,也就是先把redo log落盘(顺序IO),再把数据落盘(随机IO)。

  1. redo log到底记录的是什么?

  2. 日志刷盘的时机?

image.png

 3. 数据刷盘时机?

checkpoint机制(见后文)

以上并未深入,还有binlog、二阶段提交等概念

事务原子性怎么保证的?

原子性说白了就是要保证事务回滚时已经执行的sql语句能够被撤销

通过undo log(撤销日志)保证,当事务执行了一部分的sql需要回滚时,撤销日志用来记录相应的撤销信息,比如执行了insert,那么就会有一条insert undo log被记录,回滚时就delete这个数据即可,update和delete操作就对应update undo log。

事务持久性怎么实现的?

我们知道数据的更改是先写入内存中,然后再由内存写入磁盘,如果中途宕机了,内存中的数据就会部分丢失。 

很容易想到我们在事务提交前就把数据写入磁盘,Mysql在内存中也提供了buffer这么一个缓冲机制,但这样会有问题,毕竟一个事务里的SQL可能牵涉到多个数据页的修改,而这些数据页可能不是相邻的,也就是属于随机IO。显然操作随机IO,速度会比较慢。另外buffer中一个页大小16kb,只要操作的数据达到了16kb就会有一个落盘操作(这种做法的目的是为了防止commit再落盘时数据量太大,严重影响性能)。

所以采用redo log(重做日志)来保证持久性会比普通的buffer快,在更新时还是先操作内存,但同时也会在redo log buffer中记录这次操作,当事务提交的时候,会将redo log buffer日志进行刷盘。当数据库宕机重启的时候,根据undo log和binlog内容决定回滚数据还是提交数据。

以下为update执行流程:

image.png

对于mysql来说,所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的脏页会以一定的频率被刷入磁盘(checkPoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就可以保证整体的性能不会下降太快。

缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint并不是每次变更的时候就触发的,而是master线程隔一段时间去处理的。所以最坏的情况就是刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。这样的话就不满足ACID中的D,为了解决这种情况下的持久化问题,InnoDB引擎的事务采用了WAL技术(Write-Ahead Logging),这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。当发生宕机且数据未刷到磁盘的时候,可以通过redo log来恢复,保证ACID中的D,这就是redo log的作用。

但是redo log也不是每次都写入磁盘,redo log也有缓冲区的,叫做redo log buffer(重做日志缓冲),InnoDB引擎会在写redo log的时候先写redo log buffer,然后也是以一定的频率刷入到真正的redo log中,这里的同步策略有三种:

  • master线程每秒将buffer刷到到redo log中
  • 每个事务提交的时候会将buffer刷到redo log中
  • 当buffer剩余空间小于1/2时,会被刷到redo log中

需要注意的是redo log buffer刷到redo log的过程并不是真正的刷到磁盘中去了,只是刷入到os cache中去,这是现代操作系统为了提高文件写入的效率做的一个优化,真正的写入会交给系统自己来决定(比如os cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来fsync,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。针对这种情况,InnoDB给出innodb_flush_log_at_trx_commit策略,让用户自己决定使用哪个。

 通过命令查看:

show variableslike 'innodb_flush_log_at_trx_commit';
  • 0:表示事务提交后,不进行fsync,而是由master每隔1s进行一次重做日志的fysnc
  • 1:默认值,每次事务提交的时候同步进行fsync
  • 2:写入os cache后,交给操作系统自己决定什么时候fsync

从3种刷入策略来说:

2肯定是效率最高的,但是只要操作系统发生宕机,那么就会丢失os cache中的数据,这种情况下无法满足ACID中的D

0的话,是一种折中的做法,它的IO效率理论是高于1的,低于2的,它的数据安全性理论是要低于1的,高于2的,这种策略也有丢失数据的风险,也无法保证D。

1是默认值,可以保证D,数据绝对不会丢失,但是效率最差的。个人建议使用默认值,虽然操作系统宕机的概率理论小于数据库宕机的概率,但是一般既然使用了事务,那么数据的安全应该是相对来说更重要些。

为什么有个prepare? 两段式提交,为了保证redo log和binlog的数据同步,如果redo log直接提交,binlog还没来得及记录数据就宕机了,这时从机就没办法和主机保持数据同步,所以让binlog写入成功后就将prepare改为commit。(两段式提交就是指redo log有prepare和commit两种状态)

为什么不只用redo log? ,因为redo log满了就会覆盖之前的数据继续写。

那为什么不可以通过binlog来恢复未落盘的数据? 这是因为binlog不知道哪些数据落盘了,所以不知道哪些数据需要恢复。

为什么redo log知道哪些数据需要恢复? 对于redo log而言,在数据落盘后对应的redo log中的数据会被删除,那么在数据库重启后,只要把redo log中剩下的数据都恢复就行了。

通过两段式提交我们知道redo log和binlog在各个阶段会被打上prepare或者commit的标识,同时还会记录事务的XID,有了这些数据,在数据库重启的时候,会先去redo log里检查所有的事务,如果redo log的事务处于commit状态,那么说明在commit后发生了crash,此时直接把redo log的数据恢复就行了,如果redo log是prepare状态,那么说明commit之前发生了crash,此时binlog的状态决定了当前事务的状态,如果binlog中有对应的XID,说明binlog已经写入成功,只是没来的及提交,此时再次执行commit就行了,如果binlog中找不到对应的XID,说明binlog没写入成功就crash了,那么此时应该执行回滚。

Write-Ahead Log(预先日志持久化) ,在持久化一个数据页之前,先将内存中相应的日志页持久化。

事务的隔离性怎么保证的?由锁和MVCC保证

事务的MVCC是什么?

MVCC (Multi Version Concurrency Control)多版本并发控制,是指在数据库中为了实现高并发的数据访问,对数据进行多版本处理,并通过事务的可见性来保证事务能看到自己应该看到的数据版本。 作用是如果某条记录正在被修改,则可以并发读取该记录的历史版本而不必阻塞等待读锁的释放。其实现方式是通过Undo Log和ReadView实现的,可以读取Undo Log里的历史版本,ReadView用来控制哪个历史版本里是对当前事务可见的。

 MVCC中,读操作可分为两类:快照读(普通的select)和当前读(insert、delete、update、select for update就是这种读)

比如左边A线程的事务有一个修改操作会上写锁,右边线程B有个快照读在MVCC中是可以读取到旧数据的。MySQL厉害之处在于写不会阻塞读,因此读性能高。

MVCC深入:

Read View就是事务进行快照读操作的时候生产的读视图(Read View),在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View

这个Read View在可重复读的隔离级别中是事务执行第一条select生成,后面的select都是复用这一个,而在RC隔离级别中是每次select都要创建一个Read View,目的是有些事务开启晚但结束早,为了读到这种事务提交的数据就可以用RC。

三个隐式字段:

  • DB_TRX_ID:6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
  • DB_ROLL_PTR:7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)多个事务共用一个undo log
  • DB_ROW_ID:6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

rc隔离级别没有快照读?

不是,每次select生成快照并不是当前读的意思,当前读是指读取最新的数据同时加锁。

trxid和xid?

Xid 是由 server 层维护的。InnoDB 内部使用 Xid,就是为了能够在 InnoDB 事务和 server 之间做关联。但是,InnoDB 自己的 trx_id,是另外维护的。

Read View判断规则:

Read View会有一个列表,用于维护当前所有活跃事务的ID,有一个值min_id表示活跃事务ID最小值,max_id表示活跃事务ID最大值+1,判断步骤如下:

取出当前记录的trx_id,如果trx_id<min_id,说明修改当前记录的事务已经结束,那么该行记录一定是可见的,否则就看是否大于max_id,如果大于就说明一定不可见,毕竟是在Read View生成后才出现的记录,否则如果大于min_id且小于max_id,就看这个trx_id是否在活跃事务列表中,在就说明Read View生成时这个trx_id对应的事务还未提交,就不可见,否则就可见。

如果上述结果最后是不可见,那么就通过回滚指针找上一条记录。