最新要闻

广告

手机

iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?

iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?

警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案

警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案

家电

MySql的MVCC机制

来源:博客园


(资料图片)

事务隔离级别遗留问题:

  1. 在读已提交的级别下,事务B可以读到事务A持有写锁的的记录,且读到的是未更新前的,为何写读没有冲突?

  2. 可重复读级别,事务B可以更新事务A理论上应该已经获取读锁的记录,且更新后,事务A依然可以读到数据,为何读-写-读没有冲突?

  3. 在可重复读级别,幻读没有产生

其中,前两个问题就是因为mvcc机制(读锁的一种优化机制),通过不加读锁,避免读写冲突,进而提高了性能。

为什么要有MVCC机制?

  1. 在读已提交的级别下,由于是给读加锁来保证读已提交, 如果事务A持有写锁,为了保证读已提交,事务B必须等待事务A提交之后才可以读;其他的读事务也是这样的情况,效率太低

  2. 在可重复读级别,为了保证可重复读,如果事务A持有读锁,为了第二次读到的一样,其他所有写事务必须等待读完才可以,同样效率低

那么很自然的想到,无论读事务是先产生还是后产生,如果这个时候还存在写事务没有执行,或者需要执行;那么就应该让读事务读到目前最新的值,且写事务可以更新;只不过读事务在写事务提交更新后,依据隔离级别是否可见最新更新即可。这就是MVCC机制的核心能力,将读锁干掉。

MVCC机制核心组件

MVCC机制由版本链、undolog、readview三大核心构成

版本链

猜测很多人第一次看到MVCC的版本都是和我一样在各种各样的博客文章上,或者可能是在一些课程专栏或者《高性能mysql》这本书的mvcc部分看到的,那么在你的理解中,版本的底层是什么样子呢?innodb引擎数据库中的每一条记录上,我们都可以认为上面有3个隐藏字段,分别是DB_ROW_ID(不在此次讨论范围),DB_TRX_ID和DB_ROLL_PTR,如下图一样在我的理解中,DB_TRX_ID就是插入或者更新时,当前事务的trx_id,由全局事务管理器分配的递增的一个id;DB_ROLL_PTR存储的undolog中当前记录上一个版本的指针,先姑且记住这是一个指针。当插入一条记录时在这条记录的DB_TRX_ID填入当前事务的id,由于没有历史版本,所以DB_ROLL_PTR为空当更新一条记录时由于这个时候存在历史版本,所以需要将老版本的数据写到undolog里,然后构建指针,将DB_TRX_ID更新为当前事务的id,将DB_ROLL_PTR更新为刚才构建的指针,以及更新需要更新的字段。当删除一条记录时(这个不太确定,主观猜测)猜测是将老记录写到undolog,然后构建指针,新记录DB_TRX_ID更新为当前事务的id,将DB_ROLL_PTR更新为刚才构建的指针,但是没有需要更新的字段。而且mysql不会立即删除,记录上有一个info_bits字段,会标记上删除标识(REC_INFO_DELETED_FLAG),后续由purge线程(不了解,姑且认为是个scheduleTask吧)删除这样,当多次更新之后,新记录存储的永远都是最新操作的事务id,并通过指针指向了老版本,老版本还指向了更老的版本...等等,最终构成了一个版本链

Readview

理论:

在周志明老师的凤凰架构(或者极客时间的‘周志明的软件架构课’)中对mvcc简单介绍到
隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。
在mysql官网中是这么描述的
If the transaction isolation level is REPEATABLE READ(the default level), all consistent reads within the same transaction read the snapshot established by the first such read in that transaction. You can get a fresher snapshot for your queries by committing the current transaction and after that issuing new queries.With READ COMMITTEDisolation level, each consistent read within a transaction sets and reads its own fresh snapshot.
翻译:隔离级别是可重复读:在同一个事务中,一致性读总是去读在该事务第一次读取时生成的快照。隔离级别是读已提交:事务中的每次读取都取自己新生成的快照。相比之下,周老师形容的更贴近隔离级别的概念上,官方的描述则是底层的具体实现逻辑。两者结合一下就是可重复读:通过在每个事物只读取第一次select时生成的快照和undolog比较,根据一个可见性规则判断,是否可以读当前版本的记录,可以就返回,不行就继续比较再上一个版本,直到最老的版本;读已提交:除了每次读取都会使用最新的快照,后面的都和可重复读的逻辑一样。为什么我这里说的是可见性规则呢?是因为周老师描述里“总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录”很容易错误的理解为当前版本记录里的trx_id<=快照创建时的事务id(create_trx_id)就都可见,真正的判断逻辑并不只是一个create_trx_id就能搞定的。但这里先不展开讲,自己想一下为什么不行,下面的图可能会给你一点灵感,接下来我们先去读一下“可见性规则”的底层源码。

