目录
- 一、数据库的并发场景
- 二、多版本并发控制
- 隐藏字段
- undo日志
- 模拟MVCC
- Read View
- 整体流程
- RC(不可重复读)和RR(可重复读)的本质区别
一、数据库的并发场景
数据库并发的场景无非如下三种:
- 读-读并发:不存在任何问题,也不需要并发控制。
- 读-写并发:有线程安全问题,可能会存在事务隔离性问题,可能遇到脏读、幻读、不可重复读。
- 写-写并发:有线程安全问题,可能会存在两类更新丢失问题。
说明一下:
- 写-写并发场景下的第一类更新丢失又叫做回滚丢失,即一个事务的回滚把另一个已经提交的事务更新的数据覆盖了,第二类更新丢失又叫做覆盖丢失,即一个事务的提交把另一个已经提交的事务更新的数据覆盖了。
- 读-读并发不需要进行并发控制,写-写并发实际也就是对数据进行加锁,这里最值得讨论的是读-写并发,读-写并发是数据库当中最高频的场景,在解决读-写并发时不仅需要考虑线程安全问题,还需要考虑并发的性能问题。
二、多版本并发控制
- 多版本并发控制(Multi-Version Concurrency Control,MVCC)是一种用来解决读写冲突的无锁并发控制,主要依赖记录中的3个隐藏字段、undo日志和Read View实现。
- 为事务分配单向增长的事务ID,为每个修改保存一个版本,将版本与事务ID相关联,读操作只读该事务开始前的数据库快照。
- MVCC保证读写并发时,读操作不会阻塞写操作,写操作也不会阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读、幻读和不可重复读等事务隔离性问题。
隐藏字段
数据库表中的每条记录都会有如下3个隐藏字段:
- DB_TRX_ID:6字节,创建或最近一次修改该记录的事务ID。
- DB_ROW_ID:6字节,隐含的自增ID(隐藏主键)。
- DB_ROLL_PTR:7字节,回滚指针,指向这条记录的上一个版本。
说明一下:
- 采用InnoDB存储引擎建立的每张表都会有一个主键,如果用户没有设置,InnoDB就会自动以DB_ROW_ID产生一个聚簇索引。
- 此外,数据库表中的每条记录还有一个删除flag隐藏字段,用于表示该条记录是否被删除,便于进行数据回滚。
实例
创建一个学生表,表中包含学生的姓名和年龄。如下:
当向表中插入一条记录后,该记录不仅包含name和age字段,还包含三个隐藏字段。如下:
name | age | DB_ROW_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | 9 | 1 | null |
- 假设插入该记录的事务的事务ID为9,那么该记录的DB_TRX_ID字段填的就是9。
- 因为这是插入的第一条记录,所以隐式主键DB_ROW_ID字段填的就是1。
- 由于这条记录是新插入的,没有历史版本,所以回滚指针DB_ROLL_PTR的值设置为null。
- MVCC重点需要的就是这三个隐藏字段,实际还有其他隐藏字段,只不过没有画出。
undo日志
- redo log:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。
- bin log:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。
- undo log:回滚日志,用于对已经执行的操作进行回滚,保证事务的原子性。
MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
MVCC的实现主要依赖三大日志中的undo log,记录的历史版本就是存储在undo log对应的缓冲区中的。
模拟MVCC
现在有一个事务10(仅仅为了好区分),对student表中记录进行修改(update):将name(张三)改成name(李四)。
-
事务10,因为要修改,所以要先给该记录加行锁。
-
修改前,现将改行记录拷贝到undo log中,所以,undo log中就有了一行副本数据。(原理就是写时拷贝)
-
所以现在 MySQL 中有两行同样的记录。现在修改原始记录中的name,改成 ‘李四’。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务10 的ID, 我们默认从 10 开始,之后递增。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它。
-
事务10提交,释放锁。
备注:此时,最新的记录是’李四‘那条记录。
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)。
- 事务11,因为也要修改,所以要先给该记录加行锁。(该记录是那条?)
- 修改前,现将改行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的副本,我们采用头插方式,插入undo log。
- 现在修改原始记录中的age,改成 38。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副
- 本记录,既表示我的上一个版本就是它。
- 事务11提交,释放锁。
这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
上面的一个一个版本,我们可以称之为一个一个的 快照。
insert和delete的记录如何维护版本链?
- 删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。
- 新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了。
当前读、快照读
- 当前读:读取最新的记录,就叫做当前读。
- 快照读:读取历史版本,就叫做快照读。
事务在进行增删查改的时候,并不是都需要进行加锁保护:
- 事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。
- 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
Read View
事务在进行快照读操作时会生成读视图ReadView,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。
ReadView在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个ReadView,根据这个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: 记录Read View生成时刻,系统尚未分配的下一个事务ID。
- m_creator_trx_id: 记录创建该Read View的事务的事务ID。
由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:
- 事务ID小于 m_up_limit 的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。
- 事务ID大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID。
- 事务ID位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。
- 一个事务在进行读操作时,只应该看到自己或已经提交的事务所作的修改,因此我们可以根据Read View来判断当前事务能否看到另一个事务所作的修改。
- 版本链中的每个版本的记录都有自己的DB_TRX_ID,即创建或最近一次修改该记录的事务ID,因此可以依次遍历版本链中的各个版本,通过Read View来判断当前事务能否看到这个版本,如果不能则继续遍历下一个版本。
源码:
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));
}
使用该函数时将版本的DB_TRX_ID传给参数id,该函数的作用就是根据Read View,判断当前事务能否看到这个版本。
如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即可以看到。上面的
readview 是当你进行select的时候,会自动形成。
整体流程
假设当前有条记录:
事务操作:
- 事务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
此时版本链是:
- 只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务。
我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list) 进行比较,判断当前事务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提交的版本也是全局角度上最新的版本
实例:
RC(不可重复读)和RR(可重复读)的本质区别
实例
不可重复读
在两个事务并发运行时,一个事务可以观察到另一个事务操作数据的过程。
可重复读
情况1:
情况2:
上面两次实验的唯一区别在于,右终端中的事务在左终端中的事务修改数据之前是否进行过快照读。
RR与RC的本质区别
- 正是因为Read View生成时机的不同,从而造成了RC和RR级别下快照读的结果的不同。
- 在RR级别下,事务第一次进行快照读时会创建一个Read View,将当前系统中活跃的事务记录下来,此后再进行快照读时就会直接使用这个Read View进行可见性判断,因此当前事务看不到第一次快照读之后其他事务所作的修改。
- 而在RC级别下,事务每次进行快照读时都会创建一个Read View,然后根据这个Read View进行可见性判断,因此每次快照读时都能读取到被提交了的最新的数据。
- RR级别下快照读只会创建一次Read View,所以RR级别是可重复读的,而RC级别下每次快照读都会创建新的Read View,所以RC级别是不可重复读的。