初识MySQL · 事务 · 下
目录
前言:
隔离性级别
READ UNCOMMITTED(读未提交)
READ COMMITTED(读已提交)
REPEATABLE READ(可重复读)
SERIALIZABLE(可串行化)
理解隔离性
前置知识
3个记录隐藏字段
undo log
read view
前言:
前文我们介绍了MySQL的事务的四大特点,分别是原子性,隔离性,一致性,持久性,其中以上四个特点统称为ACID,不过其实我们学完了事务之后,我们知道所谓的ACID最后不过是通过AID保证C的。
前文我们介绍了事务的基本操作,可以通过start transaction或者begin开启事务,使用commit提交事务,也见识了如果不commit就停止服务的话,事务就会自动回滚,默认回滚到起点,我们也可以使用savepoint来回滚。
并且较为重要的一点是单条SQL语句也是事务,如果我们关闭了autocommit,客户端退出之后,就会导致查询不到结果了。
那么以上是前文的一个简单介绍。那么对于本文来说,着重要理解的是事务的隔离性,事务之间如何隔离的,是怎么做到隔离的,最典型的例子就是我们上次特意将事务设置为了读未提交,在执行的时候对方还可以看到对应的更改,我们通过事务的隔离性,引入事务的剩余部分。
隔离性级别
READ UNCOMMITTED(读未提交)
描述:
READ UNCOMMITTED 是事务隔离级别中最宽松的一种。在这种隔离级别下,一个事务可以读取到其他事务尚未提交的数据。这意味着即使另一个事务还未完成(可能后续会回滚),当前事务依然可以访问其修改后的内容。
可能存在的问题:
这种隔离级别存在脏读(Dirty Read)问题。脏读指的是一个事务读取了另一个尚未提交事务写入的数据。如果对方事务最终回滚了,那么当前事务所读取的数据就成了无效或伪造的。这会导致数据不一致,极度影响系统的可靠性。
总结:
由于其数据一致性差,READ UNCOMMITTED 在实际项目中几乎不会使用,除非业务允许极高的并发并能容忍极短时间内的数据不一致。
READ COMMITTED(读已提交)
描述:
READ COMMITTED 要求一个事务只能读取到其他事务已经提交的数据。也就是说,若某事务对某条数据进行了修改,但尚未提交,那么其他事务在查询这条数据时无法看到这个修改,直到该事务提交为止。
可能存在的问题:
虽然这种隔离级别避免了脏读,但它可能导致不可重复读Non-repeatable Read问题。不可重复读是指在同一个事务中两次读取同一条记录,却得到了不同的结果。这通常发生在另一个事务在两次读取之间对该记录做了提交更新。对于依赖多次读取得到一致结果的逻辑(如对比前后数据、判断条件等),可能造成错误判断。
总结:
READ COMMITTED 是许多数据库(如 Oracle)的默认隔离级别。它避免了脏读,并且在性能和一致性之间取得了一个相对平衡,因此在对一致性要求适中但追求并发的业务中非常常见。
REPEATABLE READ(可重复读)
描述:
REPEATABLE READ 是 MySQL(InnoDB 引擎)的默认隔离级别。在该隔离级别下,一个事务在开始后,无论进行多少次相同的查询操作,读取到的都是事务开始时的一致性快照数据,即便其他事务已经提交了修改。也就是说,事务内部对同一条数据的读取结果是一致的。
可能存在的问题:
REPEATABLE READ 有效解决了不可重复读的问题,但仍然可能发生幻读(Phantom Read)。幻读是指在一次事务中先读取到了一批数据,但在事务还未结束时,再次读取相同范围的数据却发现多出了记录,通常发生在 INSERT
或 DELETE
操作上。例如事务第一次查询“所有工资大于 10000 的员工”,结果是 3 条记录,但在事务过程中另一个事务插入了一条工资为 15000 的新员工,下一次查询就会得到 4 条记录。
不过值得注意的是,InnoDB 通过间隙锁Gap Lock机制,在多数情况下也能防止幻读的发生,从而提供了接近 SERIALIZABLE 的一致性体验。
总结:
REPEATABLE READ 是一个在性能和一致性之间更进一步的折中选项。它提供了比 READ COMMITTED 更强的隔离能力,适合大多数中高一致性要求的业务系统。
SERIALIZABLE(可串行化)
描述:
SERIALIZABLE 是最严格的事务隔离级别,它不仅要求所有读取的数据都来自已提交的事务,还会对所有读取的行加锁(包括范围查询),强制事务串行执行。所有读写操作都会互相阻塞,仿佛每个事务是排队一个一个执行的。
可能存在的问题:
虽然 SERIALIZABLE 可以完全避免脏读、不可重复读和幻读,保证数据绝对一致,但代价是性能非常低。由于事务间高度阻塞,会带来大量锁等待和死锁风险,从而严重影响系统吞吐量和并发性能。
总结:
SERIALIZABLE 主要适用于对数据一致性要求极高的关键场景,如银行转账、证券交易等。但在日常业务中通常不会采用,除非你确实无法容忍任何数据不一致,并且能承受低并发性能的代价。
隔离级别 | 脏读(Dirty Read) | 不可重复读(Non-Repeatable Read) | 幻读(Phantom Read) | 说明 |
---|---|---|---|---|
READ UNCOMMITTED | ✅ 可能发生 | ✅ 可能发生 | ✅ 可能发生 | 最低级别,能读到其他事务未提交的数据 |
READ COMMITTED | ❌ 防止 | ✅ 可能发生 | ✅ 可能发生 | 只能读到已提交数据,但可能两次查询结果不同 |
REPEATABLE READ | ❌ 防止 | ❌ 防止 | ❌ InnoDB 中也防止 | MySQL 默认级别,使用 MVCC 和间隙锁避免幻读 |
SERIALIZABLE | ❌ 防止 | ❌ 防止 | ❌ 防止 | 最严格,事务串行执行,开销最大,性能最低 |
理解隔离性
首先,对于数据库并发的场景分为读读,读写,写写,其中对于读读来说的话,无须进行任何操作,因为不涉及对数据的修改,对于写写来说也简单,对修改的数据进行加锁即可,但是对于读写就不一样了,我们如何保证读取到的数据是修改之前还是修改之后的,更准确来说就是如何解决脏读幻读不可重复读的问题。
那么在MySQL中,解决方式是使用MVCC(多版本并发控制),这是一种无锁并发控制,其实提到了多版本,我们似乎也能联想git中的版本控制?通过多个hash值,将指针指向hash值从而有效的进行版本控制。
那么其实MySQL中也有异曲同工之妙,对于理解隔离性来说我们主要要理解的还是RC和RR,即读提交和可重复读这两种隔离性,对于RU来说,相当于没有隔离性,所以没有探讨的必要,对于serializable来说,只要不是查询,但凡是对数据进行了修改的,那么就要加锁,等其他事务执行完了自己才执行。
但是RC和RR就不一样了,既然我们查询都是同一份数据,怎么会它更新它的,我修改我的呢?带着这种问题,我们来理解MVCC模式,不过在理解之前,我们需要学习三个前置知识。
前置知识
3个记录隐藏字段
DB_TRX_ID,大小为6字节,表示的是最近修改/插入事务的ID,记录创建这条记录/最后一次修改该记录的事务ID,该ID越小,表示该事务来的越早。
DB_ROLL_PTR,大小为7字节,回滚指针,指向这条记录的上一个版本,这些数据一般在undo log中。
DB_ROW_ID,大小为6字节,如果表中没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引,不过这个ID通常是隐藏的。
实际上还有一个删除flag的隐藏字段,有的时候数据被删除不是真的被删除了,不过是把flag设置为了某个特定的值,这样可以通过减少和磁盘的IO而提高效率。
所以如果我们查询出了这个结果:
实际上的结果是:
undo log
对于undo log来说,我们简单的记住,undo log代表的是MySQL内部的缓冲区,保存相关数据,完成各种操作之后将相关数据刷新到磁盘中的。
我们清楚,对于事务来说,就像是TCP连接一样,需要被管理起来,那么对于一个要管理的对象来说,我们指定是要先描述在组织,所以在MySQL中有自己的代码,用来描述事务这个结构体,那么既然了回滚指针这个概念,指向的上一条记录的地址,所以在undo log这个缓冲区中,多条记录的组织方式是:
这个不就是链表的形式进行管理的吗?当然这是一个非常粗略的描述。
那么也就是说,对于一条记录而言,在undo log中可能会同时组织多条,每条中的字段可能都有所差异,对于上面一条条的记录,我们就称之为快照。
那么如何形成上面的版本就应该是一个问题,上面的数据是已经存在了的,所以update会有快照生成,如果是delete的话,更改flag也能形成快照,但是如果是insert的话,本身就没有数据存在,所以insert我们就不考虑形成版本链了。
那么select呢?对于查询来说,查询的是历史的还是当前的呢?如果查询的是当前的,那么称为当前读,如果查询的是历史的,那么称为快照读。
对于快照读来说,就不需要加锁,因为历史数据是不允许被修改的,但是对于当前读来说就不一样了,当前读来说它是需要保证当前读取的数据不能被修改的,所以需要加锁。
那么如何保证当前读还是历史读呢?我们这个时候就需要使用read view了。
read view
Read View就是事务进行 快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一 刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
Read View在 MySQL 源码中,就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View读视图,把它比作条件,用来判断当前事务能够看到哪个版本的 数据,既可能是当前最新的数据,也有可能是该行记录的 undo log里面的某个版本的数据。
这里其实已经将RR和RC的区别暗示出来了~
class ReadView {// 省略...private:/** 高水位,大于等于这个ID的事务均不可见*/trx_id_t m_low_limit_id
/** 低水位:小于这个ID的事务均可见 */trx_id_t m_up_limit_id;/** 创建该 Read View 的事务ID*/trx_id_t m_creator_trx_id;/** 创建视图时的活跃事务id列表*/ids_t m_ids;/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/trx_id_t m_low_limit_no;/** 标记视图是否被关闭*/bool m_closed;// 省略...};
那么为了方便理解,我们只需要理解其中的四个字段:
m_ids;//一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id;//记录m_ids列表中事务ID最小的ID(没有写错)
low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的
最大值+1(也没有写错)
creator_trx_id;//创建该ReadView的事务ID
其中通过up_limit_id和low_limit_id构成一个事务ID的区间。
根据不同的事务ID,begin但是没有commit的事务的ID将在中间的区间,而已经commit的事务则是左边的,后面的事务就是之后的了,那么RR和RC的主要区别就是快照形成的时机。
而假如存在四个事务如下:
在事务2执行快照读之前,事务4已经提交完毕,那么此时,m_ids集合中就有1,3,代表的就是活跃的事务是1,3,那么1,3作出的修改就是事务2看不到的,但是事务4不一样,在事务2执行快照读的时候,因为up_limit_id是小于id4的,low_limit_id是大于,也就是说事务4按道理来说是在这个区间的,但是它不在活跃的事务列表中,所以在事务2看来事务4是已经提交了的,那么,已经提交了的事务,在2看来就能看到对应的修改了。
那么,我们接下来通过两个实验,来见识快照读的魅力。
实验一:
实验二:
实验二就是取消实验一中事务B的快照查询操作。
这二者的区别在于,有没有形成快照,select之后,如果不加处理就是快照读,那么就会生成快照视图,生成快照视图之后,那么开始判断,发现另一个事务没有commit,也就是它是活跃事务,所以它的任何修改另一个事务也看不到,即便commit之后,因为快照也没有更新,所以仍然当作对方是活跃事务,也就看不到了。
而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下 的事务中可以看到别的事务提交的更新的原因。
总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是 同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。 正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。
感谢阅读!