可见性规则底层实现

ReadView类

storage/innobase/include/read0types.h:47//ReadView类class ReadView {...private:  /** trx id of creating transaction, set to TRX_ID_MAX for free  views. */  //创建快照的时候,快照对应的事务id,只有含有写操作的才会分配真正的事务id  trx_id_t m_creator_trx_id;/** Set of RW transactions that was active when this snapshot  was taken */  //活跃的读写事务id列表,从trx_sys->rw_trx_ids抄过来的  ids_t m_ids;/** The read should not see any transaction with trx id >= this  value. In other words, this is the "high water mark". */  //赋值是即将分配的下一个事务id,所以大于等于这个id的记录对当前事务来说都是不可见的  trx_id_t m_low_limit_id;/** The read should see all trx ids which are strictly  smaller (<) than this value.  In other words, this is the  low water mark". */  //m_ids不为空就是ids.get(0),为空则是m_low_limit_id,所以小于这个事务id的就代表着快照建立的时候  //已经不是活跃事务了,即已经提交了,所以一定可以看到这些事务的改动记录  trx_id_t m_up_limit_id;....  }

初始化赋值的时候

//read0read.cc//row_search_mvcc -> trx_assign_read_view -> MVCC::view_open ->void ReadView::prepare(trx_id_t id) {  ut_ad(trx_sys_mutex_own());  m_creator_trx_id = id;  m_low_limit_no = trx_get_serialisation_min_trx_no();  m_low_limit_id = trx_sys_get_next_trx_id_or_no();  ut_a(m_low_limit_no <= m_low_limit_id);  if (!trx_sys->rw_trx_ids.empty()) {    copy_trx_ids(trx_sys->rw_trx_ids);  } else {    m_ids.clear();  }/* The first active transaction has the smallest id. */  m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;  ut_a(m_up_limit_id <= m_low_limit_id);  ut_d(m_view_low_limit_no = m_low_limit_no);  m_closed = false;}

判断某个版本的记录是否可见?

//read0types.h bool changes_visible(trx_id_t id,                                   const table_name_t &name) const {  ut_ad(id > 0);//如果当前版本记录上的事务id(DB_TRX_ID)小于低水位或者等于当前事务,//那么要么就是自己更改的,要么就是历史上已经提交了的,所以可以读到  if (id < m_up_limit_id || id == m_creator_trx_id) {    return (true);  }  check_trx_id_sanity(id, name);//如果当前版本记录上的事务id(DB_TRX_ID)大于高水位,那么就是在当前快照生成后生成的事务,一律看不到  if (id >= m_low_limit_id) {    return (false);  //这一步我没有理解,  } else if (m_ids.empty()) {    return (true);  }  const ids_t::value_type *p = m_ids.data();//二分查找,如果活跃的事务里面没有,那么就返回true//这里我是这么理解的,[低水位,高水位]包含活水和死水,即活跃的事务和已经提交的事务//假如存在事务1是活跃的,事物2是已提交的,事务3是活跃的,我们在事务4的时候开启快照,很明显我们只能读到事务2或者事务4的变更//假如正在判断的是事务2,因为已经经过了上面的校验,//所以我们知道当前版本记录的事务m_low_limit_id(高水位)>id>=m_up_limit_id(低水位),且不是当前事务;//所以就需要判断事务只要不是活跃的,那么就一定是已经提交的事务,那么就可读  return (!std::binary_search(p, p + m_ids.size(), id));}
在了解可见性规则之后我们知道,快照建立的时候会初始化几个属性,在查询的时候会通过changes_visible方法来判断是否可见,而调用这个方法的上层就是下面这两段逻辑,和我先前描述的类似,判断是否可以读当前版本的记录,可以就返回,不行就继续比较再上一个版本,直到最老的版本
//row0sel.cc#row_search_mvccif (srv_force_recovery < 5 &&    !lock_clust_rec_cons_read_sees(rec, index, offsets,                                   trx_get_read_view(trx))) {  rec_t *old_vers;  /* The following call returns "offsets" associated with "old_vers" */  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());  if (err != DB_SUCCESS) {    goto lock_wait_or_error;  }  if (old_vers == nullptr) {    /* The row did not exist yet in    the read view */    goto next_rec;  }  rec = old_vers;  prev_rec = rec;  ut_d(prev_rec_debug = row_search_debug_copy_rec_order_prefix(           pcur, index, prev_rec, &prev_rec_debug_n_fields,           &prev_rec_debug_buf, &prev_rec_debug_buf_size));}//lock0lock.cc#lock_clust_rec_cons_read_seesbool 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 */{  ut_ad(index->is_clustered());  ut_ad(page_rec_is_user_rec(rec));  ut_ad(rec_offs_validate(rec, index, offsets));  /* Temp-tables are not shared across connections and multiple  transactions from different connections cannot simultaneously  operate on same temp-table and so read of temp-table is  always consistent read. */  if (srv_read_only_mode || index->table->is_temporary()) {    ut_ad(view == nullptr || index->table->is_temporary());    return (true);  }  /* NOTE that we call this function while holding the search  system latch. */  trx_id_t trx_id = row_get_rec_trx_id(rec, index, offsets);  return (view->changes_visible(trx_id, index->table->name));}

