InnoDB支持MVCC来提高系统读写并发性能。InnoDB MVCC的实现基于Undo log,通过回滚段来构建需要的版本记录。通过ReadView来判断哪些版本的数据可见。同时Purge线程是通过ReadView来清理旧版本数据。MVCC的相关知识在过去的月报中已有涉及,这里笔者从部分相关实现的角度做一个学习与分享。代码基于MySQL8.0。

之前月报涉及相关知识的有:

MySQL · 引擎特性 · InnoDB undo log 漫游

MySQL · 引擎特性 · InnoDB 事务系统

MySQL · 引擎特性 · InnoDB 事务子系统介绍

Undo log

Undo log可以用来做事务的回滚操作,保证事务的原子性。同时可以用来构建数据修改之前的版本,支持多版本读。

InnoDB表数据组织方式是主键聚簇索引。二级索引通过索引键值加主键值组合来唯一确定一条记录。聚簇索引和二级索引都包含了DELETED BIT标记位来标识记录是否被删除,真正的删除在事务commit之后且没有读会引用该版本数据的时候。在聚簇索引上还有一些额外信息会存储,6字节的DB_TRX_ID字段,表示最近一次插入或者更新该记录的事务ID。7字节的DB_ROLL_PTR字段,指向该记录的rollback segment的undo log记录。6字节的DB_ROW_ID,当有新数据插入的时候会自动递增。当表上没有用户主键的时候,InnoDB会自动产生聚集索引,包含DB_ROW_ID字段。

对于聚簇索引,更新是在原记录位置更新,通过记录指向undo log的隐藏列来重构早期版本的数据。但对于二级索引,是没有聚簇索引上的这些隐藏列的,因此无法在原记录位置更新。当二级索引更新的时候,需要将原记录标记为删除,再插入新的数据记录。当快照读通过二级索引读取数据发现deleted标识或者更新的时候,如果二级索引页上无法判断可见性,InnoDB会查看聚簇索引上的记录行,通过行上的DB_TRX_ID判断可见性,找到正确的可见版本数据。

