一、什么是事务
在MySQL中键入begin命令,一组事务就开始了,随后可输入多个SQL语句,提交后表格就被实际修改了。所以事务现象上可以简单理解成一组SQL语句构成的整体,要么全部成功,要么全部失败。但实际上,一张表格可能有同时多个事务在修改和访问,很可能出现并发问题。
所以为了让程序员不需要考虑各种各样的潜在错误和并发问题,一个完整的事务不是简单的sql集合,还需要满足如下四个属性:
- 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发地完成预定的工作。实际上,一致性是另外三种特性得到保证的结果。
- 隔离性:数据库允许许多并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
- 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
注:上面四个属性简称为 ACID
二、隔离级别
为了保证隔离性,事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)下面分别做介绍:
- 读未提交:一端在事务中做的修改还未提交就能被另一端可以看到,这种读到没提交的数据的行为叫脏读。
- 读提交:一端提交事务后才能被另一端看到,但这会导致在一个事务内部第一次select看到的是提交前的数据,第二次select看到提交后的数据,这是不合理的,这种现象叫不可重复读。
- 可重复读:直到当前事务结束了,才能看到其它事务变更后的数据。
- 串行化:事务加锁执行,即当前库有事务在执行时的同时不能有其它事务执行。
注:如果可重复读隔离级别下同时写了怎么办?上述隔离机制针对的是读写并发的场景,如果同时读不存在并发问题,同时写当然选择串行执行。
三、隔离的实现原理
1.事务ID
mysql面临同一时间处理多个事务的问题,所以要对事务做管理,所以事务会被封装成对象,然后用特定数据结构进行管理。基于此,每个事务都能有单向增长的id,因此可根据id大小判断事务被创建的时间顺序。
2.三个隐藏字段
我们看到的表显示并不完全,MySQL其实还有四个隐藏字段:
- 最近修改该条记录的事务ID(MySQL中一行信息也称为一行记录)
- 回滚指针,指向该记录的上一版本地址,也就是每次我们在事务中对记录做修改时,会创建一分历史数据的拷贝
- 自增的隐藏主键(在表没有主键、也没有合适的唯一非空索引时生成)
注:此外还有一个删除标记位,用01表示该记录是否被更新
3.undo日志
前面索引学习中提到过,MySQL在内存中开了一块缓冲区Buffer Pool,另外还开了一块区域作为undo日志缓冲区。
4.多版本并发控制MVCC
前置知识大概介绍后,下图展示MySQL如何进行记录的版本控制:
注:一条条undo log就叫做版本。
- 上图演示的是读操作,如果是删除操作,仅需将删除标志位设为1再放到undolog里去,逻辑上这条记录已经被删除了,如果后续快照读(后面讲解)其它事务逻辑上判定这条事务的删除自己不可见,就会把这条记录重新恢复出来。当没有活跃事务需要用到旧记录时才会真正的从B+树中删除该记录。
- 如果是插入操作,新插入的记录没有历史版本,InnoDB会生成一条Insert undo log并放入undo log中。这条日志里只记录这条新记录的主键信息,若该记录需要回滚,就会根据这个主键找到B+树中的数据并删除。一旦事务成功提交,这条undo log会被标记为可丢弃,由后台线程清理掉。
undo log是否会满?
undo log的服务对象是一个事务的,commit以后会清空undo log,所以不用担心undo log满溢。这就是为什么commit后下次再打开事务无法回滚。
那么事务调用select时看到的是最新版本还是历史版本?
- 当前读:读取最新版本
- 快照读:读取历史版本
什么决定了select使用当前读还是快照读呢?这就要看具体的SQL操作:
- 只读不改->快照读
- 要改数据(UPDATE/DELETE/INSERT)->当前读(先当前读拿最新数据,再加锁修改)。
- 强制要求看最新数据且加锁(SELECT FOR UPDATE)-> 当前读(强制拿最新数据并加锁)。
下面来看看什么决定了快照读可以读到的内容。
5.读视图(read view)
read view是在执行快照读的时被创建的类对象,用于后续决定快照读读的是哪个版本。下面给出该对象的主要字段。
m_ids;//一张列表,用来维护Read View生成时刻,系统正活跃的事务ID up_limit_id;//记录m_ids列表中事务ID最小的ID low_limit_id;//ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1 creator_trx_id//创建该ReadView的事务ID某个事事务执行快照读时,后根据数据版本记录的事务ID(DB_TRX_ID)来判断这个版本自己是否可见(注意版本始终在那里,如果想强制读取是没问题的,这里只是一种软性约束):
- DB_TRX_ID == creator_trx_id,那这个版本就是我自己创建的,当然可以看到。
- DB_TRX_ID < up_limit_id,那这个版本已经是古早事务创建的了,至少在我形成快照时它已经退出了,所以我看见这个版本是没关系的。
- DB_TRX_ID >= low_limit_id,这个版本是我形成快照后才启动的事务创建的,逻辑上我能读到未来的消息,所以不可见。
- DB_TRX_ID >= up_limit_id&&DB_TRX_ID < low_limit_id,则进行二次判断:如果DB_TRX_ID在m_ids中,说明他是我进来后正在活跃的事务,我不该去窥视他的版本;如果DB_TRX_ID不在m_ids,说明这个事务也已经退出了,可以看见。
至此我们已经知道快照读能看见哪些版本,可RC(读提交)和RR(可重复读)都会执行快照读,他们的功能为什么会形成差异呢?很简单:
- RC隔离级别下,每次快照读都形成新的快照,意味着每次都可以读到在此次快照形成时之前的所有版本
- RR隔离级别下,只使用第一次生成的快照。所以无论读几次都和第一次读到的版本一模一样。