事务的trx_id

在我还没开始看mysql源码,只是跟着博客学习写用例测试的时候,我发现,开启事务进行了第一次查询之后,确实有生成事务id,但后面我执行了一条更新语句之后,原来的事务id变了;就像下面这个图一样,最开始只有查询的时候是比较长的这个id,但执行了一条update语句后,事务id变成了一个短的。这个时候我就产生了很多疑问?同一个事务里,事务id怎么还能变呢?变的话changes_visible里面的比较怎么算?搜索了一下之后了解到,只读事务是不会生成事务id的,是假的!于是我又疑惑,那这个假id怎么参与changes_visible呢?也就是这个时候,我才下定决心去看源码,也借此理解了高低水位的设计,并认识到自己之前的理解是错误的。先上结论只读事务不会分配真正的事务id,他的值是0;只读事务参与change_visable的时候,create_trx_id也确实是0,是通过m_up_limit_id(低水位)来判断是否可见的,只有在变成读写事务时,create_trx_id才会起效并应用;因为值是0,所以在通过下面sql查询的时候,那串id只是展示的时候特殊处理的
select * from information_schema.INNODB_TRX;
//trx0trx.cc#trx_start_low //这里可以看到只有读写事务才真正分配了id else {trx->id = 0;if (!trx_is_autocommit_non_locking(trx)) {    /* If this is a read-only transaction that is writing    to a temporary table then it needs a transaction id    to write to the temporary table. */    if (read_write) {      trx_sys_mutex_enter();      ut_ad(!srv_read_only_mode);      trx->state.store(TRX_STATE_ACTIVE, std::memory_order_relaxed);      trx->id = trx_sys_allocate_trx_id();      trx_sys->rw_trx_ids.push_back(trx->id);      trx_sys_mutex_exit();      trx_sys_rw_trx_add(trx);    } else {      trx->state.store(TRX_STATE_ACTIVE, std::memory_order_relaxed);    }  } else {    ut_ad(!read_write);    trx->state.store(TRX_STATE_ACTIVE, std::memory_order_relaxed);  }}//trx0trx.ic//这里是在展示的时候对只读事务的id做了处理/** Retreieves the transaction ID.In a given point in time it is guaranteed that IDs of the runningtransactions are unique. The values returned by this function for readonlytransactions may be reused, so a subsequent RO transaction may get the same IDas a RO transaction that existed in the past. The values returned by thisfunction should be used for printing purposes only.@param[in]      trx     transaction whose id to retrieve@return transaction id */static inline trx_id_t trx_get_id_for_print(const trx_t *trx) {  /* Readonly and transactions whose intentions are unknown (whether  they will eventually do a WRITE) don"t have trx_t::id assigned (it is  0 for those transactions). Transaction IDs in  information_schema.innodb_trx.trx_id,  performance_schema.data_locks.engine_transaction_id,  performance_schema.data_lock_waits.requesting_engine_transaction_id,  performance_schema.data_lock_waits.blocking_engine_transaction_id  should match because those tables  could be used in an SQL JOIN on those columns. Also trx_t::id is  printed by SHOW ENGINE INNODB STATUS, and in logs, so we must have the  same value printed everywhere consistently. */  /* DATA_TRX_ID_LEN is the storage size in bytes. */  static const trx_id_t max_trx_id = (1ULL << (DATA_TRX_ID_LEN * CHAR_BIT)) - 1;  ut_ad(trx->id <= max_trx_id);  /* on some 32bit architectures casting trx_t* (4 bytes) directly to  trx_id_t (8 bytes unsigned) does sign extension and the resulting value  has highest 32 bits set to 1, so the number is unnecessarily huge.  Also there is no guarantee that we will obtain the same integer each time.  Casting to uintptr_t first, and then extending to 64 bits keeps the highest  bits clean. */  return (trx->id != 0              ? trx->id              : trx_id_t{reinterpret_cast(trx)} | (max_trx_id + 1));}