当用mvcc读取的时候(row_search_mvcc),对于聚簇索引,当拿到一条记录后,会先通过函数lock_clust_rec_cons_read_sees判断可见性,如果不可见会再构建老版本数据row_vers_build_for_consistent_read。

  1. dberr_t row_search_mvcc(byte *buf, page_cur_mode_t mode,
  2. row_prebuilt_t *prebuilt, ulint match_mode,
  3. ulint direction) {
  4. ...
  5. /* This is a non-locking consistent read: if necessary, fetch
  6. a previous version of the record */
  7. if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED) {
  8. /* Do nothing: we let a non-locking SELECT read the
  9. latest version of the record */
  10. } else if (index == clust_index) {
  11. /* Fetch a previous version of the row if the current
  12. one is not visible in the snapshot; if we have a very
  13. high force recovery level set, we try to avoid crashes
  14. by skipping this lookup */
  15. if (srv_force_recovery < 5 &&
  16. !lock_clust_rec_cons_read_sees(rec, index, offsets,
  17. trx_get_read_view(trx))) {
  18. rec_t *old_vers;
  19. /* The following call returns 'offsets' associated with 'old_vers' */
  20. err = row_sel_build_prev_vers_for_mysql(
  21. trx->read_view, clust_index, prebuilt, rec, &offsets, &heap,
  22. &old_vers, need_vrow ? &vrow : NULL, &mtr,
  23. prebuilt->get_lob_undo());
  24. }

对于二级索引,拿到记录会先调用lock_sec_rec_cons_read_sees判断page上记录的最近一次修改trx id是否小于m_up_limit_id,如果小于即该page上数据可见,否则即调用row_search_idx_cond_check检查可见性,对于ICP,索引条件下推的,可以先判断索引条件是否满足条件,这样避免不满足条件的行回表;对于满足条件的行则回表查看可见性。

  1. dberr_t row_search_mvcc(byte *buf, page_cur_mode_t mode,
  2. row_prebuilt_t *prebuilt, ulint match_mode,
  3. ulint direction) {
  4. ...
  5. /* This is a non-locking consistent read: if necessary, fetch
  6. a previous version of the record */
  7. if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED) {
  8. /* Do nothing: we let a non-locking SELECT read the
  9. latest version of the record */
  10. } else if (index == clust_index) {
  11. ...
  12. } else {
  13. /* We are looking into a non-clustered index,
  14. and to get the right version of the record we
  15. have to look also into the clustered index: this
  16. is necessary, because we can only get the undo
  17. information via the clustered index record. */
  18. ut_ad(!index->is_clustered());
  19. if (!srv_read_only_mode &&
  20. !lock_sec_rec_cons_read_sees(rec, index, trx->read_view)) {
  21. /* We should look at the clustered index.
  22. However, as this is a non-locking read,
  23. we can skip the clustered index lookup if
  24. the condition does not match the secondary
  25. index entry. */
  26. switch (row_search_idx_cond_check(buf, prebuilt, rec, offsets)) {
  27. case ICP_NO_MATCH:
  28. goto next_rec;
  29. case ICP_OUT_OF_RANGE:
  30. err = DB_RECORD_NOT_FOUND;
  31. goto idx_cond_failed;
  32. case ICP_MATCH:
  33. goto requires_clust_rec;
  34. }
  35. ...
  36. }
  37. bool lock_sec_rec_cons_read_sees(
  38. const rec_t *rec, /*!< in: user record which
  39. should be read or passed over
  40. by a read cursor */
  41. const dict_index_t *index, /*!< in: index */
  42. const ReadView *view) /*!< in: consistent read view */
  43. {
  44. ...
  45. trx_id_t max_trx_id = page_get_max_trx_id(page_align(rec));
  46. ut_ad(max_trx_id > 0);
  47. return (view->sees(max_trx_id));
  48. }

在Undo log中会记录TRX_UNDO_TRX_ID事务ID和TRX_UNDO_TRX_NO事务Commit时的number值。其他的信息可以参考MySQL · 引擎特性 · InnoDB undo log 漫游

当事务为读写事务的时候,事务会获取trx_id。

  1. /** Allocates a new transaction id.
  2. @return new, allocated trx id */
  3. UNIV_INLINE
  4. trx_id_t trx_sys_get_new_trx_id() {
  5. ut_ad(trx_sys_mutex_own());
  6. /* VERY important: after the database is started, max_trx_id value is
  7. divisible by TRX_SYS_TRX_ID_WRITE_MARGIN, and the following if
  8. will evaluate to TRUE when this function is first time called,
  9. and the value for trx id will be written to disk-based header!
  10. Thus trx id values will not overlap when the database is
  11. repeatedly started! */
  12. if (!(trx_sys->max_trx_id % TRX_SYS_TRX_ID_WRITE_MARGIN)) {
  13. trx_sys_flush_max_trx_id();
  14. }
  15. return (trx_sys->max_trx_id++);
  16. }

当事务commit时会获取新的系统trx id作为trx_no。

  1. trx_commit_low->trx_write_serialisation_history->trx_serialisation_number_get
  2. /** Set the transaction serialisation number.
  3. @return true if the transaction number was added to the serialisation_list. */
  4. static bool trx_serialisation_number_get(
  5. trx_t *trx, /*!< in/out: transaction */
  6. trx_undo_ptr_t *redo_rseg_undo_ptr, /*!< in/out: Set trx
  7. serialisation number in
  8. referred undo rseg. */
  9. trx_undo_ptr_t *temp_rseg_undo_ptr) /*!< in/out: Set trx
  10. serialisation number in
  11. referred undo rseg. */
  12. {
  13. ...
  14. trx->no = trx_sys_get_new_trx_id();
  15. ...
  16. }

由于Undo log会保留直到事务提交同时没有其他快照读引用后才会purge。所以需要尽量避免长语句或长事务的执行,避免因此导致的undo堆积或者undo链太长使读取变慢。

Read View

ReadView主要结构

  • m_low_limit_id。 事务ID大于等于该值的数据修改不可见

  • m_up_limit_id. 事务ID小于该值的数据修改可见。

  • m_creator_trx_id。创建该ReadView的事务,该事务ID的数据修改可见。

  • m_ids。当快照创建时的活跃读写事务列表。

  • m_low_limit_no。事务number,上一节介绍Undo log时候,事务提交时候获取同时写入Undo log中的值。事务number小于该值的对该ReadView不可见。利用该信息可以Purge不需要的Undo。

  • m_closed。 标记该ReadView closed,用于优化减少trx_sys->mutex这把大锁的使用。

    可以看到在view_close的时候如果是在不持有trx_sys->mutex锁的情况下,会仅将ReadView标记为closed,并不会把ReadView从m_views的list中移除。

    1. void MVCC::view_close(ReadView *&view, bool own_mutex) {
    2. uintptr_t p = reinterpret_cast<uintptr_t>(view);
    3. /* Note: The assumption here is that AC-NL-RO transactions will
    4. call this function with own_mutex == false. */
    5. if (!own_mutex) {
    6. /* Sanitise the pointer first. */
    7. ReadView *ptr = reinterpret_cast<ReadView *>(p & ~1);
    8. /* Note this can be called for a read view that
    9. was already closed. */
    10. ptr->m_closed = true;
    11. /* Set the view as closed. */
    12. view = reinterpret_cast<ReadView *>(p | 0x1);
    13. } else {
    14. view = reinterpret_cast<ReadView *>(p & ~1);
    15. view->close();
    16. UT_LIST_REMOVE(m_views, view);
    17. UT_LIST_ADD_LAST(m_free, view);
    18. ut_ad(validate());
    19. view = NULL;
    20. }
    21. }

    当再次调用view_open的时候,如果trx上的read view在产生之后没有新的读写事务发生就可以不用生成新的ReadView,避免加锁添加到m_views中的操作。

    1. void MVCC::view_open(ReadView *&view, trx_t *trx) {
    2. ...
    3. if (view != NULL) {
    4. if (trx_is_autocommit_non_locking(trx) && view->empty()) {
    5. view->m_closed = false;
    6. if (view->m_low_limit_id == trx_sys_get_max_trx_id()) {
    7. return;
    8. } else {
    9. view->m_closed = true;
    10. }
    11. }
    12. }
    13. ...
    14. }
    • m_view_list 用于MVCC链表中前后节点信息存储。

ReadView可见性判断:

  • 如果记录trx_id小于m_up_limit_id或者等于m_creator_trx_id,表明ReadView创建的时候该事务已经提交,记录可见。

  • 如果记录的trx_id大于等于m_low_limit_id,表明事务是在ReadView创建后开启的,其修改,插入的记录不可见。

  • 当trx_id在m_up_limit_id和m_low_limit_id之间的时候,如果id在m_ids数组中,表明ReadView创建时候,事务处于活跃状态,因此记录不可见。

  1. bool changes_visible(trx_id_t id, const table_name_t &name) const
  2. MY_ATTRIBUTE((warn_unused_result)) {
  3. ut_ad(id > 0);
  4. if (id < m_up_limit_id || id == m_creator_trx_id) {
  5. return (true);
  6. }
  7. check_trx_id_sanity(id, name);
  8. if (id >= m_low_limit_id) {
  9. return (false);
  10. } else if (m_ids.empty()) {
  11. return (true);
  12. }
  13. const ids_t::value_type *p = m_ids.data();
  14. return (!std::binary_search(p, p + m_ids.size(), id));
  15. }

Class MVCC封装了ReadView相关的访问。内部成员变量有 m_free存放释放的read view用来reuse避免重新构造。m_views存放active和closed状态的read view。该类提供的主要函数有

  • clone_oldest_view(ReadView *view) 考虑最老的ReadView,用于purge线程清理deleted数据和不需要的旧版本数据。

    1. trx_purge(
    2. {
    3. trx_sys->mvcc->clone_oldest_view(&purge_sys->view);
    4. }
  • set_view_creator_trx_id(ReadView *view, trx_id_t id); 设置read view的creator trx id。

  • size() 处于活跃状态的read view数目

  • view_open(ReadView *&view, trx_t *trx); 创建read view。 view属于trx.

  • view_close(ReadView *&view, bool own_mutex); close read view。当own_mutext为false的时候,设置view为closed不去从m_views中移除。

  • view_release(ReadView *&view); release非活跃事务

  • is_view_active(ReadView *view) read view是否活跃

Semi consistent read

对于RC隔离级别或者设置innodb_locks_unsafe_for_binlog的情况下,当发生表扫描的UPDATE语句,如果数据行上有锁,UPDATE会先查看最近一次提交的数据是否满足条件,利用undo构建最近一次提交的数据。当满足条件再去读最新修改的行,这一次再等锁加锁,避免锁的等待。

  1. row_search_mvcc()
  2. {
  3. case DB_LOCK_WAIT:
  4. /* Lock wait for R-tree should already
  5. be handled in sel_set_rtr_rec_lock() */
  6. ut_ad(!dict_index_is_spatial(index));
  7. /* Never unlock rows that were part of a conflict. */
  8. std::fill_n(prebuilt->new_rec_lock, row_prebuilt_t::LOCK_COUNT, false);
  9. if (UNIV_LIKELY(prebuilt->row_read_type !=
  10. ROW_READ_TRY_SEMI_CONSISTENT) ||
  11. unique_search || index != clust_index) {
  12. goto lock_wait_or_error;
  13. }
  14. /* The following call returns 'offsets' associated with 'old_vers' */
  15. row_sel_build_committed_vers_for_mysql(clust_index, prebuilt, rec,
  16. &offsets, &heap, &old_vers,
  17. need_vrow ? &vrow : NULL, &mtr);
  18. }

这里当查询为unique_search并没有走semi consistent read,即对于’update t set … where pk = xx’的语句不会走semi consistent read。这里原因是bug#52663,在部分代码实现约束仅table scan的执行才可以。

同时Semi consistent read由于采用了非冲突串行化的处理方式,因此只能用在RC隔离级别或者设置innodb_locks_unsafe_for_binlog的情况下使用。

总结

InnoDB的多版本并不是直接存储多个版本的数据,而是所有更改操作利用行锁做并发控制,这样对某一行的更新操作是串行化的,然后用Undo log记录串行化的结果。当快照读的时候,利用Undo log重建需要读取版本的数据,从而实现读写并发。