同一个事务里面对同一条数据做2次修改_Mysql事务深度简析

一、什么是事务

事务就是一组独立不可分割的工作单元,事务中的操作要么全部执行,要么都不执行,对应的是现实中一种比较特殊的业务需求,比如银行转账,扣减金额和增加金额两个操作要么全部成功,要么全部失败,不能存在中间状态

二、事务的四大特性(ACID)

原子性(Atomicity):事务是一个整体,要么全部成功要么全部失败 一致性(Consistency):事务开始之前和结束以后,数据需要符合现实世界中的约束 隔离性(Isolation):不同的事务之间不能互相影响,就像两次转账应该是互不影响的 持久性(Durability):已经提交的事务对数据的修改,应该是永久保存在下来

三、InnoDB事务特性的实现原理

3.1 原子性

原子性在数据库中的体现就是事务回滚,回滚能够撤销所有已经执行的sql语句,InnoDB实现回滚靠的是回滚日志(undo log)

InnoDB存储引擎提供两种事务日志:redo log(重做日志)和undo log(回滚日志),其中redo log用于保证事务持久性;undo log则是事务原子性和隔离性实现的基础

当事务对数据库进行修改时,InnoDB会生成相应的undo log,它记录的是sql执行相关的信息,如果事务执行失败或调用了rollback,发生回滚,InnoDB会根据undo log的内容做与之前相反的工作,对于insert执行delete,对于delete执行insert,对于update执行相反的update把数据改回去,将数据回滚到修改之前的样子

以update操作为例:当事务执行update时,其生成的undo log中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到update之前的状态

3.2 持久性

持久性的对应数据库中就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。

事务提交时同步的将数据写到硬盘就可以保证持久性

但是InnoDB作为MySQL的存储引擎,还需要考虑到性能,mysql数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool)

Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中

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

在事务提交时同步的把该事务所修改的所有页面都刷新到磁盘也能保证持久性

同样的Innodb为了性能没有选择这么做,因为

必须按照数据页进行刷新:有时候我们仅仅修改了某个页面中的一个字段,事务提交时不得不将一个完整的页面从内存中刷新到磁盘

可能要进行多次的磁盘IO:如果一个事务修改了非常多的记录,但这些记录可能在不同的页面而且这些页面可能并不相临,这就会导致将这些页面刷新到磁盘时要进行多次的磁盘io操作

Innodb使用重做日志(redo log)来解决了这个问题,实现持久性的同时保证了性能。当事务修改数据时,先写入重做日志,再写缓存WAL(Write-ahead logging,预写式日志);当事务提交时,把重做日志刷新到磁盘。如果MySQL宕机,重启时可以读取重做日志来恢复数据。

和刷新内存页相比redo log只会记录事务修改的数据,写入的数据很少,而且是单独的储存空间,顺序写入磁盘,不需要进行多次的磁盘IO操作

3.3 隔离性

3.3.1 并发事务问题

隔离性是指事务和事务之间是隔离的,并发执行的各个事务之间不能互相干扰,在没有隔离性的情况下并发事务可能存在一下问题:

第一类丢失更新/回滚覆盖(写-写并发):对同一条记录进行修改,一个事务提交后,另一个事务发生回滚,造成第一个事务的修改被覆盖

时间取钱事务A汇款事务B
T1开始事务
T2查询余额1000开始事务
T3查询余额1000
T4汇入100余额1100
T5提交事务B
T6取出100余额900
T7出现异常,事务A回滚
T8余额1000

第二类丢失更新/提交覆盖(写-写并发):对同一条记录进行修改,前一个事务的修改被后面事务的修改覆盖

时间汇款事务A取钱事务B
T1开始事务
T2开始事务查询余额1000
T3查询余额1000
T4取出100余额900
T5提交事务
T6汇入100余额1100
T7提交事务

脏读(读-写并法):一个事务读到了另一个事务未提交的修改

时间查询余额事务A汇款事务B
T1开始事务
T2开始事务
T3查询余额1000
T4汇入100余额1100
T5查询余额1100
T6出现异常,事务回滚
T7余额1000

不可重复读(读-写并法):一个事务范围内,多次查询某个数据,却得到了不同的结果(事务修改已提交)

时间查询余额事务A汇款事务B
T1开始事务
T2开始事务查询余额1000
T3查询余额1000
T4汇入100余额1100
T5提交事务
T6查询余额1100

幻读(读-写并法):事务A查询记录条数,由于事务B的增删导致事务A前后两次读取的记录条数不同

时间查询列表事务A新增记录事务B
T1开始事务
T2查询列表
a:1000
开始事务
T3插入记录
b:1200
T4提交事务
T5查询列表
a:1000b:1200

3.3.2 事务的隔离级别

为了解决上面的一系列并发事务问题,主流关系型数据库都会提供四种事务隔离级别。

读未提交(Read Uncommitted)

在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别是最低的隔离级别,虽然拥有超高的并发处理能力及很低的系统开销,但很少用于实际应用。因为采用这种隔离级别只能防止第一类更新丢失问题,不能解决脏读,不可重复读及幻读问题。

读已提交(Read Committed)

这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别可以防止脏读问题,但会出现不可重复读及幻读问题。

可重复读(Repeatable Read)

