【MySQL】——理解事务的隔离性、MVCC、当前读、快照读
目录
一、前言
二、读-写
1、事务ID
2、版本化数据
3、三个记录隐藏列字段
4、undo log
模拟MVCC
5、Read View
三、RR和RC之间的区别
RR 与 RC的本质区别
一、前言
上篇文章中,我们已经对事务的隔离性有了一定的理解,对事务隔离性的四个等级进行了演示。但是尽管我们已经知道这些隔离性的应用,但是我们仍对这些事物的隔离性有着很深的疑问,数据库是如何实现这些控制的。
首先,我们要知道数据库并发的场景有三种:
- 读-读 :不存在任何问题,也不需要并发控制
- 读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失(后面补充)
我们先来了解数据库是如何对读-写进行控制的
二、读-写
在MySQL中,多版本并发控制(MVCC)是一种用来解决 读写冲突的 无锁并发控制
在理解MVCC前我们需要先知道一些概念:
- 事务ID
- 版本化数据
- 三个记录隐藏列字段
- undo 日志
- Read View
1、事务ID
事务ID(Transaction ID)是一个唯一的标识符,用于区分和跟踪数据库中的各个事务。
-
唯一性:每个事务在其生命周期内都会被分配一个独一无二的事务ID。这个ID确保了在同一时间点上运行的所有事务都可以被明确地区分。
-
排序与版本控制:事务ID通常是一个单调递增的数值,这意味着较新的事务会有更大的事务ID。这有助于确定事务的时间顺序,对于实现MVCC机制非常重要,因为它允许数据库维护数据的不同版本,并根据事务开始时的快照提供一致的数据视图。
-
在并发环境下,不同的事务可能尝试同时修改相同的数据项。通过比较事务ID,数据库可以判断哪个事务先开始、哪个后开始,从而决定如何处理这些冲突(例如,是否需要回滚某个事务)。
-
如果发生系统故障,事务ID可以帮助识别哪些事务已经完成提交,哪些还在进行中或尚未提交。这对于执行崩溃恢复过程至关重要。
2、版本化数据
当某个事务对数据进行修改时(插入、更新或删除),数据库不会直接覆盖原有数据,而是创建该数据的一个新版本,并将这个新版本与执行修改操作的事务ID关联起来。旧的数据版本仍然保留一段时间,以便其他并发事务可以继续访问这些历史版本的数据。
3、三个记录隐藏列字段
- DB_TRX_ID :6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录或者最后一次修改该记录的事务ID
- DB_ROLL_PTR : 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就行,这些数据一般在 undo log 中)
- DB_ROW_ID : 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以DB_ROW_ID 产生一个聚簇索引
- 补充:实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了
假设我们创建的表结构是
但是实际上MySQL还会生成三列隐藏的字段如图
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
Tom | 25 | null | 1 | null |
由于我们目前并不知道创建该记录的事务ID,隐式主键,我们就默认设置成null,1。第一条记录也没有其他版本,我们设置回滚指针为null。
4、undo log
这里我们简单了解一下,我们知道MySQL将来是以服务进程的方式在内存中运行的,MySQL中的机制:索引、事务、隔离性、日志等,都是在内存中完成的,即在MySQL内部的相关缓冲区中,保存相关的数据,完成各种的操作判断,然后在合适的时候刷新到磁盘。
所以我们只需要将undo log理解成MySQL中的一段内存缓冲区,用来保存日志文件的。
模拟MVCC
有了上面的知识我们就可以简单模拟一下MVCC了
现在假设有一个事务10要对下面的表内容做修改,将 Tom 修改为 Jeo ,那么它会怎么做呢?
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
Tom | 25 | null | 1 | null |
- 因为事务10要对表内容做修改,所以先对该记录加行锁。
- 在修改前,先将该行记录做一份拷贝到 undo log中,所以undo log中就有着一行副本数据(原理:写时拷贝)
- 所以现在MySQL中有着两行相同的数据。现在修改原始记录中的name,修改完成后, 同时还需要修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务10 的ID, 我们默认从 10 开始,之后递增。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它
- 提交事务10,释放锁,此时最新的记录就是修改完的那一条记录
接下来假设又有一个事务11, 要对表中数据进行修改,将age改为50,所以类似上面的操作
- 事务11,因为也要修改,所以要先给该记录加行锁,该记录是指上次修改过的新的记录。
- 修改前,现将改行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的副本,我们采用头插方式,插入undo log。
- 现在修改原始记录中的age,改成 50。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它。
- 事务11提交,释放锁
这样,就形成了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据
上面的一个一个版本,我们可以称之为一个一个的快照。
接下来我们思考问题:
1、上面的操作主要是以更新(update)为主,那如果是删除(delete)操作呢?
事实上,删除不是清空数据,而是设置flag为删除即可,也可以形成版本。
2、那么insert呢?
因为insert是插入,也就是之前没有数据,那么insert也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了,所以它也可以形成版本。
3、那么select呢?
首先,select不会对数据做任何修改,所以,为select维护多版本,没有意义。不过,此时有个问题,就是:select读取,是读取最新的版本呢?还是读取历史版本?
- 当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in share mode(共享锁), select for update
- 快照读:读取历史版本(一般而言),就叫做快照读。
我们可以看到,在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有select过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。
但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即MVCC的意义所在。
那么问题来了, 经过上面的操作我们发现,事务从begin->CURD->commit,是有一个阶段的。也就是事务有执行前,执行中,执行后的阶段。但,不管怎么启动多个事务,总是有先有后的。那么多个事务在执行中,CURD操作是会交织在一起的。那么,为了保证事务的“有先有后”,是不是应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。
先来的事务,应不应该看到后来的事务所做的修改呢?如何保证不同的事务看到不同的內容呢?即如何实现隔离级别呢?
那就要提到下一个知识点了:Read View
5、Read View
Read View就是事务进行 快照读 操作的时候生产的 读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID
在 MySQL 源码中,Read View就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
//在InnoDB源码中(如trx0trx.h和trx0read.h文件),read view 的核心结构体定义如下:
struct ReadView {
trx_id_t low_limit_id; // 低水位线,小于该ID的事务都已提交,可以被当前事务看到
trx_id_t up_limit_id; // 高水位线,大于等于该ID的事务未提交,不能被当前事务看到
trx_id_t *m_ids; // 活跃事务ID数组(当前未提交事务的ID集合)
ulint m_n_ids; // 活跃事务ID的数量
trx_id_t creator_trx_id; // 创建该read view的事务ID
};
- m_ids:一张列表,用来维护Read View生成时刻,系统正在活跃的事务ID。
- up_limit_id:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
- low_limit_id:记录的是m_ids列表中事务ID最小的ID
- creator_trx_id:创建该ReadView的事务ID
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的DB_TRX_ID 。那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的DB_TRX_ID 。
1、首先新开始的事务不会立刻生成快照,而是根据以下条件生成:
隔离级别 | 形成快照的时机 |
---|
READ COMMITTED | 每次执行select语句时,都会创建一个新的 read view(生成快照)。 |
REPEATABLE READ | 第一次执行select语句时生成 read view,整个事务期间保持不变(生成一次快照)。 |
SERIALIZABLE | 不使用 MVCC,而是通过加锁机制实现一致性读取,不形成快照。 |
READ UNCOMMITTED | 不使用快照,直接读取最新数据(可能产生脏读)。 |
2、 当事务要形成快照时,会经历以下步骤:
-
系统遍历当前所有事务,将未提交事务的ID(活跃事务)放入 m_ids数组。
-
设置 low_limit_id 为当前最早启动的未提交事务的ID。
-
设置 up_limit_id 为下一个将要分配的事务 ID。
-
记录创建 Read View的事务 ID (creator_trx_id)
3、形成快照之后,接着进行事务可见性判断
我们知道事务ID通常是一个单调递增的数值,这意味着较新的事务会有更大的事务ID。基于这个,在生成快照之后,下面的提交都以快照生成时刻为基准
- 对于事务ID小于low_limit_id的事务,说明该事务开启比当前活跃事务中最早开启的事务还早,但是却不是活跃事务,说明早已经提交,所以可见。
- 对于事务ID大于等于up_limit_id的事务,说明这些事务是在快照之后开启的,还未提交,所以不可见
- 对于在m_ids中的事务,表示该事务是活跃事务,还未提交,所以可见
- 而不在m_ids中的事务,表示已经提交,可见。
这里需要注意的是,尽管事务ID的创建有早有晚,可以通过ID辨别,但是其提交并不一定,对于小事务可能就提交早,大事务就晚,所以对于不在m_ids中的事务,尽管这些事务是与当前事务一起开启的,但是它们在快照生成前就已经提交,所以可见。
所以快照,顾名思义就是相当于一张照片,将当时的时刻记录了下来,然后根据这个照片判断哪些事务可见,哪些事务不可见,代码如下
//在 InnoDB 源码(如 trx0read.cc 文件)中,
//判断事务可见性的核心函数是 trx_is_view_visible()。
//它接收一个 read view 和一个事务 ID (trx_id)
//作为参数,返回布尔值表示是否可见。
bool trx_is_view_visible(
const ReadView* view, // 当前事务的 read view
trx_id_t trx_id // 要判断的事务 ID
) {
// 1. 判断 trx_id 是否早于 low_limit_id
if (trx_id < view->low_limit_id) {
return true; // 已提交事务,可见
}
// 2. 判断 trx_id 是否大于等于 up_limit_id
if (trx_id >= view->up_limit_id) {
return false; // 未提交事务,不可见
}
// 3. 判断 trx_id 是否在活跃事务列表 m_ids 中
for (ulint i = 0; i < view->m_n_ids; i++) {
if (trx_id == view->m_ids[i]) {
return false; // 事务仍活跃,不可见
}
}
// 4. 未在 m_ids 中,表示已提交,可见
return true;
}
三、RR和RC之间的区别
前边我们提到过对于不同的隔离级别,形成快照的时机是不一样的。
1、当前读和快照读在RR隔离性下的区别
我们先知道一个语句 :select * from user lock in share mode,以加共享锁方式进行读取,对应的就是当前读,也成为读锁。
1.1 例1
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 启动事务 | 启动事务 | begin |
select * from account | 快照读,查到 name=Oven | 快照读查询 | select * from account |
update account set name=lisi where id=1; | 更新 name=Oven | - | - |
commit | 提交事务 | - | - |
- | - |
| select * from account |
- | - | select lock in share mode 当前读,读到 name=lisi | select * from account lock in share mode |
1.2 例2
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 启动事务 | 启动事务 | begin |
select * from account | 快照读,查到 name=lisi | - | - |
update account set name=Oven where id=1; | 更新 name=Oven | - | - |
commit | 提交事务 | - | - |
- | - | select 快照读 name=Oven | select * from account |
- | - | select lock in share mode 当前读 name=Oven | select * from account lock in share mode |
用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改name前 快照读 过一次age数据,而 表2 的事务B在事务A修改name前没有进行过快照读。
结论:
事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力
delete同样如此
RR 与 RC的本质区别
- 正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来
- 此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
- 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
- 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
- 总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
- 正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。