「前言」文章内容大致是MySQL事务管理,续上一篇。
「归属专栏」MySQL
「主页链接」个人主页
「笔者」枫叶先生(fy)
目录
- 七、再次理解隔离性
- 7.1 数据库并发的场景有
- 7.2 多版本并发控制(MVCC)
- 7.3 三个隐藏字段列
- 7.4 undo日志
- 7.5 模拟MVCC
- 7.6 Read View
- 7.7 Read View理论验证
- 八、RR与RC的本质区别
七、再次理解隔离性
7.1 数据库并发的场景有
数据库并发的场景有以下三种:
- 读-读 :不存在任何问题,也不需要并发控制
- 读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
其中读-写并发是数据库当中最高频的场景,下面讨论的就是这个,读-读并发不存在任何问题,写-写并发不谈
7.2 多版本并发控制(MVCC)
多版本并发控制(Multi-Version Concurrency Control
)是一种用来解决读-写冲突
的无锁并发控制,主要依赖记录中的3个隐藏字段列、undo
日志和Read View
实现
MVCC
为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。 所以MVCC
可以为数据库解决以下问题:
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数 据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
下面先介绍3个隐藏字段列、undo
日志和Read View
7.3 三个隐藏字段列
数据库表中的每条记录都会有如下3个隐藏字段列:
DB_TRX_ID
:6byte,最近修改(修改/插入)事务ID,记录创建这条记录/最后一次修改该记录的事 务IDDB_ROLL_PTR
:7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就 行,这些数据一般在undo log
中)DB_ROW_ID
:6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB
会自动以
DB_ROW_ID
产生一个聚簇索引
补充:
- 实际还有一个删除
flag
隐藏字段列,用于记录该条数据是否被删除,便于进行数据回滚(删除数据并不是真的删除了,只是修改了flag
字段)
例如,有一个测试表
create table if not exists student(
name varchar(11) not null,
age int not null
);
插入一条数据
insert into student (name, age) values ('张三', 28);
查询该表的数据
查出来的记录不仅包含name和age字段,还包含三个隐藏字段
说明:
- 假设插入该记录的事务的事务ID为1,那么该记录的
DB_TRX_ID
字段填的就是1,没有就为null
- 这是表中的第一条记录,所以隐式主键
DB_ROW_ID
字段填的就是1 - 记录是新插入的,没有历史版本,所以回滚指针
DB_ROLL_PTR
的值设置为null
7.4 undo日志
undo log
是MySQL数据库中的一种日志,用于记录事务的回滚信息- 在MySQL中,事务的回滚是通过
undo log
来实现的
undo log
简单理解成就是 MySQL 中的一段内存缓冲区,用来保存日志数据的,必要时会将缓冲区中的数据刷新到磁盘
7.5 模拟MVCC
事务ID为10的事务
假设现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名“张三”改为“李四”:
-
因为是要进行写操作,所以需要先给该记录加行锁
-
修改前,现将改行记录拷贝到
undo log
中,所以,undo log
中就有了一行副本数据(原理就是写时拷贝) -
所以现在MySQL中有两行同样的记录
-
现在修改原始记录中的name,改成 ‘李四’,并且修改原始记录的隐藏字段
DB_TRX_ID
为当前 事务10 的ID,我们默认从10开始,之后递增 -
而原始记录的回滚指针
DB_ROLL_PTR
列,里面写入undo log
中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它 -
最后当事务10提交后释放锁,这时最新的记录就是学生姓名为“李四”的那条记录
现在又有一个事务11
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)
- 因为是要进行写操作,所以需要先给该记录(最新的记录)加行锁
- 修改前,先将该行记录拷贝到
undo log
中,此时undo log
中就又有了一行副本数据 - 然后再将原始记录中的学生年龄改为38,并将该记录的
DB_TRX_ID
改为11,回滚指针DB_ROLL_PTR
设置成刚才拷贝到undo log
中的副本数据的地址,从而指向该记录的上一个版本 - 最后当事务11提交后释放锁,这时最新的记录就是学生年龄为38的那条记录
- 这样,我们就有了一个基于链表记录的历史版本链
- 所谓的回滚,无非就是用历史数据,覆盖当前数据
上面的一个一个版本,我们可以称之为一个一个的快照
insert和delete的记录如何维护版本链
上面已经谈了update,update可以形成版本链,那insert和delete呢?
- 对于delete,删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入
undo log
中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了 - 对于insert,新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了
update
和delete
和insert
可以形成版本链
select
不会对数据做任何修改,所以,为select
维护多版本,没有意义
select读取,是读取最新的版本呢?还是读取历史版本?
先说两个概念,当前读 VS 快照读:
- 当前读:读取最新的记录,就叫做当前读
- 快照读:读取历史版本,就叫做快照读
事务在进行增删查改的时候,并不是都需要进行加锁保护:
- 事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护
- 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
undo log中的版本链何时才会被清除?
- 提交事务:当一个事务成功提交后,数据库系统会认为该事务的操作是永久性的,不再需要回滚。因此,与该事务相关的
undo log
版本链就会被清除 - 还有其他情况就不补充了
如何保证,不同的事务,看到不同的内容呢?也就是如何实现隔离级别?
- 下面Read View再谈
- Read View本质是用来进行可见性判断的
- 解决在读提交和可重复读隔离级别下,应当是当前读还是快照读的问题(select语句查询)
7.6 Read View
Read View
就是事务进行快照读
操作的时候生产的读视图 (Read View
),(即使用select查看数据的时候才会产生读视图)- 在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID是递增的,所以最新的事务,ID值越大)
- Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据
ReadView类的源码如下:
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
m_up_limit_id; //记录m_ids列表中事务ID最小的ID
m_low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
m_creator_trx_id //创建该ReadView的事务ID
由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id
和m_low_limit_id
,可以将事务ID分为三个部分:
- 事务ID小于
m_up_limit_id
的事务 - 事务ID位于
m_up_limit_id
和m_low_limit_id
之间的事务 - 事务ID大于等于
m_low_limit_id
的事务
注:ReadView就是一个对象,初始化一次之后就不再改变了(只初始化一次)
- 事务ID小于
m_up_limit_id
的事务:一定是生成Read View
时已经提交的事务,因为m_up_limit_id
是生成Read View
时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了 - 事务ID位于
m_up_limit_id
和m_low_limit_id
之间的事务:该区间的事务,在生成Read View
时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids
中来判断该事务是否已经提交(所有活跃的事务都在m_ids
中) - 事务ID大于等于
m_low_limit_id
的事务:一定是生成Read View
时还没有启动的事务,因为m_low_limit_id
是生成Read View
时刻,系统尚未分配的下一个事务ID
注意:事务ID不一定是连续的,比如事务ID10,15,16,17…
上述对应的隐藏字段:
- 第一个区间,如果
m_creator_trx_id
(创建该ReadView
的事务ID)==DB_TRX_ID
或者DB_TRX_ID
<m_up_limit_id
,则说明该事务是历史已经提交了的(已commit),应该被当前事务看到 - 第二个区间,如果
DB_TRX_ID
不在m_ids
列表中,说明该事务已经提交了(已commit),应该被当前事务看到。如果在的话m_ids
列表中,说明该事务与当前事务都处于活跃状态(没有commit),不应该被当前事务看到 - 第三个区间,
DB_TRX_ID
>=m_low_limit_id
,说明该事务是快照之后才提交的事务,不应该被当前事务看到
对应的源码策略如下:
bool changes_visible(trx_id_t id, const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见
if (id >= m_low_limit_id) {
return(false);
}
//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见
else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见
return (!std::binary_search(p, p + m_ids.size(), id));
}
注意:Read View是一个可见性的一个类,并不是事务创建出来就有Read View,而是当这个事务(已经存在)进行快照读的时候,MySQL才会形成Read View(进行select的时候,会自动形成)
7.7 Read View理论验证
下面进行 Read View的理论验证,即 Read View的整体流程
假设当前有条记录
有四个事务并发进行对该记录进行操作,事务4先进行修改,事务4进行修改完成之后,事务2进行快照读
- 事务4:修改name(张三) 变成name(李四)
- 当
事务2
对某行数据执行了快照读
,数据库为该行数据生成一个 Read View 读视图
//事务2的 Read View
m_ids; // 1,3
m_up_limit_id; // 1
m_low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
m_creator_trx_id // 2
此时版本链是:
只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务
事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID
去跟m_up_limit_id
,m_low_limit_id
和活跃事务ID列表(m_ids
) 进行比较,判断当前事务2能看到该记录的版本
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
//事务4提交的记录对应的事务ID
DB_TRX_ID=4
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中
//结论
故,事务4的更改,应该看到
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
八、RR与RC的本质区别
RR是可重复读的缩写,RC是读提交的缩写
我们之前的查询语句都是快照读,如果想进行当前读,执行下列语句:
-- 以加共享锁方式进行读取,对应的就是当前读
select * from 表名字 lock in share mode; -- 当前读
实验1
启动两个终端,将隔离级别都设置为可重复读,并查看此时表中的数据
两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息
左终端中的事务对表中的信息进行修改并提交,右终端中的事务看不到修改后的数据
左边终端提交事务;右边终端进行当前读,可以看到最新的数据
select * from account lock in share mode;
实验2(进行同样的操作,只是SQL语句执行顺序不同)
- 启动两个终端,将隔离级别都设置为可重复读,并查看此时表中的数据
- 两个终端各自启动一个事务,在左终端中的事务操作之前,右边的终端不查看表中数据
左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据
右边终端进行当前读,可以看到刚才读取到的确实是最新的数据
实验对比
上面两次实验的唯一区别在于,右终端中的事务在左终端中的事务修改数据之前是否进行过快照读
实验一的操作流程:
实验2的操作流程:
事务B在事务A修改age前没有进行过快照读
结论
- RR级别下要求事务内每次读取到的结果必须是相同的,因此事务首次进行快照读的地方,决定了该事务后续快照读结果的能力
RR与RC的本质区别
正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
- 此后在调用快照读的时候,还是使用的是同一个
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
才会有不可重复读问题。
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.9.10
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。