2 事务

来源:

《MySQL实战45讲》

MVCC:https://mp.weixin.qq.com/s/bM_g6Z0K93DNFycvfJIbwQ(MVCC算法图)

MySQ Binlog日志格式:https://www.cnblogs.com/baizhanshi/p/10512399.html

Force Log at Commit:https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html
在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化。

总结来说:事务的隔离性由多版本控制机制和锁实现,原子性、一致性和持久性由InnoDB的redolog(持久性)、undolog(原子性)和Force Log at Commit机制(持久性)实现,原子性和持久性则保证了一致性

1 事务隔离性与隔离级别

在MySQL中,事务支持是在引擎层实现的,此处讲解InnoDB的事务的隔离性

读未提交:⼀个事务还未提交,但它所做的变更可以被其他事务看到
读提交:⼀个事务提交之后,它所做的变更才可以被别的事务看到;
所以如果此事务修改了值再提交,则其他事务在此事务提交前后查询此值时会结果不一致
可重复读:⼀个事务执行过程中看到的数据是⼀致的,当前事务未提交时,其他事务修改的值不会影响自己
串行化:对应⼀个记录会加读写锁,出现冲突的时候,后访问的事务必须等前⼀个事务执行完成才能继续执行

在实现上,数据库会创建⼀个视图,访问的时候以视图的逻辑结果为准,这个视图是InnoDB在实现MVCC时用到的一致性读视图,即read-view,作用是在事务执行期间定义“我能看到什么数据“(类似内存屏障?):

在“读未提交”隔离级别下,直接返回记录上的最新值,没有视图概念:
因此如果读取到的值被其他事务做了修改,则如下图的当前值立马就被更改了,读取到的值也就跟原先的不一样了;

在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的:
因此如果读取到的值被其他事务做了修改,但是还未提交,没有更新到表里,SQL语句的结果还是不变;
但是如果修改了值后提交了,则意味着表进行了更新,所以结果也就变了

在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图,此视图可以认为是静态的,不受其他事务更新的影响,使用的技术是”快照“:
因此就算提交了,也不影响这个静态的视图,也就不存在两次查询结果不一致的情况了;
不过当前事务可能在查询时只出现了三条数据,但是当其他事务插入数据时,当前事务再次查询,就出现了四条数据,此为幻读,原因见下文解析

“串行化”隔离级别下直接用加锁的方式来避免并行访问:
直接加锁,产生冲突时,其他后访问的事务被锁住,等当前事务提交了之后,其他后访问的事务才能继续执行;
即事务读数据则加表级共享锁,事务写数据则加表级排他锁

解析一下可重复读(在2中会进一步解析):

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

假设⼀个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录:
在这里插入图片描述
当前值是4,而在查询这条记录的时候,不同时刻启动的事务会有不同的read-view,即同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(MVCC)

如对于read-view A,要得到1,就必须将当前值依次执行图中所有的回滚操作得到。

可以看到,这种方式只是防止了更新,并没有防止插入、删除操作,这也是幻读的原因

当系统里没有比这个回滚日志更早的read-view的时候,就可以删除这些回滚日志了;
可以看到,这种实现方式如果使用了长事务,会导致大量的回滚记录大量占用存储空间;
还占用锁资源,可能拖垮整个库

2 事务的隔离问题

本文涉及的表如下:

CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

insert into t(id, k) values(1,1),(2,2);

2.1 可重复读的实现

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”;
注意,这个快照是基于整库的,这个快照是怎么实现的呢?先看看多版本和row trx_id的概念。

InnoDB里面每个事务有⼀个唯⼀的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的;
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会⽣成⼀个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的⼀行记录,其实可能有多个版本(row),每个版本有自己的row trx_id

如图就是⼀行数据被多个事务连续更新后的状态,图中的三个虚线箭头,就是undo log,V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依次执行U3、U2算出来:
在这里插入图片描述
多版本和row trx_ud的概念看完之后,可以得出快照的实现方式了:
⼀个事务只需要在启动的时候声明:“以我启动的时刻为准,如果⼀个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上⼀个版本”。

实现上,InnoDB为每个事务构造了⼀个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID;
“活跃”指的就是,启动了但还没提交。

数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。

视图数组和高水位组成了当前事务的⼀致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的row trx_id和这个⼀致性视图的对比结果得到的。

