Mysql-事务的隔离性机制


事务就是要保证一组数据库操作要么全部成功,要么全部失败,事务支持是在引擎层实现的,而MyISAM是不支持事务的

事务的四大特性

  • 原子性:事务中的指令序列要么全部成功,要么全部失败
  • 一致性:事务操作前和操作后,数据满足完整性的约束
  • 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改,隔离性可以防止事务并发执行的时候由于交叉执行而导致的数据的不一致,因为多个事务同时使用相同的数据的时候,不会相互干扰,每个事务都有一个独立而完整的数据空间,对其他并发事务是隔离的。
  • 持久性:事务处理结束之后,对数据的修改就是永久的,即使系统故障也不会丢失

持久性是基于redo log(重做日志)实现的

原子性是基于undo log(回滚日志)实现的

隔离性则是通过MVCC(多版本并发控制)或者锁机制实现的

持久性则是基于前面三者的具体实现来保证的

1. 隔离性与隔离级别

当数据库上有多个事务同时执行的时候,就可能出现

  • 脏读(dirty read):读取到其他事务还没有提交的数据

  • 不可重复读(non-repetable read):前后读取到的相同数量的记录的内容不同

  • 幻读:前后读取到的记录数量不同

隔离得越严实,效率就会越低下,SQL标准的事务隔离级别有

  • 读未提交read uncommited:一个事务还没有提交的时候,它做的变更就能被其他事务看到
  • 读提交read committed:一个事务只能读取到其他事务已经提交的变更
  • 可重复读repetable read:一个事务在执行的过程中,总是跟这个事务在启动的时候看到的数据是一致的
  • 串行化serializable:对于同一行记录,写会加写锁,读会加读锁,当出现读写冲突的时候,后访问的事务必须等待前一个事务执行完成后,才能继续执行的

隔离级别的例子说明

在不同的隔离级别下,事务A将会有不同的返回结果

  • 如果隔离级别是读未提交,那么V1的值就是2,这时候事务B虽然还没有提交,但是其结果已经被A看到了,因此V2V3都将会是2
  • 如果隔离级别是读提交,那么V1就是1,因为这时候事务B还没有提交,但是V2=V3=2
  • 如果隔离级别是可重复读,那么V1=V2=1,而V3=2,事务在执行期间看到的数据必须是一致的
  • 如果隔离级别是串行化,那么V1=V2=1,当B执行将1修改为2的时候,这时候将会因为排他锁的原因而被阻塞,当事务A提交后,被唤醒,V3=2

在实现上,数据库内部会创建一个视图,访问的时候是以视图的逻辑结果为准的

可重复读的隔离级别下,这个视图是在事务启动的时候创建的,整个事务存在期间都使用这个视图

在Mysql中,可重复读的隔离级别很大程度上避免了幻读的问题

  • 针对快照读(普通的select语句),是通过MVCC方式解决了幻读的问题,因为可重复读的隔离级别下,事务执行过程中看到的数据一直跟这个事务启动的时候看到的数据的是一致的,这个期间,除了本事务对快照的操作可见之外,其他事务对快照的操作都是不可见的
  • 针对当前读(select for update语句),是通过next-key lock(记录锁+间隙锁)方式解决了幻读的问题,因为当执行了select for update语句的时候,就会加上next-key lock,如果有其他事务在next-key lock范围内插入了一条记录,那么这个插入语句就会被阻塞而无法成功插入,因此很好的解决了幻读的我问题

读提交的隔离级别下,这个视图是在每个SQL语句执行之前创建的

读未提交的隔离级别下,没有视图的概念,而是直接返回记录上的最新值

串行化的隔离级别下,直接以加锁的方式避免并行访问

mysql中,默认的事务隔离级别是RR(Read Repetable)


mysql> show variables like 'transaction_isolation';

+-----------------------+----------------+

| Variable_name | Value |

+-----------------------+----------------+

| transaction_isolation | READ-COMMITTED |

