0%

MySQL源代码阅读:一致性读的实现

Innodb是一个支持MVCC(即多版本并发控制)的存储引擎,一致性读功能基于MVCC。本文基于MySQL 5.7的源代码讨论一致性读的原理,包括快照的创建、判断是否可见、快照的关闭等。

前提

  • 本文基于mysql-5.7.29,为方便阅读,文中代码大量删减。
  • 需要了解“快照读”:生成快照时未提交事务的更改不可见,已提交事务的更改可见。

事务结构体

事务的结构体定义位于storage/innobase/include/trx0trx.h:898。与MVCC相关的部分如下:

1
2
3
4
5
6
7
8
9
10
11
struct trx_t {
/* 事务ID */
trx_id_t id; /*!< transaction id */


/* 一致性读的快照 */
ReadView* read_view; /*!< consistent read view used in the transaction, or NULL if not yet set */

// 省略一大堆属性...
// ...
}

其中事务的ID是一个64位的非负整数,需要注意的是,只有读写事务会分配事务ID,只读事务是不会分配ID的。
每个事务拥有各自的ReadView,以下简称快照。

ReadView源代码

ReadView中定义了一个m_ids,它保存了处于活跃状态的事务ID列表,用于判断其它事务的修改对当前事务是否可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ReadView {

/**
* 创建这个快照的事务ID
*/
trx_id_t m_creator_trx_id;

/**
* 生成这个快照时处于活跃状态的事务ID的列表,
* 是个已经排好序的列表
*/
ids_t m_ids;

/**
* 高水位线:id大于等于 m_low_limit_id 的事务都不可见。
* 在生成快照时,它被赋值为“下一个待分配的事务ID”(会大于所有已分配的事务ID)。
*/
trx_id_t m_low_limit_id;

/**
* 低水位线:id小于m_up_limit_id的事务都不可见。
* 它是活跃事务ID列表的最小值,在生成快照时,小于m_up_limit_id的事务都已经提交(或者回滚)。
*/
trx_id_t m_up_limit_id;


// 判断事务是否可见的方法
bool changes_visible(){}

// 关闭快照的方法
void close(){}
// ...
}

判断更改是否可见

ReadView中, 有一个changes_visible()方法,用于判断某个事务的更改对当前事务是否可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* 判断某个事务的修改对当前事务是否可见 */
bool changes_visible(){

/**
* 可见的情况:
* 1. 小于低水位线,即创建快照时,该事务已经提交(或回滚)
* 2. 事务ID是当前事务。
*/
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}

if (id >= m_low_limit_id) { /* 高于水位线不可见,即创建快照时,该事务还没有提交 */
return(false);

} else if (m_ids.empty()) { /* 创建快照时,没有其它活跃的读写事务时,可见 */

return(true);
}

/**
* 执行到这一步,说明事务ID在低水位和高水位之间,即 id ∈ [m_up_limit_id, m_low_limit_id)
* 需要判断是否属于在活跃事务列表m_ids中,
* 如果在,说明创建快照时,该事务处于活跃状态(未提交),修改对当前事务不可见。
*/

// 获取活跃事务ID列表,并使用二分查找判断事务ID是否在 m_ids中
const ids_t::value_type* p = m_ids.data();
return(!std::binary_search(p, p + m_ids.size(), id));
}

由此可见,当前事务判断一个事务的修改是否可见,主要依靠活跃事务列表m_ids来判断。

对于具体的某一行记录,如何判断当前事务是否可见呢?

判断主键索引是否可见

对于主键索引中的每一个数据行,除了用户定义的字段,还有额外的系统字段,包括:

  • Transaction ID: 修改这行记录的事务ID。
  • Roll Pointer: undo日志指针

有了记录行的事务ID,再调用changes_visible()就能知道这个记录对当前事务是否可见:

1
2
3
4
5
6
7
8
9
10
11
12
/**
Checks that a record is seen in a consistent read.
@return true if sees, or false if an earlier version of the record
should be retrieved */
bool lock_clust_rec_cons_read_sees()
{
// 获取修改这个数据行的事务ID
trx_id_t trx_id = row_get_rec_trx_id(rec, index, offsets);

// 调用 changes_visible() 判断是否可见
return(view->changes_visible(trx_id, index->table->name));
}

如果不可见呢?需要去undo日志中找之前的版本:

1
2
3
4
5
6
7
8
9
if (!lock_clust_rec_cons_read_sees(clust_rec, index, offsets,
node->read_view)) {
// 判断数据行不可见时,去找之前的版本
err = row_sel_build_prev_vers(
node->read_view, index, clust_rec,
&offsets, &heap, &plan->old_vers_heap,
&old_vers, mtr);
// ......
}

判断辅助索引是否可见

假设通过一个辅助索引查询id的SQL:

1
select id from t where idx=2; 

通过idx=2定位到索引记录时,仅从辅助索引就可以获取到对应的id。那此时能否直接返回这个id呢?

显然不能。
与主键索引不同,辅助索引并没有保存修改这条记录的事务id,因此并不能判断idx=2对应的记录是否可见。例如这条记录在当前事务创建快照之后,才被另外一个事务创建,那此时就是不可见的(隔离级别为可重复读时)。

对于辅助索引,每个数据页有一个字段:PAGE_MAX_TRX_ID,保存了修改这个数据页的最大事务ID。

因此,判断辅助索引中的记录是否可见时,判断条件为:max_trx_id < m_up_limit_id

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 判断辅助索引中的记录是否可见。
* 返回true时,可见。返回false时,不确定,需要去主键索引中查询。
*/
bool lock_sec_rec_cons_read_sees()
{
// 修改这个页的最大事务id
trx_id_t max_trx_id = page_get_max_trx_id(page_align(rec));

// 判断是否可见,条件是 max_trx_id < m_up_limit_id
return(view->sees(max_trx_id));
}