这个视图数组把所有的row trx_id 分成了几种不同的情况:
在这里插入图片描述
对于当前事务的启动瞬间来说,⼀个数据版本的row trx_id,有以下几种可能:(当前事务属于黄色部分)

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况
    a. 若 row trx_id在数组中,表示这个版本是由启动了但还没提交的事务生成的,不可见;
    b. 若 row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于图1中的数据来说,如果有⼀个事务,它的低水位是18,那么当它访问这⼀行数据时,就会从V4通过U3计算出V3,所以在它看来,这⼀行的值是11。

有了这个声明后,系统里面随后发生的更新,就跟这个事务看到的内容⽆关了,因为之后的更新,生成的版本
⼀定属于上面的2或者3(a)的情况,而对这个事务来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了;

可重复读隔离级别实现的总结:每个事务拥有一个数组,数组每一项都是一个当前正在“活跃(启动了但还没提交)”的所有事务ID,将数组里面事务ID的最小值标记为低水位,访问数据时,找到第一个版本的row trx_id比低水位小的,或与自己的事务ID相同的版本,说明这个版本可见,版本对应的值就是此事务所看到的。
这也对应着事务MVCC的实现,MVCC是实现可重复读的基础:
(1)每个事务都有⼀个事务ID,叫做transaction id(严格递增)
(2)事务在启动时,找到已提交的最大事务ID记为up_limit_id。
(3)事务在更新⼀条语句时,比如id=1改为了id=2.会把id=1和该行之前的row trx_id写到undo log里,并且在数据页上把id的值改为2,把修改这条语句的transaction id记在该行的行头。
(4)定⼀个规矩:⼀个事务要查看⼀条数据时,必须先用该事务的up_limit_id与该行的transaction id做比对,如果up_limit_id>=transaction id,那么可以看;如果up_limit_id<transaction id,则只能去undo log里去取;
去undo log查找数据的时候,也需要做比对,必须up_limit_id>transaction id,才能返回数据

InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力,而不是将数据库所有数据都copy一份。

MVCC添加的字段:row_id(每一次更新的唯一标识)、trx_id(每一次更新的事务id)、Roll Point(上一次更新的row_id的值),跟其他业务列合起来成为一行一行的数据,成为Undolog的页page,一个个页page又存在区Extent中,区Extent又在段segment中,段segment中又在叶节点段中,即数据段,层层划分;
常用说法是:回滚段存在undolog页里

undolog就存在段segment中,段位于共享表空间中;
undolog的写入会产生redolog,因为undolog页需要持久性的保护,所以需要记录到redolog里

因此,redolog buffer不仅要存数据页、索引页,还要存undolog页;
undolog和脏页按照checkpoint进行落盘,落盘后相应的redolog就可以删除了

2.2 案例

在这里插入图片描述
事务A开始前,系统里面只有⼀个活跃事务ID是99;
事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务;
三个事务开始前,(1,1)这⼀行数据的row trx_id是90。

结果:事务B查到的k值是3,而事务A查到的k值是1

  • 解析

首先看事务A:
在这里插入图片描述
可以看到,根据2.1的规则,找到了历史版本2,所以事务A查到的k值为1,而101和102都比高水位大,不可见

可以用如下简单规则来判断:

情况1 版本未提交,不可见;

情况2 版本已提交,但是是在视图创建后提交的,不可见;

情况3 版本已提交,而且是在视图创建前提交的,可见。

事务A的查询语句的视图数组是在事务A启动的时候生成的;
这时候(1,3)还没提交,属于情况1,不可见;
(1,2)虽然提交了,但是是在视图数组创建之后提交的,属于情况2,不可见;
(1,1)是在视图数组创建之前提交的,可见。

然后看事务B

事务B用到了一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

因此,虽然按照上面所说的简单规则在判断的时候,发现事务B的视图数组是先生成的,之后事务C才提交,应该看见的是(1,1),看不见(1,2),但是因为进行了更新,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx_id是101。

所以,在执行事务B查询语句的时候,⼀看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。

  • ”当前读“’

除了update语句外,select语句如果加锁,也是当前读。

所以,如果把事务A的查询语句select * from t where id=1修改⼀下,加上lock in share mode 或 for update,也都可以读到版本号是101的数据,返回的k的值是3。
下面这两个select语句分别加了读锁(S锁,共享锁)和写锁(X锁,排他锁)。

select k from t where id=1 lock in share mode;
select k from t where id=1 for update;
  • 案例加强版
    在这里插入图片描述
    事务C’的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。