+-----------------------+----------------+

什么时候需要可重复读?

假设你在管理一个个人银行账户表,一个表存了账户余额,一个表存了账单明细,到了月底要做数据的校对,判断上个月的余额的差额是否和当月的账单明细一致,这时候如果有用户插入了数据,也不要影响我的校验结果

那么这时候使用可重复读就比较好

2. 事务隔离的实现

MVCC:多版本并发控制,通过undo log版本链和read-view实现事务的隔离

在Mysql中,实际上每条记录在更新的时候都会同时记录一条回滚操作,记录上的最新值,通过回滚操作,都可以得到前一个状态的值

假设一个值从1被按顺序修改成了2,3,4,在回滚记录中就会有类似的记录

A 开启事务,创建视图 A;

B 开启事务,创建视图 B,将 c 从 1 改为 2,同时向视图 A、B 中记录一条回滚记录(将 c 从 2 改回 1);

C 开启事务,创建视图 C,此时 c 的值为 2,将 c 从 2 改为 3,同时向视图 A、B、C 中记录一条回滚日志(将 c 从 3 改回 2);

此时视图 A 中有两条回滚记录,事务 A 再次获取 c 时依次执行这两条回滚记录,即可得到 c 最开始的值 1。

当前值是4,但是在查询这条记录,不同时刻启动的事务会有不同的read-view,如图中所示,在视图A、B、C中,这一个记录的值分别是1 2 4,同一条记录在系统中存在多个版本,就是数据库的多版本并发控制,对于read-view A,如果要得到1,那么就必须将当前值4,依照所有的所有的回滚操作才能得到

如果此时有一个事务将4修改成5,那么这个事务和read-viwe A B C是不会产生冲突的

这个是因为每个视图都是独立的,不会导致视图间的互相篡改,同时回滚日志的存在能够确保事务一旦失败,在后续中能够继续完成

Mysql中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会记录在这个回滚日志中,然后再执行相关的操作,如果在执行过程中遇到异常的话,那么就直接利用回滚日志中的信息将数据库还原在事务还没有执行的状态,当用户再次启动数据库的时候,数据库还能通过查询回滚日志来回滚之前未完成的事务

另外,MVCC的实现是依赖于隐藏字段Read Viewundo log。在内部实现中,InnoDB通过数据行的DR_TRX_IDRead View来判断数据的可见性,如果不可见,则通过数据行的DB_ROLL_PTR找到undo log中的历史版本,每个事务所读取到的历史版本可能是不一样的,在同一个事务中,用户能够看到的只有该事务创建Read View之前已经提交的版本和修改该事务本身所做的修改

回滚日志的存活周期

当没有事务对应的read-view需要这些日志的时候,这些日志就会被删除

为什么不要使用长事务?

长事务意味着系统里有很老的视图,由于这些事务可能随时访问数据库中的任何数据,所以在这个事务提交之前,数据库中关于它的回滚日志都将会保留,这将导致空间的大量占用,即使回滚段被清除了,但是这个事务所申请的磁盘空间不会被os所回收,只能被数据库系统标记为可用空间。

同时长事务还可能长时间占用锁资源,也可能将数据库搞垮

关于读未提交

因为可以读取未提交事务修改的数据,所以直接读取最新的数据即可

关于串行化

通过加读写锁的方式来断绝并行访问

关于读提交和可重复读

它们是通过Read View来实现的,它们的区别在于创建Read View的时机不同,Read View本质上就是一个数据快照,很像Redis中的RDB,记录了某一时刻数据的状态

读提交的隔离级别是在每个语句执行之前都会重新生成一个Read View

而可重复读的隔离级别则是在事务启动的时候都会生成一个Read View,然后整个事务期间都在用这个Read View

思考:关于读提交和读未提交,其实都是读取最新的记录,但是一个是读取事务执行完毕后的最新记录,一个是读取事务还没执行完的最新记录,你有思考过它们的底层到底是怎么实现的吗?

