目录
[(1) 记录隐藏字段](#(1) 记录隐藏字段)
[(2) undo日志](#(2) undo日志)
[(3) 模拟MVCC](#(3) 模拟MVCC)
[(4) Read View](#(4) Read View)
数据库并发场景
数据库并发的场景有三种:
读-读:不存在任何问题,也不需要并发控制
读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失(后面补充)
MVCC机制
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制
每个事务都要有自己的事务ID,可以根据事务ID的大小,来决定事务到来的先后顺序
MySQL可能会面临处理多个事务的情况,每个事务都有有自己的生命周期,mysqld要对多个事务进行管理-->先描述,再组织。从实现角度看,事务在mysqld中表现为一个或多个结构体/类对象,构成事务的基础数据结构
为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。所以MVCC可以为数据库解决以下问题
- 在并发读写数据库时,可以做到在读操作时,不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
(1) 记录隐藏字段
- DB_TRX_ID:6byte,最近 修改/插入 事务ID,记录创建这条记录/最后一次修改该记录的事务ID。它保存着创建该记录或最后一次修改该记录的事务标识。
- DB_ROLL_PTR:7byte,作为回滚指针,指向这条记录的上一个版本(可以简单理解为,指向历史版本,这些数据一般在undo log 中)
- DB_ROW_ID:6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID自动创建一个聚簇索引(生成一个B+树)
- 补充:实际还有一个删除flag隐藏字段,记录的更新或删除操作并非物理删除,而是通过修改该删除标记的状态实现的即删除flag变了(恢复数据将标志为设为有效即可)
create table if not exists student( name varchar(11) not null, age int not null );创建一个初始表

上面查询结果的意思为

(2) undo日志
MySQL将以服务进程的形式在内存中运行。我们之前讨论的所有机制------包括索引、事务、隔离性、日志等------都是在内存中实现的。具体来说,MySQL会在其内部缓冲区中存储相关数据并执行各种判断操作,然后在适当的时机将这些数据刷新到磁盘。
undo日志就是 MySQL 中的一段内存缓冲区 ,用来保存日志数据的
当一个事务产生的Undo Log不再被任何其他活动事务需要时,它就可以被标记为可删除
(3) 模拟MVCC
假设插入数据的事务ID为9

现在有一个事务10,对student表中的记录进行修改(update):将name(张三) 更新为 name(李四)。
上面的数据就放入到undo log里面
回滚指针将指向该undo log记录的存储地址,下面假设为0xaa
事务10在执行修改操作前,需要先对该记录添加行锁。修改流程如下:
- 首先将该行记录拷贝至undo log中保存为副本数据(基于写时拷贝原理)
- 此时MySQL中存在两行相同的记录
- 修改原始记录的name字段为"李四"
- 更新原始记录的隐藏字段:
- 将DB_TRX_ID设置为当前事务10的ID(假设事务ID从10开始递增)
- 将DB_ROLL_PTR指向undo log中的副本记录地址,表示该副本是当前记录的上一版本
- 最后,事务10提交并释放行锁。

又有一个事务11,对student表中记录进行修改(update):将age(28)改为age(38).
事务11,因为也要修改,所以要先给该记录加行锁。(该记录是那条?)
- 将该行记录的原始数据拷贝到undo log中,采用头插方式新增一个副本
- 修改原始记录:
- 将age字段更新为38
- 将隐藏字段DB_TRX_ID设置为当前事务11的ID
- 更新回滚指针DB_ROLL_PTR,指向undo log中的副本记录,表示这是该记录的上一个版本
事务11提交,释放锁。

这样就有一个基于链表记录的历史版本链,所谓回滚,无非就是用历史数据,覆盖当前数据
上面一个个的版本,可以称为一个个快照
如果是delete呢?
删除数据不是清空,而是设置flag为删除即可,也可以形成版本
如果是insert插入呢?
之前没有数据那么insert也就没有历史版本,一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么undo log的历史insert记录就可以被清空了。
所以 update和delete可以形成版本链,insert暂时不考虑
select不会对数据进行任何修改,维护多版本也就没有意义
select读取时是读取历史版本呢?还是最新的版本呢??
当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也可能当前读
例如:select lock in share mode(共享锁), select for update (排他锁)
快照读:读取历史版本,就叫做快照读
我们可以看到,在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有select过来,如果也要读取最新版(当前读),那么也需要加锁,这就是串行化****( 操作不得不串行执行,与隔离级别无关)。
但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高效率,即 MVCC的意义所在。
在READ UNCOMMITTED(读未提交) 隔离级别下,普通的SELECT语句既不是严格意义上的当前读,也不是快照读,而是读取最新未提交的数据(脏读)。
决定select是当前读还是快照读的是隔离级别
为什么要有隔离级别??
事务都是原子的。所以无论如何,事务是有先后的
事务从begin ->CURD->commit 是有一个阶段的,即事务有执行前,执行中,执行后的阶段。但不管怎么启动多个事务,总是有先后的
多个事务在执行中,CURD操作,会交织在一起,为保证事务的有先有后,应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。
先来的事务应不应该看到后来的事务所做的修改呢?
那么,如何保证,不同的事务,看到不同的内容呢?也就是如何如何实现隔离级别?
由Read View来实现
(4) Read View
Read View就是事务进行快照读操作的时候生产的读视图(Read View) ,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID是递增的,所以最新的事务,ID值越大)
Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的。即当我们某个事务执行快照读的时候,对该记录创建一个Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,即可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据
下面是ReadView的简化结构
cpp
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
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即当前记录的DB_TRX_ID。
那么我们手中就有当前快照读的Read View和版本链中的某一个记录的DB_TRX_ID。
所以当前快照读应不应该读到当前版本记录

如上图所示我们当DB_REX_ID = creator_trx_id (创建该ReadView的事务ID) 就说明,是当前事务进行的修改,应该看到当前版本记录
如果DB_TRX_ID 小于 up_limit_id; (记录m_ids列表中事务ID最小的ID)说明,修改的事务已经commit提交了,应该看到当前版本记录
DB_TRX_TD>=low_limit_id(目前已出现过的事务ID的最大值+1)
是快照之后才提交事务也就是说在Read view之后提交的事务,不应该看到
如果有多个事务,就有多个事务ID,所有活跃事务ID都在m_ids中
注意:
这里的快照到的事务ID不一定是连续的!!!
例如,21,22,23,24,25号事务,在快照前,22,24提交了,那么找到的m_ids就是11,13,15
如果DB_TRX_ID不在m_ids列表中,说明已经提交!可以看到
如果DB_TRX_ID在m_ids里,说明该事务和我们的事务一样都是活跃事务,没有commit。不应该看到
总结
已提交的老事务 :
DB_TRX_ID < up_limit_id→ 肯定可见活跃范围的事务 :
up_limit_id <= DB_TRX_ID < low_limit_id→活跃范围内未来的新事务 :
DB_TRX_ID >= low_limit_id→ 肯定不可见活跃范围内的事务不在m_ids中说明已经提交了可以看到
在范围内说明该事务和我们的事务都是活跃事务,不应该看到
Read View 是事务可见性的一个类,不是事务创建出来,就会有Read View而是当这个事务(已经存在),首次进行快照读的时候,MySQL形成ReadView
RR与RC的本质区别
当前读和快照读在RR级别下的区别
测试表为

测试1
| 事务 A 操作 | 事务 A 描述 | 事务 B 描述 | 事务 B 操作 |
|---|---|---|---|
| begin | 开启事务 | 开启事务 | begin |
| select * from user | 快照读(无影响)查询 | 快照读查询 | select * from user |
| update user set age=18 where id=1; | 更新 age=18 | - | - |
| commit | 提交事务 | - | - |
| - | - | select 快照读,没有读到 age=18 | select * from user |
| - | - | select lock in share mode 当前读,读到 age=18 | select * from user lock in share mode |
如下图所示

测试2
| 事务 A 操作 | 事务 A 描述 | 事务 B 描述 | 事务 B 操作 |
|---|---|---|---|
| begin | 开启事务 | 开启事务 | begin |
| select * from user | 快照读,查到 age=18 | - | - |
| update user set age=28 where id=1; | 更新 age=28 | - | - |
| commit | 提交事务 | - | - |
| - | - | select 快照读 age=28 | select * from user |
| - | - | select lock in share mode 当前读 age=28 | select * from user lock in share mode |
在A提交后事务B查询结果如下

用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改age前 快照读 过一次age数据
而 表2 的事务B在事务A修改age前没有进行过快照读。
事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决 定该事务后续快照读结果的能力 delete同样如此
read view形成的时机不同,会影响事务的可见性!!
所以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才会有不可重复读问题。
这篇就到这里(づ ̄3 ̄)づ╭❤~