需要根据两阶段锁协议来进行判断:行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。

此时事务C’没提交,也就是说(1,2)这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读,所以结果也是跟之前案例 一样的:
在这里插入图片描述

  • 读提交级别
    在这里插入图片描述
    事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:(如下分析使用简易判断,建议使用2.1的方法进行判断)
    (1,3)还没提交,属于情况1,不可见;
    (1,2)提交了,属于情况3,可见。
    所以,这时候事务A查询语句返回的是k=2,事务B查询结果k=3。

注意:start transaction with consistent snapshot;的意思是从这个语句开始,创建⼀个持续整个事务的⼀致性快照,所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。

  • 总结

对于可重复读,查询只承认在事务启动前就已经提交完成的数据。

对于读提交,查询只承认在语句启动前就已经提交完成的数据。

当前读则总是读取已经提交完成的最新版本,需要加锁(update和select)

视图数组有点类似内存屏障?

3 幻读

3.1 带来的问题

可重复读级别下,其他被扫描到的,但是不满足条件的行会不会被加锁呢?:
在这里插入图片描述
Q3读到id=1这一行的现象就被称为幻读:⼀个事务在前后两次查询同⼀个范围的时候,后⼀次查询看到了前⼀次查询没有看到的行。

注意:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插⼊的数据的。因此,幻读在“当前读”下才会出现。
  2. 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读;
    幻读仅专指“新插⼊的行”。

带来的问题:
在这里插入图片描述
在T1时刻,session A 只是给id=5这⼀行加了行锁, 并没有给id=0这行加上锁,因此,session B在T2时刻,是可以执行这两条update语句的,这样,就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。
session C也是⼀样的道理,对id=1这⼀行的修改,也是破坏了Q1的加锁声明。

扫描过程中碰到的行不加锁会造成数据不一致的问题,加了锁可以解决:
在这里插入图片描述
如果不加锁的话,即上图blocked不存在,则binlog日志为(Statement格式):
在这里插入图片描述
这个语句序列,不论是拿到备库去执⾏,还是以后用binlog来克隆⼀个库,这三行的结果,都变成了
(0,5,100)、(1,5,100)和(5,5,100),跟主库不一致,这就是数据不一致性问题:

  1. 经过T1时刻,id=5这⼀行变成 (5,5,100),这个结果最终是在T6时刻正式提交的;
  2. 经过T2时刻,id=0这⼀行变成(0,5,5);
  3. 经过T4时刻,表里面多了⼀行(1,5,5);

但是上述幻读问题,即T4还是不能解决,因为给所有行加锁的时候,产生幻读的这⼀行,即id=1还不存在,不存在也就加不上锁。

3.2 如何解决幻读

(可重复读级别下,select加for update,并使用间隙锁即可,或直接使用串行化,等于加表锁)

新插入记录这个动作,要更新的是记录之间的“间隙”,因此InnoDB引入间隙锁:锁两个值之间的空隙

如图,如果插入6个数据,那么会产生7个间隙,给这7个间隙加上锁,这样就确保了无法再插入新的记录:
在这里插入图片描述
间隙锁之间不存在冲突关系,跟间隙锁存在冲突关系的,是“往这个间隙中插⼊⼀个记录”这个操作,如图:
在这里插入图片描述

假设表中没有c=7这个记录,那么session A加的是间隙锁(5,10)。⽽session B也是在这个间隙加的间隙锁;
它们有共同的目标,即:保护这个间隙,不允许插入值,但它们之间是不冲突的。

间隙锁的引⼊,可能会导致同样的语句锁住更大的范围,影响了并发度,如图:
在这里插入图片描述

  1. session A 执行select … for update语句,由于id=9这⼀行并不存在,因此会加上间隙锁(5,10);
  2. session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  3. session B 试图插⼊⼀行(9,9,9),被session A的间隙锁挡住了,只好进入等待;
  4. session A试图插⼊⼀行(9,9,9),被session B的间隙锁挡住了。

至此,两个session进⼊互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。

间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间;
也就是说,表t初始化以后,如果用select * fromt for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20,25]、(25, +suprenum]。

常用解决:把隔离级别设置为读提交,这样就没有间隙锁了;
但同时要解决可能出现的数据和日志不⼀致问题,需要把binlog格式设置为row。

发布了235 篇原创文章 · 获赞 264 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41594698/article/details/103530838