隔离级别和MVCC
本文源码基于MySQL 8.0.25
大家都知道数据库定义了四个隔离级别,分别对应了在多个事务同时进行时读取数据的表现:
隔离级别 | 行为 |
---|---|
读未提交 | 可以读到其它进行中的事务已写入,但是未提交的数据 |
读提交 | 读其它事务写的数据时,只有当该事务提交后才能读到 |
可重复读 | 在自己的事务中每次读取的结果必然相同 |
序列化 | 事务好像是一个个串行执行一样 |
上面的可重复读还可能产生幻读问题,所以还要序列化这个级别;MySQL幻读的一个例子是:两个事务并发写入一条id相同的数据,只有一个会成功,失败的一个会提示duplicate primary key,但是又select不到那条冲突的数据,因为写操作都是当前读,读操作是快照读,这里的快照读就是mvcc的核心;
MVCC 是mysql实现事务隔离的技术方案;它保证了在读提交和可重复读的隔离级别下,mysql中的select操作会有如下实验中的行为:
提前创建数据如下:
create table trx_t(id int primary key,name varchar(20));
insert into trx_t values(1, 'aaaa');
在隔离级别为读提交的时候,两个并发事务的行为如下:
事务1 | 事务2 |
---|---|
begin | begin |
select * from trx_t where id = 1; // 结果会返回aaaa | 无操作 |
无操作 | update trx_t set name=‘bbbb’ where id=1; |
select * from trx_t where id = 1; // 结果会返回aaaa,因为事务2没提交 | 无操作 |
无操作 | commit; |
select * from trx_t where id = 1; // 结果会返回bbbb,因为事务2已经提交了 |
在隔离级别为可重复读的时候,同样的两个并发事务的行为就不同了:
事务1 | 事务2 |
---|---|
begin | begin |
select * from trx_t where id = 1; // 结果会返回aaaa | 无操作 |
无操作 | update trx_t set name=‘bbbb’ where id=1; |
select * from trx_t where id = 1; // 结果会返回aaaa,因为事务2没提交 | 无操作 |
无操作 | commit; |
select * from trx_t where id = 1; // 结果还是会返回aaaa,因为事务2的操作对事务1不可见 |
本文会基于源码解释为何会出现上述的现象;
ReadView
很多人都了解,mysql在更新或删除一行数据时会将旧的数据行保留到undo log中,通过在每个行(最新的行以及在undo log中的行)上加上事务编号和软删除标志来实现MVCC,也就是多版本并发控制;需要注意的是,事务编号的大小和可读性之间没有绝对的关系,下文的实例中可以看到,事务编号较小的事务一样可以读到事务编号较大的事务写入的数据,只要事务编号较大的事务在事务编号较小第一次读取数据之前已经完成了提交即可;
但是实现不同事务读写之间的隔离还涉及到一个重要的概念: ReadView。ReadView 定义了当前事务可见的事务范围;当读取到一行数据时,mysql会根据readview和读取到的记录上的事务id来判断该行是否对当前事务可见。
产生上述行为的关键原因如下:在可重复读的隔离级别下,readview在事务的第一次select操作时构建,之后保持不变,因此读取到的数据也不会变;而读提交的隔离级别下,在每次select操作时会重新构建当前事务的readview,将新的已经提交的事务包括进来
MySQL中ReadView 有以下几个重要的成员变量:
class ReadView {
private:
/** 记录的trx id >= 这个值的,当前事务不可见,会设置为构建readview时已完成的最大事务号+1 */
trx_id_t m_low_limit_id;
/** 记录的trx id (<) 这个值的,当前事务可见. 会设置为构建readview时还活跃的事务id最小的事务的id */
trx_id_t m_up_limit_id;
/** 当前的事务id,只读事务设置为0 */
trx_id_t m_creator_trx_id;
/** 创建当前ReadView时活跃的读写事务编号,不包括m_creator_trx_id */
ids_t m_ids;
/** 不能看到事务号比这个还小的undo log,里面的数据已经被删除了 */
trx_id_t m_low_limit_no;
}
mysql读取一行数据的函数row_search_mvcc
中有这样一段逻辑:
dberr_t row_search_mvcc(byte *buf, page_cur_mode_t mode,
row_prebuilt_t *prebuilt, ulint match_mode,
const ulint direction) {
if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED) {
// 读未提交级别...
} else if (index == clust_index) {
if (srv_force_recovery < 5 &&
// 如果rec对当前事务不可见
!lock_clust_rec_cons_read_sees(rec, index, offsets,
trx_get_read_view(trx))) {
rec_t *old_vers;
/* 尝试从undo log中的更旧的版本读取一行数据 */
err = row_sel_build_prev_vers_for_mysql(
trx->read_view, clust_index, prebuilt, rec, &offsets, &heap,
&old_vers, need_vrow ? &vrow : nullptr, &mtr,
prebuilt->get_lob_undo());
}
...
}
而判断一行数据是否对当前事务可见的逻辑就在函数lock_clust_rec_cons_read_sees
中的changes_visible
中:
bool lock_clust_rec_cons_read_sees(
const rec_t *rec, /*!< in: user record which should be read or
passed over by a read cursor */
dict_index_t *index, /*!< in: clustered index */
const ulint *offsets, /*!< in: rec_get_offsets(rec, index) */
ReadView *view) /*!< in: consistent read view */
{
...
trx_id_t trx_id = row_get_rec_trx_id(rec, index, offsets);
return (view->changes_visible(trx_id, index->table->name));
}
重点分析下changes_visible
:
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
bool changes_visible(trx_id_t id, const table_name_t &name) const
MY_ATTRIBUTE((warn_unused_result)) {
if (id < m_up_limit_id || // 条件1
id == m_creator_trx_id) { // 条件2
return (true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) { // 条件3
return (false);
} else if (m_ids.empty()) { // 条件4
return (true);
}
const ids_t::value_type *p = m_ids.data();
return (!std::binary_search(p, p + m_ids.size(), id)); // 条件5
}
changes_visible
入参的id表示当前读到的数据的trx_id, 返回值表示这个trx_id对应的事务写入的数据是否对当前事务可见,也就是现在读到的这条数据当前事务是否可见;依次解析一下上述几个条件的含义:
条件1: id < m_up_limit_id
;在构建read_view时,m_up_limit_id
会被设置成当前所有未提交的事务中事务id最小的一个事务的事务id,读到的记录的事务id如果小于这个值,那么表示写这条数据的事务已经提交了(否则这个值应该设为当前读到的这个记录的事务id),那么这条记录当然可见;
条件2:id == m_creator_trx_id
;在构建read_view时,m_creator_trx_id
设置成了构建当前read view的事务id,满足条件2表示读到的这条数据就是当前事务创建的,这个也应该可见;
条件3:id >= m_low_limit_id
;在构建read_view时,m_low_limit_id
被设置成了下一个没分配的trx_id(当前最大),如果当前读到的记录上的事务id大于等于这个值,那么说明这个事务是在这个read_view构建完成之后才开始的,自然不应该可见;
条件4:如果 m_up_limit_id <= id < m_low_limit_id
,且m_ids为空,直接返回true;m_ids在read_view 构建时记录了除当前事务之外所有正在进行中的事务的id(也就是不可见的事务的id);如果这个为空,那么说明除了id >= m_low_limit_id
的部分,没有当前事务看不到的事务了,这条数据可见,只是因为一些原因(例如这是一个已经提交的事务,但是还有比其id更小的进行中事务,下面例子中的事务4就是这种情况)其没有被记录到m_ids中,也不满足id < m_up_limit_id
;
条件5:读到的这条记录的事务id如果在m_ids
中则不可见,反之则可见;m_ids
就是在构建read_view时所有的活跃的事务编号(不包括m_creator_trx_id);这些事务的数据当前事务自然不可见,其它的不在不可见的范围内,那么应该可见。
实例分析
下表是一个运行中的实例,可以帮助理解上面的代码逻辑:
有以下几点需要先了解:
- 第一次select的时候才会构建read view, begin的时候还没开始,update的时候也没有构建
- 第一次写数据的时候才会给事务分配事务id,否则m_creator_trx_id是0,
m_up_limit_id
和m_low_limit_id
都是下一个待分配的事务id,也就是目前为止最大的事务id
下表是8个事务,其中5是执行查询的事务,状态表示5第一次执行查询时这些事务的状态
编号 | trx_id | 状态 | 是否可见 | 备注 |
---|---|---|---|---|
1 | 20013 | 完成 | 是 | 条件1 |
2 | 20014 | 完成 | 是 | 条件1 |
3 | 20019 | 进行中 | 否 | 条件5 |
4 | 20020 | 完成 | 是 | 条件5 |
5 | 20025 | 当前事务 | 是 | 条件2 |
6 | 20026 | 已完成 | 是 | 条件5 |
7 | 20031 | 进行中 | 否 | 条件5 |
8 | 20032 | 未开始 | 否 | 条件3 |
下面是实验的详细步骤:
step1:
事务1 2 3 4 5 6 7 按时间先后依次插入一条数据;id分别为1,2,3,4,5,6,7,使会话的事务开始;
之后使用sql:
SELECT * FROM information_schema.INNODB_TRX;
查看事务id是否符合预期
step2:
事务1 2 3 6 依次提交
打开断点 ,在事务5里面执行select,read view 的值如下
查询的记录id | m_low_limit_id | m_up_limit_id | m_creator_trx_id | m_ids | m_low_limit_no |
---|---|---|---|---|---|
6 | 20032 | 20019 | 20025 | [20019,20031] | 20032 |
先分析一下查id为6 的记录的现象:
- 事务4也已经完成了,但是
m_up_limit_id
没有显示4的trx_id, 而是显示的3的id,说明这个读的是还在运行的最小事务的id; - m_ids里面也不会包含当前事务的id,虽然这个事务也在进行中;
- 事务id是不连贯的,因为还有别的表的事务也公用了这个id
step3:
事务8插入一条id为8数据,5中select id为8的数据,不可见,代码走到条件3;
源码分析
接下来分析一下源码中read view 的构建过程:
源码中设置m_up_limit_id
和m_low_limit_id
的代码如下:
首先是ReadView::prepare
void ReadView::prepare(trx_id_t id) {
ut_ad(mutex_own(&trx_sys->mutex));
m_creator_trx_id = id;
// 先将上下水位设置成最大可用事务id
m_low_limit_no = m_low_limit_id = m_up_limit_id = trx_sys->max_trx_id;
// 复制事务id
if (!trx_sys->rw_trx_ids.empty()) {
copy_trx_ids(trx_sys->rw_trx_ids);
} else {
m_ids.clear();
}
...
m_closed = false;
}
上面的代码首先将m_low_limit_no
、 m_low_limit_id
、 m_up_limit_id
的值设置成了当前系统事务的最大id;接下来构建m_ids的过程如下:
void ReadView::copy_trx_ids(const trx_ids_t &trx_ids) {
ulint size = trx_ids.size();
// m_creator_trx_id>0 表示这是一个读写事务
if (m_creator_trx_id > 0) {
--size;
}
if (size == 0) {
m_ids.clear();
return;
}
m_ids.reserve(size);
m_ids.resize(size);
ids_t::value_type *p = m_ids.data();
/* Copy all the trx_ids except the creator trx id */
if (m_creator_trx_id > 0) {
// 查找到m_creator_trx_id在trx_ids里面的下标
trx_ids_t::const_iterator it =
std::lower_bound(trx_ids.begin(), trx_ids.end(), m_creator_trx_id);
// 计算离第一个元素的偏移量
ulint i = std::distance(trx_ids.begin(), it);
// 计算要复制多少字节
ulint n = i * sizeof(trx_ids_t::value_type);
// p指向了m_ids, 将trx_ids前n个字节拷贝进去
::memmove(p, &trx_ids[0], n);
// 计算除了m_creator_trx_id还需要拷贝多少字节
n = (trx_ids.size() - i - 1) * sizeof(trx_ids_t::value_type);
// 将剩下的也拷贝过去
if (n > 0) {
::memmove(p + i, &trx_ids[i + 1], n);
}
} else {
// 读事务 m_creator_trx_id不在trx_ids里面,不需要拷贝
ulint n = size * sizeof(trx_ids_t::value_type);
::memmove(p, &trx_ids[0], n);
}
m_up_limit_id = m_ids.front();
}
上面的m_ids.front();
就是m_ids
里面的第一个,也就是id最小的一个进行中事务的id;上面的代码主要做了两件事:
- 如果
m_creator_trx_id
不为0,即当前是一个读写事务,就将trx_ids
里面除了m_creator_trx_id
之外的记录拷贝到m_ids
中 - 将
m_ids
中第一个值赋给m_up_limit_id
完成了这两件事情之后,read view 中主要的几个变量就设置好了;就可以根据这个read view 判断记录对当前事务的可见性了;
从读取数据到调用ReadView::prepare的链路如下:
row_search_mvcc
->trx_assign_read_view
->view_open
->prepare
代码如下:
dberr_t row_search_mvcc(byte *buf, page_cur_mode_t mode,
row_prebuilt_t *prebuilt, ulint match_mode,
const ulint direction) {
...
if (!srv_read_only_mode) {
trx_assign_read_view(trx);// 下一步这里进入
}
...
}
ReadView *trx_assign_read_view(trx_t *trx) /*!< in/out: active transaction */
{
ut_ad(trx->state == TRX_STATE_ACTIVE);
if (srv_read_only_mode) {
ut_ad(trx->read_view == nullptr);
return (nullptr);
} else if (!MVCC::is_view_active(trx->read_view)) {
trx_sys->mvcc->view_open(trx->read_view, trx); // 下一个
}
return (trx->read_view);
}
void MVCC::view_open(ReadView *&view, trx_t *trx) {
/** If no new RW transaction has been started since the last view
was created then reuse the the existing view. */
if (view != nullptr) {
uintptr_t p = reinterpret_cast<uintptr_t>(view);
view = reinterpret_cast<ReadView *>(p & ~1);
mutex_enter(&trx_sys->mutex);
} else {
mutex_enter(&trx_sys->mutex);
view = get_view();
}
if (view != nullptr) {
view->prepare(trx->id);
...
}
trx_sys_mutex_exit();
}
Read Committed 和 Repeated Read差异原因分析
之前说到,RC级别下能看到新提交的数据,是因为每次select的时候都会重建read view,上面的构建read view 里面的代码里有一段:
else if (!MVCC::is_view_active(trx->read_view)) {
trx_sys->mvcc->view_open(trx->read_view, trx); // 下一个
}
说明只有在MVCC::is_view_active
判断当前view失效了的情况下才会新创建,跟踪源码发现确实是这样,RCis_view_active
每次返回false,然后重新执行view_open
,RR下则不会,看下这个函数的逻辑:
@return true if the view is active and valid */
static bool is_view_active(ReadView *view) {
ut_a(view != reinterpret_cast<ReadView *>(0x1));
return (view != nullptr && !(intptr_t(view) & 0x1));
}
看来在RC下某个地方在select完了会把当前read view 置为失效;通过直接在代码里搜索和常量TRX_ISO_READ_COMMITTED
有关的代码,我们发现了这样一段代码:
if (trx->isolation_level <= TRX_ISO_READ_COMMITTED &&
MVCC::is_view_active(trx->read_view)) {
/* At low transaction isolation levels we let
each consistent read set its own snapshot */
mutex_enter(&trx_sys->mutex);
trx_sys->mvcc->view_close(trx->read_view, true);
// std::cout<<"i will not close 2"<<std::endl;
mutex_exit(&trx_sys->mutex);
}
基本上可以确定就是这段代码在起作用了,验证方法很简单,把这行代码注释掉,结果果然看不到最新提交的数据了;继续跟踪源代码,也发现read view 并没有被置为失效,这段代码的运行时调用栈是这样的:
ha_innobase::store_lock(THD*, THR_LOCK_DATA**, thr_lock_type) ha_innodb.cc:18972
get_lock_data(THD*, TABLE**, unsigned long, unsigned int) lock.cc:682
mysql_lock_tables(THD*, TABLE**, unsigned long, unsigned int) lock.cc:327
lock_tables(THD*, TABLE_LIST*, unsigned int, unsigned int) sql_base.cc:6860
Sql_cmd_dml::execute(THD*) sql_select.cc:571
mysql_execute_command(THD*, bool) sql_parse.cc:4412
dispatch_sql_command(THD*, Parser_state*) sql_parse.cc:5000
dispatch_command(THD*, COM_DATA const*, enum_server_command) sql_parse.cc:1841
do_command(THD*) sql_parse.cc:1320
至此我们就完全弄清楚了MVCC的原理,代码逻辑不复杂,关键是要多做实验,看看运行时数据究竟是怎样的。