这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。这种隔离级别可以防止除幻读外的其他问题。

可串行化(Serializable)

这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读、第二类更新丢失问题。在这个级别,可以解决上面提到的所有并发问题,但可能导致大量的超时现象和锁竞争,通常数据库不会用这个隔离级别,我们需要其他的机制来解决这些问题:乐观锁和悲观锁

事务隔离级别脏读不可重复读幻读第一类丢失更新第二类丢失更新
读未提交
读已提交
可重复读
串行化

3.3.3 Innodb隔离级别实现原理

Innodb实现隔离界别主要有两种机制,MVCC,重点介绍下MVCC

MVCC,中文叫多版本并发控制,通过读取历史版本的数据,保证了事务的一致性读,MVCC实现依赖于隐式字段、undo log、快照读、Read View

隐式字段

对于InnoDB存储引擎,每一行数据都有两个隐藏列DB_TRX_ID(事务id)、DB_ROLL_PTR(回滚指针),如果表中没有主键和非NULL唯一键时,则还会有第三个隐藏的主键列DB_ROW_ID(行号)

  • DB_TRX_ID(事务ID):最近一次修改的事务ID
  • DB_ROLL_PTR(回滚指针):指向undo log的指针,多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个undo log,每条undo log也会指向更早版本的undo log,从而形成一条版本链
  • 假设表accout现在只有一条记录,插入该记录的事务Id为50
  • 如果事务B(事务Id为200),对id=1的该行记录进行更新,把balance值修改为1100

则形成的Undo Log链如下:

d9a64c8b220abf86cf25e71f7d4a5f1a.png

快照读:

读取的是记录数据的可见版本(有旧的版本),不加锁,普通的select语句都是快照读,如:

select * from account where id>2;

当前读:

读取的是记录数据的最新版本,显示加锁的都是当前读

select * from account where id>2 lock in share mode;
select * from  account where id>2 for update;

Read View

Read View就是事务执行快照读时,会先生成数据库系统当前的一个读视图(快照),记录当前系统中还有哪些活跃的读写事务,并把它们放到一个列表里(m_ids),通过ReadView隐式字段undo log版本链可以判断当前事务可见哪个版本的数据,保证的是读到的是事务已经提交的数据

判断的规则很简单:

  • 数据版本的 trx_id小于列表中最小的事务id,表明该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问
  • 如果被访问版本的 trx_id 大于m_ids列表中最大的事务id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问
  • 如果被访问版本的trx_id属性值在m_ids列表中最大的事务id和最小事务id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明版本的事务还是活跃的,该版本不可以被访问;如果不在,该版本的事务已经被提交,该版本可以被访问
  • 如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记

接下来看看InnoDB是怎么使用MVCC来实现隔离级别解决事务并发问题的

读已提交(Read Committed)

读已提交解决的是脏读问题,参考之前脏读的例子

时间查询余额事务A(id=100)汇款事务B(id=200)
T1开始事务
T2开始事务
T3查询余额1000
T4汇入100余额1100
T5查询余额1100
T6出现异常,事务回滚
T7余额1000

假设上次插入数据TRX_ID=50,则在T5时刻,undo log 版本链如下

d9a64c8b220abf86cf25e71f7d4a5f1a.png

事务A执行查询过程如下:

  • 在执行Select语句时会先生成一个ReadView,m_ids列表的内容就是[200]
  • 从版本链中查找可见的记录,最新版本trx_id值为200,在m_ids列表内,不符合要求,继续跳到下一个版本
  • 下个版本的trxid值为50,小于m_ids列表中最小的事务id 200,所以这个版本是符合要求的,返回余额1000

可以看到MVCC通过读取数据时生成ReadView解决了脏读的问题

那同样的能不能解决不可重复读问题呢?还是前面读已提交的问题案例

时间查询余额事务A(id=100)汇款事务B(id=200)
T1开始事务
T2开始事务查询余额1000
T3汇入100余额1100
T4查询余额1000
T5提交事务
T6查询余额1100

事务A第一次查询:在执行SELECT语句时会先生成一个ReadView,按照版本链去读,返回1000事务A第二次查询:在执行SELECT语句时会先生成一个ReadView,当前数据没有活跃事务,返回最新数据1100

每次生成ReadView并没有解决不可重复读的问题,那么InnoDB的解决办法是对于RR隔离级别在事务第一次读取数据时生成一个ReadView,在事务的过程中只用第一次生成的ReadView

事务A的第一次查询:生成ReadView,m_ids[200],事物B修改不可见返回1000事务A的第二次查询:使用第一次生成的ReadView,最新版本的数据余额1100,事务id200,虽然事务已经提交但是由于使用的第一次生成的ReadView,m_ids[200],不满足条件最终返回1000(可重复读)

总结:InnoDB使用MVCC的机制实现了读已提交和可重复读的隔离级别,解决了脏读,不可重复读问题

3.3.4 可串行化(Serializable)

该隔离级别不会使用 MVCC。如果使用的是普通的 select语句,它会在该语句后面加上 lock in share mode,变为一致性锁定读。假设一个事务读取一条记录,其他事务对该记录的更改都会被阻塞。假设一个事务在更改一条记录,其他事务对该记录的读取都会被阻塞。 在该隔离级别下,读写操作变为了串行操作