生成快照时机(不太确定)

可重复读:只生成一次,后面继续使用
ReadView *trx_assign_read_view(trx_t *trx) /*!< in/out: active transaction */{  ut_ad(trx_can_be_handled_by_current_thread_or_is_hp_victim(trx));  ut_ad(trx->state.load(std::memory_order_relaxed) == 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);}
读已提交:用完就关,所以每次再获取就得新开,但是这里的关有两个地方调
ha_innodb.cc#store_lock 和ha_innodb.cc#external_lockif (lock_type != TL_IGNORE && trx->n_mysql_tables_in_use == 0) {  trx->isolation_level =      innobase_trx_map_isolation_level(thd_get_trx_isolation(thd));  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);    mutex_exit(&trx_sys->mutex);  }}

在学习了解MVCC机制中遇到的问题:

  1. 为什么更新操作必须使用当前读?
更新操作后的如果不回滚那没有事,如果要回滚,应该回滚到最新一次的提交,所以undo log里必须是最新的视图
  1. 只读事务突然更新的话,因为更新必须使用当前读,那是否需要重新生成事务id?
不算做重新,是只有在触发锁操作时会分配真正的事务id,只读事务分配的id其实就是0
  1. 只读事务分配的事务id是什么东西?如何参与运作?
没有作为判断条件,作为判断条件的是up_limit记录的是最小活跃id,小于他的才能读,正规的事务id分配的在readview结构里其实是creator_trxid,用来判断当前是否能被当前事务看见
  1. readview的范围
有这个疑问还是因为最开始对change_visable掌握的不够清晰。之前想的场景是在事务A里,如果存在两条不同记录甚至不同表的查询,而事务B在第事务A两条查询中间的时候对第二条查询的记录做了更改并提交,那应该查到的是新的还是旧的。其实应该是旧的,因为就算是第二条查询,最新版本的改动的事务id会大于等于事务A的高水位,因此只能查询到更老的undolog里的记录
  1. 知道了mvcc底层是undolog和readview后,怎么理解“版本”这个概念
创建版本肯定就是字段里那个隐藏字段,删除版本应该是回滚指针
  1. 在只读视图能查到其他事务已经删除并且提交的记录吗?
经测试是可以的

怎么解决的幻读?

在只读事务下,如上文所说的事务1读不到事务2的更新是因为事务2的版本号要大于当前快照的高水位,那对于新增的记录来说,其版本号也是同样的道理,因此事务1读不到比当前快照里的高水位高的,也就避免了幻读这种情况。

参考资料:

MySQL 8.0 MVCC 源码解析 - 掘金https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.htmlMySQL事务ID的分配时机_mysql事务id什么时候分配_哲学长的博客-CSDN博客MYSQL innodb中的只读事物以及事物id的分配方式_ITPUB博客Mysql如何实现隔离级别 - 可重复读和读提交 源码分析_mysql 可重复度源码_择维士的博客-CSDN博客

关键词: