MySQL8 MVCC源码分析

隔离级别和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
beginbegin
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
beginbegin
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);这些事务的数据当前事务自然不可见,其它的不在不可见的范围内,那么应该可见。

实例分析

下表是一个运行中的实例,可以帮助理解上面的代码逻辑:

有以下几点需要先了解:

  1. 第一次select的时候才会构建read view, begin的时候还没开始,update的时候也没有构建
  2. 第一次写数据的时候才会给事务分配事务id,否则m_creator_trx_id是0,m_up_limit_idm_low_limit_id都是下一个待分配的事务id,也就是目前为止最大的事务id

下表是8个事务,其中5是执行查询的事务,状态表示5第一次执行查询时这些事务的状态

编号trx_id状态是否可见备注
120013完成条件1
220014完成条件1
320019进行中条件5
420020完成条件5
520025当前事务条件2
620026已完成条件5
720031进行中条件5
820032未开始条件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 的值如下

查询的记录idm_low_limit_idm_up_limit_idm_creator_trx_idm_idsm_low_limit_no
6200322001920025[20019,20031]20032

先分析一下查id为6 的记录的现象:

  1. 事务4也已经完成了,但是m_up_limit_id没有显示4的trx_id, 而是显示的3的id,说明这个读的是还在运行的最小事务的id;
  2. m_ids里面也不会包含当前事务的id,虽然这个事务也在进行中;
  3. 事务id是不连贯的,因为还有别的表的事务也公用了这个id

step3:
事务8插入一条id为8数据,5中select id为8的数据,不可见,代码走到条件3;

源码分析

接下来分析一下源码中read view 的构建过程:
源码中设置m_up_limit_idm_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_nom_low_limit_idm_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;上面的代码主要做了两件事:

  1. 如果m_creator_trx_id不为0,即当前是一个读写事务,就将trx_ids里面除了m_creator_trx_id之外的记录拷贝到m_ids
  2. 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的原理,代码逻辑不复杂,关键是要多做实验,看看运行时数据究竟是怎样的。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值