具体来说:

  • max_trx_id小于低水位线时,可见,因为当前事务创建快照时修改这个索引页的事务已经提交。
  • 当条件不成立时,无法确定是否可见。此时需要到主键索引中查找,再根据前面的changes_visible()来判断。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 无法确定辅助索引是否可见时,
* 先执行ICP(索引下推)判断索引是否匹配,如果匹配再去查主键索引。
* 如果条件不匹配,就处理下一个辅助索引记录。
*/
if (!srv_read_only_mode
&& !lock_sec_rec_cons_read_sees(rec, index, trx->read_view)) {

switch (row_search_idx_cond_check(
buf, prebuilt, rec, offsets)) {
case ICP_NO_MATCH: // ICP不匹配,不用再去看主键索引。直接处理下一条记录
goto next_rec;
case ICP_OUT_OF_RANGE:
err = DB_RECORD_NOT_FOUND;
goto idx_cond_failed;
case ICP_MATCH: // ICP满足条件时,查主键索引,判断是否可见
goto requires_clust_rec;
}

ut_error;
}

获取活跃事务列表

在创建快照时,获取活跃的读写事务列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 生成读写事务ID列表,计算高低水位线等。
* 在创建快照 MVCC::view_open() 时调用
*/
void ReadView::prepare(trx_id_t id){

// 创建快照的ID为当前事务ID
m_creator_trx_id = id;

// 高水位线是“下一个待分配事务ID”
m_low_limit_no = m_low_limit_id = trx_sys->max_trx_id;

/**
* 系统事务结构体(trx_sys)中会记录活跃的事务ID列表(trx_sys->rw_trx_ids),
* 如果有活跃的读写事务,就从trx_sys复制读写事务ID列表到m_ids中
*/
if (!trx_sys->rw_trx_ids.empty()) {
copy_trx_ids(trx_sys->rw_trx_ids);
} else {
// 创建快照时没有活跃的读写事务
m_ids.clear();
}

// ...
}

上面的代码获取到了m_ids和高水位线,低水位线在ReadView::complete()中计算:

1
2
3
4
5
6
7
void ReadView::complete()
{
/* 低水位线是活跃事务列表的最小值,即第1个活跃事务ID */
m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;

// ...
}

快照的生成

快照的生成定义在ReadView* trx_assign_read_view()方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ReadView* trx_assign_read_view(
/*=================*/
trx_t* trx) /*!< in/out: active transaction */
{

if (srv_read_only_mode) { // 只读模式不用生成快照
return(NULL);

} else if (!MVCC::is_view_active(trx->read_view)) { // 如果已经生成快照,会直接返回
trx_sys->mvcc->view_open(trx->read_view, trx); // 执行生成快照
}

return(trx->read_view);
}

调用trx_assign_read_view()方法主要有2个地方:

  • row_search_mvcc()
    在执行无锁的select时会调用,即select不带for updatelock in share mode
  • innobase_start_trx_and_assign_read_view()
    使用start transaction with consistent snapshot语句在开始事务的时候创建一致性视图(仅在可重复读时有效),代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int innobase_start_trx_and_assign_read_view()
{
if (trx->isolation_level == TRX_ISO_REPEATABLE_READ) {
trx_assign_read_view(trx);
} else {
// 隔离级别不是可重复读时,会输出一条warning
push_warning_printf(thd, Sql_condition::SL_WARNING,
HA_ERR_UNSUPPORTED,
"InnoDB: WITH CONSISTENT SNAPSHOT"
" was ignored because this phrase"
" can only be used with"
" REPEATABLE READ isolation level.");
}
}

快照的关闭

不同隔离级别,处理关闭快照有点不同。

  • 在可重复读隔离级别下,快照会在事务结束时关闭。整个事务的多个SQL使用同一个快照。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    static void trx_commit_in_memory(){

    if (trx_is_autocommit_non_locking(trx)) { // 自动提交的非锁定一致性读
    if (trx->read_view != NULL) {
    // 执行关闭快照
    trx_sys->mvcc->view_close(trx->read_view, false);
    }
    } else {

    if (trx->id > 0) {
    /* trx->id 大于0,说明当前事务是读写事务。
    * 需要从系统事务结构体(trx_sys->rw_trx_ids)的读写事务列表中移除当前事务ID,并关闭视图
    */
    /* For consistent snapshot, we need to remove current
    transaction from running transaction id list for mvcc
    before doing commit and releasing locks. */
    trx_erase_lists(trx, serialised);
    }

    // 只读事务
    if (trx->read_only || trx->rsegs.m_redo.rseg == NULL) {

    // 快照不为空时关闭快照
    if (trx->read_view != NULL) {
    trx_sys->mvcc->view_close(trx->read_view, false);
    }
    }

    }
  • 在读已提交隔离级别下,快照会在SQL语句结束时关闭。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /* If the MySQL lock count drops to zero we know that the current SQL
    statement has ended */
    if (trx->n_mysql_tables_in_use == 0) { // 当前SQL使用到的表格是数0,说明SQL执行结束了

    // 隔离级别为读已提交时,执行 关闭快照
    if (trx->isolation_level <= TRX_ISO_READ_COMMITTED
    && MVCC::is_view_active(trx->read_view)) {


    trx_sys->mvcc->view_close(trx->read_view, true);

    }
    }

总结

  • 执行无锁的select语句时会生成快照
  • 在可重复读隔离级别下,快照会在事务结束时关闭。
  • 在读已提交隔离级别下,快照会在SQL语句结束时关闭。
  • 可见性判断:生成快照时已提交的事务可见。