3. 事务的启动方式

Mysql的事务启动有以下这些方式

  • 显式启动事务语句,begin、start transaction,配套的提交语句是commit和回滚语句是roll back
  • set autocommit = 0,这个命令将这个线程的自动提交关闭,意味着你只执行一个select语句,这个事务会持续到你主动执行commit或者rollback或者关闭掉这个会话的时候结束

有些客户端框架会默认使用autocommit=0,如果是长连接,那么就会导致意外的长事务

如果使用了auto commit = 0,那么就会导致你执行完一个语句,那么这个语句就自动提交了,从而避免了长事务,同时这个自动提交是可控的

当你用begin显示开启的事务,如果执行commit则就会提交事务

如果执行的是commit work and chain,则就是提交事务并且自动启动下一个事务,这样也省去了开销,同时有一个好处是能够知道每一个语句是否处于事务中

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

4. 什么是MVCC?

为什么要进行并发控制?这是因为在事务的执行过程中,我们必须要确保数据库的状态是当前现实世界的一个反映,但是当在一个事务执行的过程中,会出现部分的状态不是当前现实世界的反映

当有多个事务并发执行的时候,一个事务拿到的初始状态就有可能是一个不符合现实世界的状态,因此,为了这些事务在开始执行的时候能够基于一个现实的状态进行执行,必须使得这些事务能够有能力去访问之前一个现实世界的快照,而不是一个不一致的状态

MVCC(Mutiversion Concurrency Control)是多版本并发控制的缩写,这项技术使得在InnoDB的事务隔离级别下执行一致性读有了保证,为了查询一些正在被其他事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不需要进行加锁了

5. 什么是快照读?什么是当前读?

快照读又称为一致性读,读取的是快照数据,不加锁的简单的select都属于快照读,也就是不加锁的非阻塞读,读取某一个快照建立时(可以理解为某一时间点的数据),快照读主要体现在select时,而在不同的隔离级别下,select的行为不同

  • 在可串行化的隔离级别情况下,普通select会变成当前读,也就是说加一个共享的读锁

有人在读的时候无法写入数据

  • 在RC隔离级别下,每次select都会建立新的快照

有人写入完毕后,每次select都会产生一个新的快照

  • RR的隔离级别下,只有第一次select才会建立快照,基于旧数据的修改操作,会对快照进行重建

什么叫当前读,当前读就是读取最新提交的数据,当前读读取的是记录的最新版本(最新数据而不是历史版本的数据),读取的时候还要保证其他并发事务不能够修改当前记录,会对读取的记录进行加锁,加锁的select,或者对数据进行增删查改都会进行当前读

6. ReadView在Mysql中是如何工作的?

经典的COMPACT的行格式中有几个隐藏的字段

  • row_id:这个键并不是必要的,在创建的表中有主键的时候,或者有不允许为NULL的UNIQUE键的时候,都不会包含row_id的隐藏列
  • trx_id:一个事务每次对某条聚簇记录进行改动的时候,都会把这个事务的事务id复制到trx_id隐藏列
  • roll_pointer:每次对某条聚簇索引记录进行改动的时候,都会把旧的版本写入到undo日志中,这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息

Read View有以下四个字段

  • creator_trx_id:创建该Read View的事务的事务ID
  • m_ids:事务的id,是指当前Read View被创建的时候,当前还未提交的事务的ID
  • min_trx_id:事务的id, 是指当前Read View被创建的时候,活跃中的事务的最小ID
  • max_trx_id:下一个事务的ID,是指创建Read View时数据库中应该给下一个事务的ID值,也就是全局事务中最大的事务ID值+1

那么这个ReadView的作用就是限定了哪些记录是当前事务可见的或者不可见的

首先第一种情况,就是事务自己修改了自己的记录,那么这时候被访问的一个版本的特征是trx_id(产生这个版本的事务的ID)与ReadView中的creator_trx_id值是相同的,那么翻译一下就是说:当前修改当前版本的事务是创建当前快照的事务。

