文章目录
- 多版本并发控制(MVCC)
- 如何解决读-写并发
- undo 日志
- 模拟MVCC过程
- select读取版本
- 隔离性的实现
- 为什么要有隔离级别
- 快照(read view)
- 可重复读(RR)与读提交(RC)的本质区别
多版本并发控制(MVCC)
多版本并发控制(MVCC)是一种用来解决读写冲突的无锁并发控制,
数据库并发的场景有三种:
- 读-读:一方在读,另一方也在读,因为不对数据作修改,就不存在任何问题,也就不需要并发控制,
- 读-写:一方在读,另一方在写,有线程安全问题,可能会造成事务隔离问题,可能遇到脏读,不可重复度,幻读问题,
- 写-写:一方在写,另一方在写,有线程安全问题,可能会存在数据更新丢失问题,
如何解决读-写并发
1)结论:数据库在启动一个事务的时候,每一个事务都有一个id, 每一个sql都封装成事务,每一条sql都有id
Mysql会为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照, (类似于写时拷贝,读原来的表,写入到新表里面去): 所以 MVCC 可以为数据库解决以下问题 :
1)在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高数据库并发读写的性能
2)可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
MySQL每个表中与MVCC有关的三个隐藏字段:
DB_TRX_ID
: 6 字节,表示最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事务ID,
DB_ROLL_PTR
: 7字节,表示回滚指针,指向这条记录的上一个版本(简单理解成指向历史版本,这些历史版本数据一般在 undo log 中)
DB_ROW_ID
: 6 字节,表示隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引,
补充:实际还有一个删除flag
隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了(比如对应的比特位1->0表示删除了) 为1表示记录有效为0无效
例子:比如下面的表
mysql> create table student( name varchar(11) not null, age int not null );
mysql> insert into student (name, age) values ('张三', 28);
mysql> select * from student;
+--------+-----+
| name | age |
+--------+-----+
| 张三 | 28 |
+--------+-----+
我们目前并不知道创建该记录的事务ID,隐式主键,我们就默认设置成null,1,第一条记录也没有其他版本,我们设置回滚指针为null
name | age | DB_TRX_ID(创建该记录的事务 ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指 针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
undo 日志
MySQL以服务进程的方式,在内存之中运行,MySQL的各种机制(索引、事务、隔离、日志等等)都在内存之中完成
- 即在MySQL内部有相对应的缓冲区保存相关的数据,完成各种操作,然后在合适的时候将数据刷新至磁盘之中
简单理解就是,undo log
就是MySQL中的一段缓冲区,用来保存日志的数据, 针对每一条被修改的记录,都会改前先备份一遍放在 undo log 中,依靠字段回滚指针一条一条链接起来
日志中保存的内容通常有两种:
- 历史数据: 回滚则重新指向,
- 命令的反向的操作:比如insert操作会记录delete,update会记录update之前的值,
模拟MVCC过程
1)当前事务想要修改记录时,会先给该记录加锁;
2)并将改前记录拷贝一份放到 undo log 中保存起来;
3)改完后解锁,并将记录中的回滚指针字段指向 undo log 中的上一条记录,改完之后的记录就成了最新纪录,而 undo log 中的就是历史记录, 同时事务ID是递增的
4)提交事务,释放锁
这样,我们就有了一个基于链表记录的历史版本链,所谓的回滚,无非就是用历史数据,覆盖当前数据, 上面的一个一个版本,我们可以称之为一个一个的快照
上面是以更新(upadte
)为主的版本控制,如果是其他操作:
- delete并不是直接删除数据,而是更改flag,(由1变0)因此是可以形成历史版本的,有版本链,update也有版本链
- insert是新增数据,所以是没有历史版本的,但是为了回滚,一般也会将insert的数据放入undo log之中,如果当前事务commit,那么undo log之中的insert数据就会被清空
- select不会对数据进行修改,因此没有维护多版本的意义
删改的永远都是最新纪录,而查询的可能是历史记录,对于插入操作,自身提交后,就可以清空undo log中的插入历史纪录了,因为新增数据其他并发事务是看不见的,不需要维护隔离性,但对于更新和删除操作,如果提交后清空undo log,其他并发事务还可能正在读取历史,只能等并发事务全部提交后才能清空
undo log 里面的内容什么时候会被清除
当前最新的记录没有人修改而且被提交了,且 mysql所有事物当前没有人再和undo log里面的数据有关了
select读取版本
问:select读取的数据是最新版本的还是历史版本
首先提出一个概念: 当前读:表示读取最新的记录 快照读:表示读取历史版本
- 所有事物删改查的时候都叫当前读,因为访问的是最新的数据,需要加锁 读取最新记录都可以称为当前读
- 如果要进行select也要读取最新版本一定是需要加锁的
- 读取undo log中的历史版本,就称为快照读
如果是快照读,读取的是历史版本,是不受锁的限制的,因为是历史数据,历史版本不会被修改,被修改的永远是最新版本,只要读取历史版本就不需要加锁, 也就是可以并发执行,提高程序的效率,这就是MVCC的意义所在
回答: select有可能是当前读,也有可能是快照读,select读取历史的哪一个版本,由隔离级别决定
隔离性的实现
-
隔离性的实现: 有些事务读取的是旧版本.有些读取是新版本,所以不冲突
-
隔离级别本质:根据级别不同,让你看到历史的哪一个版本 隔离性是通过历史版本实现的
在可重复读隔离级别下,一个事务看不到其他并发事务已经提交的更改,是因为并发事务删改的是最新纪录,而当前事务只能查询到历史版本,这就是隔离性的实现,那具体看到是哪一个历史版本,就是隔离级别不同的原因
注意:隔离级别中的串行化,也就是并发事务增删改查操作都要加锁,都是当前读,不需要历史版本,而对于隔离级别中的可重复读,并发事务进行查询操作时都是读取历史版本的,就不需要加锁限制了,可以并行执行,提高了效率这就是MVCC的意义所在
事务是具有原子性的,事务能够看到的内容、哪些事务所做的修改、整个数据库数据的情况,可以认为在事务分配事务ID时就确定了,在此之后数据库发生的变化,该事务是看不见的,这就是隔离性的实现目标
为什么要有隔离级别
因为事务都是原子的,当它启动的瞬间,它的事务id就被确定好了,也就意味着它所认为的内部的mysql的数据状态也要确定下来, 它所看到的数据也必须是明确的
事务从begin开启事务->CURD->commit提交事务,是有一个阶段的,也就是事务有执行前,执行中,执行后的阶段,但是不管怎么启动多个事务,总是有先有后的, 那么多个事务在执行中,CURD操作是会交织在一起的
为了保证事务的“有先有后”,应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题
快照(read view)
Read View就是事务进行快照读操作的时候生产的读视图 ,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID
- 当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大
不同的事物读取不同的版本,这是需要判断的,判断条件就是根据事物ID,由于启动的时候会分配事务ID,ID是递增的,所以可以根据事务ID来判断,应该读取哪个历史版本
白话:当你的事务到来的时候,形成事务ID,你所能看到mysql内部的各种数据,就要被确定下来,当我们的事务到来的时候,我们能看到mysql的各种数据,类比成一张照片
总结:Read View是决定一个事务可见性的数据结构, 记录一个事务可以看到哪些历史版本的数据
Read View 在 MySQL 源码中,就是一个类,本质是用来进行可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log
里面的某个版本的数据
class ReadView
{
//....
private:
m_ids; //一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务最小的ID
low_limit_id;//ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前m_ids列表中事务ID的最大值+1
creator_trx_id //创建该ReadView的事务ID,也就是本事务的事务ID,
//....
};
如何判断undo log中的历史版本记录是否能够被本事务读取看到呢
1)如果历史版本的记录中的隐藏字段DB_TRX_ID
大于等于高水位m_low_limmit_id
,说明该记录对本事务不可见
2)如果DB_TRX_ID
小于低水位m_up_limmit_id
或等于当前事务IDm_create_trx_id
,说明该记录对本事务可见
3)如果DB_TRX_ID
在高低水位之间且在m_ids
中,说明该事务在创建快照时未提交,如果不在m_ids
中,说明该事务在创建快照时已经提交,如果已提交当前事务就能看到其所做的修改,反之则不能
可见性判断法则:只要事务早就被提交,或者事务不在快照列表里面,就应该看到 否则不应该看到!
准确的来说,事务能够看到的历史记录,在快照创建的一瞬间就确定好了,快照创建的时间就是事务首次进行select查询操作的时间,快照一旦建立好就不在变化,此时该事务所能看到的数据也就不会发生变化了
bool changes_visible(trx_id_t id, const table_name_t& name) const //能否看到
{
if (id < m_up_limmit_id || id == m_creator_trx_id) {
return true;
}
if (id >= m_low_limmit_id) {
return false;
} else if (m_ids.empty()) {
return true;
}
return !std::binary_search(m_ids.data(), m_ids.data() + m_ids.size(), id);
}
- (creator == DB_TRX_ID || DB_TRX_ID < up_limit_id):这个版本比up_limit_id 还小(事务ID是从小往大顺序生成的),说明这个版本在SELECT之前就已经提交了,所以这个数据是可见的 ; 或者这个版本的事务本身就是当前SELECT语句所在事务的话,也是一样可见的;
- (DB_TRX_ID >= low_limit_id):表示这个版本是由将来启动的事务来生成的,当前还未开始,那么是不可见的;
- (up_limit_id <= DB_TRX_ID < low_limit_id):这个时候就需要再判断两种情况:
- 如果这个版本的事务ID在ReadView的未提交事务数组中,表示这个版本是由还未提交的事务生成的,那么就是不可见的;
- 如果这个版本的事务ID不在ReadView的未提交事务数组中,表示这个版本是已经提交了的事务生成的,那么是可见的。
假设当前有条记录:
同时启动了4个事务,这四个事务是有先后关系的:
事务4对数据进行了修改:修改name(张三) 变成name(李四),此时版本链是:
当 事务2 对某行数据执行了 快照读 ,数据库为该行数据生成一个 Read View 读视图
#事务2的 Read View
m_ids; # 1,3
up_limit_id; # 1
low_limit_id; # 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id # 2
事务2在快照读该行记录的时候,就会拿版本链中的的DB_TRX_ID 去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list) 进行比较,判断当前事务2能看到该记录的版本,事务4提交的记录对应的事务ID DB_TRX_ID=4,
比较步骤:
- DB_TRX_ID(4)< up_limit_id(1)不小于,说明事务4并不是事务2之前已经提交的事务,不能看到,
- DB_TRX_ID(4)>= low_limit_id(5) 不大于等于,说明事务4并不是事务2创建视图之后的事务,
- m_ids.contains(DB_TRX_ID) 事务2的Read View不包含当前DB_TRX_ID,说明事务4不在当前的活跃事务中,
所以得出结论:事务4是和事务2是同时运行的事务,如果在读提交的隔离级别下,事务2能够看到事务4修改的数据;如果是在可重复读的隔离级别下,则看不到,只能够回滚看到“张三”那条数据,而事务4提交的版本也是全局角度上最新的版本
如果事务2在快照读之后,又来了一个事务5,将“李四”改成了“王五”并提交,那版本链就如下所示:
事务2在比较的时候DB_TRX_ID(5)>= low_limit_id(5),等式成立,说明事务5是事务2在形成快照之后来的事务,事务2无法看到这条数据,所以回滚,找“李四”那条数据
可重复读(RR)与读提交(RC)的本质区别
如果我们想要当前读怎么操作:
select * from user lock in share mode;#加共享锁方式进行读取,对应的就是当前读
在可重复读隔离级别下进行测试:
以下面的表为例
案例1:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from account | 快照读(无影响)查询 | 快照读查询 | select * from account |
update account set name=‘王五’ where id=1 | 更新 | - | - |
commit | 提交事务 | - | - |
- | - | 快照读 ,没有读到 name=‘王五’ | select * from account |
- | - | 当前读 , 读到 name=‘王五’ | select * from account lock in share mode |
案例2:
事务A操作 | 事务A描述 | 事务B操作 | 事务B描述 |
---|---|---|---|
begin | 开启事务 | begin | 开启事务 |
select * from account; | 快照读(无影响)查询 | - | - |
update account set name=‘王五’ where id=1; | 更新 | - | - |
commit | 提交事务 | - | - |
- | - | select * from account; | 快照读 ,读到 name=‘王五’ |
- | - | select * from account lock in share mode; | 当前读 , 读到 name=‘王五’ |
一个事务启动默认没有Read View, 当第一次select的时候,才会形成Read View,select的时候底层就是执行一次快照读,mysql要给事务形成Read View
用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改name前 快照读 过一次age数据,而 表2 的事务B在事务A修改name前没有进行过快照读
结论: 事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力, delete同样如此,
RC(读提交)与RR(可重复读)的本质区别是:二者Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同 (如果Read View 不变,快照读所能读到的东西也就不变,)
- 在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才会有不可重复读问题,
总结起来就是:
- RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,此后Read view不在发生变化,故以后再进行快照读都读的内容是固定的,
- RC级别下,事务每次快照读都会新生成一个快照和Read View,这就是在RC级别下的事务可以看到别的事务提交的更新的原因,