数据库并发的场景总共有三种:
第一类更新丢失也被称为:回滚丢失,是指一个事务的回滚操作导致了另一个已经提交的事务的更新操作丢失。换句话说,当一个事务回滚时,它覆盖了另一个已经提交的事务所做的更改。这可能会导致数据的不一致性和错误。
第二类更新丢失也被称为:覆盖丢失,是指一个事务的提交操作导致了另一个已经提交的事务的更新操作丢失。换句话说,当一个事务提交时,它覆盖了另一个已经提交的事务所做的更改。这也会导致数据的不一致性和错误
说明:
多版本并发控制( MVCC )是一种用来解决读-写冲突 的无锁并发控制
其主要内容是:
所以MVCC 可以为数据库解决以下问题:
理解MVCC 需要知道三个前提知识:
数据库表中的每条记录都会有如下3个隐藏字段:
DB_TRX_ID
:6字节,创建或最近一次修改本条记录的事务ID。DB_ROW_ID
:6字节,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB
会自动以DB_ROW_ID
产生一个聚簇索引。DB_ROLL_PTR
:7字节,回滚指针,指向这条记录的上一个版本(这些数据一般在undo log
中)。此外,数据库表中的每条记录还有一个删除flag
隐藏字段,用于表示该条记录是否被更新或删除,所以mysql中的删除并不代表真的删除,只是是删除flag变了,然后当数据需要进行刷盘持久化时,通过查看这个flag字段来进行选择性刷盘。
例如下面的一个学生表,表中包含学生的姓名和年龄。如下:
当向表中插入一条记录后(假设插入的这条SQL对应的事务ID是9),该记录不仅包含name
和age
字段,还包含三个隐藏字段。如下:
null
。MySQL的三大日志如下:
MySQL会为上述三大日志在内存中开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
说明:
undo log
,记录的历史版本就是存储在undo log
对应的缓冲区中的。现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为“李四”:
undo log
中,此时undo log
中就有了一行副本数据。DB_TRX_ID
改为10,回滚指针DB_ROLL_PTR
设置成undo log
中副本数据的地址,从而指向该记录的上一个版本。备注:此时最新的记录是’李四‘那条记录。
现在又有一个事务ID为11的事务,要将刚才学生表中的那条记录的学生年龄改为38:
undo log
中,此时undo log
中就又有了一行副本数据。DB_TRX_ID
改为11,回滚指针DB_ROLL_PTR
设置成刚才拷贝到undo log
中的副本数据的地址,从而指向该记录的上一个版本。修改后的示意图如下:
此时我们就有了一个基于链表记录的历史版本链,而undo log
中的一个个的历史版本就称为一个个的快照。
说明:
undo log
中的历史数据覆盖当前数据,而所谓的创建保存点就可以理解成是给某些版本做了标记,让我们可以直接用这些版本数据来覆盖当前数据。undo log
中,然后再进行写操作,和父子进程为了保证独立性而进行的写时拷贝是类似的
- insert和delete的记录如何维护版本链?
undo log
中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。
- 当前读 VS 快照读
在前面我们讨论了update
,delete
,insert
形成版本链的方式,那么select
怎么形成版本链呢?
首先,select
不会对数据做任何修改,所以,为select
维护多版本,没有意义!
不过,此时对于select
有个问题值得被讨论就是:select读取,是读取最新的版本呢?还是读取历史版本?
select * from 表名 lock in share mode
(共享锁)进行当前读。select * from 表名
都是快照读,如果没有快照就进行当前读。所以事务在进行增删查改的时候,并不是都需要进行加锁保护的:
select
查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,这提高了效率,这也就是MVCC的意义所在。而select
查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
- undo log中的版本链何时才会被清除?
undo log
中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。undo log
中的版本链才可以被清除。说明:
undo log
中的版本链清除了。undo log
中可能会存在很长时间,尤其是有其他事务和这个版本链相关联的时候,但这也没有坏处,这说明它是一个热数据。事务在进行快照读操作时会生成读视图Read View
,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。
Read View
在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View
,根据这个Read View
来判断,当前事务能够看到该记录的哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log
里面的某个版本的数据。
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
列表中事务ID最小的ID。我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的DB_TRX_ID
,那么我们现在手里面有的东西就有,当前快照读的ReadView
和 版本链中的某一个记录的DB_TRX_ID
。
所以现在的问题就是,当前快照读应不应该读到当前版本记录,下面的图能够解释这个问题。
由于事务ID是单向增长的,因此根据Read View
中的m_up_limit_id
和m_low_limit_id
,可以将事务ID分为三个部分:
m_up_limit_id
的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id
是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。m_low_limit_id
的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id
是生成Read View时刻,系统尚未分配的下一个事务ID。m_up_limit_id
和m_low_limit_id
之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids
中来判断该事务是否已经提交。判断部分:
DB_TRX_ID
,即创建或最近一次修改该记录的事务ID,因此可以依次遍历版本链中的各个版本,通过Read View来判断当前事务能否看到这个版本,如果不能则继续遍历下一个版本。源码策略如下(这个函数被调用的在一个循环中,这个循环从最新的历史版本开始向后遍历undo log
里面的所有历史版本):
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类里面的一个成员函数,其作用就是根据Read View,判断当前事务能否看到这个版本。
我们通过下面的实验现象来理解RR与RC隔离级别
- 现象演示1
主要操作以及执行顺序:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
- | - | 快照读 | 快照读查询select * from account |
update account set balance=1789.7 where name=‘张三’; | 更新 balance=1789.7 | - | - |
commit | 提交事务 | - | - |
- | - | 进行快照读 | select * from account |
- | - | 进行当前读 | select * from user lock in share mode |
启动两个终端,将隔离级别都设置为可重复读,并查看此时银行用户表中的数据。如下:
在两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息。如下:
左终端中的事务对表中的信息进行修改并提交,右终端中的事务看不到修改后的数据。如下:
在右终端中使用select ... lock in share mode
命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。如下:
- 现象演示2
主要操作以及执行顺序:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from account | 快照读 | 快照读 | select * from account |
update account set balance=789.6 where id=7; | 更新 balance=789.6 | - | - |
commit | 提交事务 | - | - |
- | - | 快照读 | select * from account |
如果修改一下SQL的执行顺序,在两个终端各自启动一个事务后,直接先让左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据。如下:
这是为什么呢?
因为Read View
的形成时机,是在第一次进行快照读的时候形成的
实验1中,右边的终端在左边的修改提交之前进行了快照读,形成了Read View
,于是这个Read View
认为左边终端中的事务,是和它一起并发运行的事务,它不应该看到左边终端中修改提交后的数据。
实验二中,左边终端的修改没有提交之前,右边的终端在一直没有形成Read View
,直到左边的事务结束以后,右边的终端才开始进行快照读,形成Read View
,但是这个时候左边的事务已经结束了,于是这个刚形成的Read View
就会判定左边终端中的事务是一个已经运行完毕的事务,其内容可以被看到。
- RR与RC的本质区别
RR与RC的本质区别于Read View
生成时机!
在RR级别下,事务第一次进行快照读时会创建一个Read View,将当前系统中活跃的事务记录下来,此后再进行快照读时就会直接使用这个Read View进行可见性判断,因此当前事务看不到第一次快照读之后其他事务所作的修改。
而在RC级别下,事务每次进行快照读时都会创建一个Read View,然后根据这个Read View进行可见性判断,因此每次快照读时都能读取到被提交了的最新的数据。
RR级别下快照读只会创建一次Read View,所以RR级别是可重复读的,而RC级别下每次快照读都会创建新的Read View,所以RC级别是不可重复读的。
参考文章
MySQL事务管理