- 不可重复读和幻读的区别在于,不可重复读是因为其他事务进行了 update/delete(sql1992这样定义,把 delete 归在不可重复读)
- 幻读主要是因为其他事务进行了 insert
Oracle 使用的是读已提交,MySQL 使用的是可重复读。由于隔离级别的提升是通过牺牲效率的(就是隔离级别越高,效率越低),所以通常可以认为 Oracle 的效率比 MySQL 高一点点
所以还有个问题就是,在项目迁移的时候,如果数据库发生了变化,代码的执行结果可能会不一样。比如 Spring 的 @Transactional 。这个注解默认使用的是数据库的隔离级别,如果换了数据库,隔离级别就不一样了,执行结果可能就会不同
面试题:MySQL 是如何实现隔离级别的?
答:有两种方式
上面所说的事务和隔离级别的概念,其实都是 SQL 标准中通用的概念,但是不同的数据库产品对标准的实现也会有很大的不同。譬如在 SQL 标准中,RR 隔离级别解决了不可重复读问题,但是依然存在幻读现象;而在 MySQL 的 RR 隔离级别下,通过多版本快照读和间隙锁技术解决了幻读问题(其实也不算解决,还是会有幻读问题)。
“幻读”的这个“读”字在 MySQL 里本身就存在歧义,这个“读”到底指的是快照读,还是当前读?如果是快照读,MySQL 通过版本号来保证同一个事务里每次查询得到的结果集都是一致的;如果是当前读,MySQL 通过 Next-key locks 保证其他事务无法插入新的数据,从而避免幻读问题。当然,如果你的场景里一会是快照读,一会是当前读,导致幻读现象,MySQL 也只能表示自己很无奈了。
SQL 规范中定义的四种隔离级别,分别是为了解决事务并发时可能遇到的四种问题,至于如何解决,实现方式是什么,规范中并没有严格定义。锁作为最简单最显而易见的实现方式,可能被广为人知,所以大家在讨论某个隔离级别的时候,往往会说这个隔离级别的加锁方式是什么样的。其实,锁只是实现隔离级别的几种方式之一,除了锁,实现并发问题的方式还有时间戳,多版本控制等等,这些也可以称为无锁的并发控制。
传统的隔离级别是基于锁实现的,这种方式叫做 基于锁的并发控制(Lock-Based Concurrent Control,简写 LBCC)。通过对读写操作加不同的锁,以及对释放锁的时机进行不同的控制,就可以实现四种隔离级别。传统的锁有两种:读操作通常加共享锁(Share locks,S锁,又叫读锁),写操作加排它锁(Exclusive locks,X锁,又叫写锁);加了共享锁的记录,其他事务也可以读,但不能写;加了排它锁的记录,其他事务既不能读,也不能写。另外,对于锁的粒度,又分为行锁和表锁,行锁只锁某行记录,对其他行的操作不受影响,表锁会锁住整张表,所有对这个表的操作都受影响。
归纳起来,四种隔离级别的加锁策略如下:
再看 读已提交,它是为了解决脏读问题,只能读取已提交的记录,要怎么做才可以保证事务中的读操作读到的记录都是已提交的呢?很简单,对读操作加上 S 锁,这样如果其他事务有正在写的操作,必须等待写操作提交之后才能读,因为 S 和 X 互斥,如果在读的过程中其他事务想写,也必须等事务读完之后才可以。这里的 S 锁是一个临时 S 锁,表示事务读完之后立即释放该锁,可以让其他事务继续写,如果事务再读的话,就可能读到不一样的记录,这就是 不可重复读 了。为了让事务可以重复读,加在读操作的 S 锁变成了持续 S 锁,也就是直到事务结束时才释放该锁,这可以保证整个事务过程中,其他事务无法进行写操作,所以每次读出来的记录是一样的。最后,序列化 隔离级别下单纯的使用行锁已经实现不了,因为行锁不能阻止其他事务的插入操作,这就会导致幻读问题,这种情况下,我们可以把锁加到表上(也可以通过范围锁来实现,但是表锁就相当于把表的整个范围锁住,也算是特殊的范围锁吧)。
虽然数据库的四种隔离级别通过 LBCC 技术都可以实现,但是它最大的问题是它只实现了并发的读读,对于并发的读写还是冲突的,写时不能读,读时不能写,当读写操作都很频繁时,数据库的并发性将大大降低,针对这种场景,MVCC 技术应运而生。MVCC 的全称叫做 Multi-Version Concurrent Control(多版本并发控制)
这个概念其实很好理解,MySQL加锁之后就是当前读。假如当前事务只是加共享锁,那么其他事务就不能有排他锁,也就是不能修改数据;而假如当前事务需要加排他锁,那么其他事务就不能持有任何锁。总而言之,能加锁成功,就确保了除了当前事务之外,其他事务不会对当前数据产生影响,所以自然而然的,当前事务读取到的数据就只能是最新的,而不会是快照数据。
快照读是针对当前读而言,指的是在RR隔离级别下,在不加锁的情况下MySQL会根据回滚指针选择从undo log记录中获取快照数据,而不总是获取最新的数据,这也就是为什么另一个事务提交了数据,在当前事务中看到的依然是另一个事务提交之前的数据。
快照:拍照,利用所有的数据都有多个版本的特性,存的是版本号(事物id)。
我们先看看MySQL默认隔离级别RR下的一个例子(注意,test和test2两张表一开始都是空表,均只有id和name两个字段)。
场景1(事务1操作数据之后再进行第一次查询):
场景2(事务1不进行任何操作,事务2先开始第一次查询)
通过上面两个场景中我们可以得出结论:
RR隔离级别快照并不是在BEGIN就开始产生了,而是要等到事务当中的第一次查询之后才会产生快照,之后的查询就只读取这个快照数据
场景3(事务2先进行一次t1表查询之后,事务1再去操作其他表t2)
从场景3我们可以得出结论:RR隔离级别快照并不只是针对当前所查询的数据,而是针对当前MySQL中的所有数据(跨库也一样,只要在同一个MySQL)
总结:
在 InnoDB 引擎中,每一行记录会有三个隐藏列:事务id(DB_TRX_ID)、回滚指针(DB_ROLL_PTR)、隐藏的 id (当 MySQL 没有建主键的时候,也没有 unique 字段的时候,用 row_id 作为默认主键,这个隐藏的 id 就是 row_id)
在了解 MVCC 的查询机制前,还得知道 undo log 和 read view。MVCC 的实现主要靠着两个东西
undo log:记录着数据表记录行的多个版本,也就是事务执行过程中的回滚段,其实就是 MVCC 中一行原始数据的多个版本镜像
read view:主要用来判断当前版本数据的可见性,记录的是当前运行中的事务。
undo log:
undo log是为回滚而用,具体内容就是
copy事务前
的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
我的理解就是:可以从 undo log 这个日志文件中取出历史数据,可以通过 read view 去判断一个事务是否提交了。毕竟,在 RR 和 RC 的两种级别下,A事务没有提交,B事务是看不到 A 的。所以如果A事务在 read view 中,那么就说明A事务是没有提交的,那么A事务中对数据库作的修改,B事务是看不见的,可以看下面的例子解释很好
还有要注意的就是:已提交读和可重复读的区别就在于它们生成ReadView的策略不同
ReadView中主要就是有个列表来存储我们系统中当前活跃着的读写事务,也就是begin了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。假设当前列表里的事务id为[80,100]。
这些记录都是去版本链里面找的,先找最近记录,如果最近这一条记录事务id不符合条件,不可见的话,再去找上一个版本再比较当前事务的id和这个版本事务id看能不能访问,以此类推直到返回可见的版本或者结束。
举个例子 ,在读已提交隔离级别下:
比如此时有一个事务id为100的事务,修改了name,使得的name等于小明2,但是事务还没提交。则此时的版本链是
那此时另一个事务发起了select 语句要查询id为1的记录,那此时生成的ReadView 列表只有[100]。那就去版本链去找了,首先肯定找最近的一条,发现trx_id是100,也就是name为小明2的那条记录,发现在列表内,所以不能访问。
这时候就通过指针继续找下一条,name为小明1的记录,发现trx_id是60,小于列表中的最小id,所以可以访问,直接访问结果为小明1。
那这时候我们把事务id为100的事务提交了,并且新建了一个事务id为110也修改id为1的记录,并且不提交事务
这时候之前那个select事务又执行了一次查询,要查询id为1的记录。
这个时候关键的地方来了
如果你是读已提交隔离级别,这时候你会重新一个ReadView,那你的活动事务列表中的值就变了,变成了[110]。
按照上的说法,你去版本链通过trx_id对比查找到合适的结果就是小明2。
如果你是可重复读隔离级别,这时候你的ReadView还是第一次select时候生成的ReadView,也就是列表的值还是[100]。所以select的结果是小明1。所以第二次select结果和第一次一样,所以叫可重复读!
也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。
这就是Mysql的MVCC,通过版本链,实现多版本,可并发读-写,写-读。通过ReadView生成策略的不同实现不同的隔离级别。
在innodb中,创建一个新事务的时候,innodb会将当前系统中的活跃事务列表(trx_sys->trx_list)创建一个副本(read view),副本中保存的是系统当前不应该被本事务看到的其他事务id列表。当用户在这个事务中要读取该行记录的时候,innodb会将该行当前的版本号与该read view进行比较。
具体的算法如下:
需要注意的是,新建事务(当前事务)与正在内存中commit 的事务不在活跃事务链表中。
对应的代码如下:
函数:read_view_sees_trx_id。
read_view中保存了当前全局的事务的范围:
【low_limit_id, up_limit_id】
1.当行记录的事务ID小于当前系统的最小活动id,就是可见的。
if (trx_id < view->up_limit_id) {
return(TRUE);
}
2.当行记录的事务ID大于当前系统的最大活动id,就是不可见的。
if (trx_id >= view->low_limit_id) {
return(FALSE);
}
3.当行记录的事务ID在活动范围之中时,判断是否在活动链表中,如果在就不可见,如果不在就是可见的。
for (i = 0; i < n_ids; i++) {
trx_id_t view_trx_id
= read_view_get_nth_trx_id(view, n_ids - i - 1);
if (trx_id <= view_trx_id) {
return(trx_id != view_trx_id);
}
}
意向锁又分为:
- 意向共享锁
- 意向排他锁
又名读锁、S 锁,对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即共享锁可多个共存),但无法修改。想要修改必须等所有共享锁都释放完之后才能进行
加锁:
select * from table lock in share mode
释放锁:
Commit、Rollback
读锁的作用:
加了读锁,可以确定一个事务是否读取完了(读取完会释放锁),例子:进出动物园都要刷卡,这样在一天结束之后,只需要检查门口的机器就能知道进去了多少人,还有多少人没出来
又名写锁、X 锁,对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作
加锁:
自动:DML 语句默认会加排它锁
手动:select * from user where id=1 for update
释放锁:
Commit、Rollback
注意:均是表锁、无法手动创建
为什么要加意向表锁?
意向锁不是来锁定数据的,意向表锁的作用是为了确保我们在加表锁的时候,不用去逐行扫描我们的数据,不用去逐行的判断里面有没有行锁(为了提高我们加表锁的效率)
只要加了共享锁和排他锁就会自动加上意向锁,而且意向锁只能主动加,不能我们自己手动加
锁的区间:
间隙:就是记录之间的间隙(间隙锁不排他,就是加了间隙锁还可以加排它锁)
临键(Next-Key):记录 + 间隙
Record lock:单个行记录上的锁,锁的是那一行
Gap lock:间隙锁,锁定一个范围,不包括记录本身(InnoDB 引擎独有的,左开右开区间)
Next-key lock:record+gap 锁定一个范围,包含记录本身(左开右闭区间)
间隙锁是根据数据库中的记录进行划分的,如下图:
数据库中的数据:
自己的总结:
MySQL 的隔离级别中的读已提交和可重复读和传统的实现方式不一样,是在 LBCC 的基础,MVCC的辅助上实现的。而 LBCC 就是基于锁的,就是加一些行锁、表锁之类的,四种隔离级别中只有序列化是表锁,其他都是行锁。
其中 读未提交,因为别人可以随时看见你修改的数据,说明你在写的时候,别人是可以读的,所以读未提交,读-读写无锁、写-读无锁写上锁。
读已提交,别人不能随便看见你修改的数据了,只能在你提交之后,也就是说读也上锁了,也就是 读-读写无锁,写-读上临时锁写上锁
可重复读,在一个事务中,你读取的数据不能变,也就是说整个事务中忽略其他事务对数据库做的改变,或者说他们不能改变,即你读的时候也上锁了,读-读无锁写上锁,写-读写上锁
MVCC 的实现原理最根本就是在判断是否取同一个事务中的数据,以及怎么去取同一个事务或者之前的哪个可见的事务,前者通过 read view,后者通过 undo log。比如 可重复读就要求在整个事务中的时候,别人都不能对我有所干扰,所以我把自己需要的范围内的列都锁起来。因为 undo log 是把很多历史数据串联在一起的,有当前正在运行的事务所修改的,也有之前的,但是可重复读要求别人没有提交的事务,我不能看见,所以我就只能去找我能看见的,哪些是我能看见的呢?在 read view 之前就提交了的,和我自己这两种事务,是我可见的。
读已提交的原理和上面差不多,只不过,read view 的时机不一样,可重复读贯穿整个事务,所以他的 read view 从第一次查询开始一直到事务结束不会改变,而 读已提交 是语句结束之后就会重新计算 read view
还有MySQL解决幻读的问题,是因为**RC 只加记录锁,RR 除了加记录锁,还会加间隙锁(就是范围锁),还有个原因是因为快照读,只要你一直认准那个事务 id,就不会幻读 **
然后前面还说了 MySQL 既解决了幻读,也没解决幻读,原因是:在一个事务中,你只认之前提交过的事务,但是你自己这个事务,你也得认啊,不可能你自己修改的数据,自己查不出来,但是这个查看自己事务属于当前读。发生当前读的时候,就可能会重现幻读!!看下图,初始 age=18。Q1 的结果是20,Q2的结果是18。原因:Q2是因为MySQL 的RR隔离级别,其他事务不会影响到它。那为什么Q1会被影响,因为它update了,要把自己锁起来,触发了当前读,当前读可以理解为他要去数据库里取出最新的数据。所以Q1上面那个 update 是在最新数据上加的1,加完之后,age=20 了,他还把自己改了之后那条数据订上自己的事务 id,所以在 Q1 的时候,他会发现 age=20 ,而且事务id 和自己要找的符合,就输出了20。
幻读是 insert 引起的,所以,把事务C的语句换成 insert 语句,就可以看到幻读效果了
LBCC 其实是讲的锁的算法的一个东西
每个事物都有个事物 id,在对一条数据进行修改的时候,会将原数据拷贝一份,修改他的事物id,这样我们找到事物id ,就知道这个数据是哪个事物修改的了
学习笔记用,总结在一起,便于自己记忆,侵权立删
参考:
解决死锁之路 - 学习事务与隔离级别
面试官:谈谈你对Mysql的MVCC的理解?
深入分析MySQL中事务以及MVCC的实现原理
MySQL数据库事务各隔离级别加锁情况–read committed && MVCC