第二种情况,如果记录的trx_id的值小于Read_View中的min_trx_id, 那么就说明这个事务已经提交了,这个版本对于当前事务就是可见的

第三种情况,如果记录的trx_id的值大于等于Read View中的min_trx_id而小于Read View中的max_trx_id,这时候,事务有可能提交,也有可能没有提交,需要通过m_ids进行检查

如果trx_idm_ids中,那么就证明事务还没有被提交,因此这个版本不可见

如果trx_id不在m_ids中,那么就证明事务被提交了,因此这个版本是可见的

第四中情况,如果记录的trx_id的值是大于等于Read View中的max_trx_id,那么就证明这个事务是在Read View之后被创建的,是不可见的

它的实现原理可以解读为一句话:过去提交的事务可以被读取到,当前活跃的事务做的操作无法被读取到

7. 可重复读是如何工作的?

可重复读隔离级别是启动事务的事务生成一个Read View,然后整个事务期间都在用这个Read View

举一个例子:如图所示,数据库中先后启动了两个事务,在可重复读的隔离级别下,开启了两个事务A和事务B

事务A的ID是51而事务B的ID是52,由于事务B比事务A后启动,因此在事务B所创建的视图中的有m_ids={51,52}

然后事务启动了,做了如下的操作:

  • 事务B只有读取操作,不断地读取小林的账户余额
  • 事务A执行存钱操作,执行一个update的操作
  • 事务B再读取,账户还是100
  • 事务A提交了事务
  • 事务B再读取,账户还是100

我们来分析一下,上面到底发生了什么?

我们从事务A执行存钱操作,执行一个update的操作看起,这个操作会生成一个新的记录行,这个记录为了和以前的记录关联,因此利用回滚指针,把当前新的记录和以前的记录串在一起。接下来的关键是:事务B的三次查询,为什么结果都是一样的?

首先第一次查询,首先明确,事务的B的trx_id是52,因此它在查询的时候会先看这条记录的修改的事务的ID,它发现是50,然后检查ReadView,ReadView中的min_trx_id是51,也就是说,当前活跃的事务的最小的ID是51,那么50早就提交了,否则的话它就会在活跃列表里面,因此这个版本是可见的,最终就被读到了

然后是第二次查询,这时候事务B做了一个update操作,插入了一条新的记录,这条新的记录的操作事务的ID是51,然后检查ReadView,其中的min_trx_id是51,于是进入了min_trx_id<=trx_id<max_trx_id的部分,在这个部分中, 需要检查事务是否已经提交,然后检查trx_id in m_ids?,发现确实有,于是没有提交,不读取第一个版本,通过回滚指针找到下一个可见的版本

然后是第三次查询,这时候事务A已经提交了,但是能不能看到事务A的结果呢?

我们知道事务B的可见性判断都是基于事务启动时的ReadView的,因此在这种情况下依然认为事务A还在活跃,不会读取第一个版本,而是顺着第一个版本的回滚指针向下查找。

8. 读提交是如何工作的?

  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 修改数据(还没提交事务),将小林的账户余额从 100 万修改成了 200 万;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 提交事务;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 200 万;

依然是对MVCC规则的运用,我们来分析事务B的查询结果

首先是第一次查询,它首先创建了一个ReadView,此时trx_id = 52,向下读取版本链,只能读取到事务trx_id=50做的一个修改

然后是第二次查询,又创建一次ReadView,此时trx_id = 52,向下读取版本链,第一个版本链是trx_id =51 然后查询m_ids={51,52},发现51依然在活跃,因此判断未提交,不读取该版本的数据

事务A提交了,此时的事务活跃列表变为了{52}

然后是第三次查询,此时新建的快照就是m_ids = {52},然后向下读取版本链,发现trx_id=51 not in m_ids, 于是可以读取了,这一步实现了读提交


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录