偶然间看到同事在看MySQL的MVCC问题,就随口插了一句,“MVCC模式解决了MySQL中的幻读问题”,但是

同事告诉我并没有,我说不可能,我清楚的记得在看《高性能MySQL 第3版》一书的事务隔离级别中的,在介绍**REPEATABLE READ **隔离级别时候提过,MVCC解决了幻读的问题。说完我还找到了书中内容给他看,内容如下:

英文原版:

  • REPEATABLE READ

REPEATABLE READ solves the problems that READ UNCOMMITTED allows. It guarantees
that any rows a transaction reads will “look the same” in subsequent reads within
the same transaction, but in theory it still allows another tricky problem: phantom
reads. Simply put, a phantom read can happen when you select some range of rows,
another transaction inserts a new row into the range, and then you select the same
range again; you will then see the new “phantom” row. InnoDB and XtraDB solve
the phantom read problem with multiversion concurrency control, which we explain later in this chapter.
REPEATABLE READ is MySQL’s default transaction isolation level.

中文版:

  • REPEATABLE READ(可重复读)

    REPEATABLE READ解决了脏读的问题。该级别保证了在同一个事务中多次读取同样记录的结果是一致的。但是理论上,可重复读隔离级别还是无法解决另外一个幻读( Phantom read)的问题。所谓幻读,指的是当某个事务在读取某个范围内的记录时另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行( Phantom row)。 InnoDB和 XtraDB存储引擎通过多版本并发控制(MVCC, Multiversion Concurrency Control)解决了幻读的问题。本章稍后会做进一步的讨论。

    可重复读是 MySQL的默认事务隔离级别。

同事说这也是他正在查找的问题,并给我演示了一个案例,相关表结构数据如下:

CREATE TABLE `employee` (
  `Id` int(11) NOT NULL AUTO_INCREMENT,
  `Name` varchar(50) DEFAULT NULL,
  `Salary` int(11) DEFAULT NULL,
  `DepartmentId` int(11) DEFAULT NULL,
  PRIMARY KEY (`Id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

insert  into `employee`(`Id`,`Name`,`Salary`,`DepartmentId`) values (1,'Joe',70000,1);
insert  into `employee`(`Id`,`Name`,`Salary`,`DepartmentId`) values (2,'Henry',80000,2);
insert  into `employee`(`Id`,`Name`,`Salary`,`DepartmentId`) values (3,'Sam',6000,2);
insert  into `employee`(`Id`,`Name`,`Salary`,`DepartmentId`) values (4,'Max',90000,1);

事务执行流程如下:

MySQL幻读的情景演示

  • 1、开始事务A

  • 2、查询employee表中所有数据

    开始事务A

  • 3、开始事务B

  • 4、向employee表中插入2条数据

  • 5、提交事务B

    执行事务B

  • 6、在事务A中执行查询

    事务B提交后再次在事务A中查询

  从这里我们可以看出MySQL的默认事务隔离级别(REPEATABLE READ)解决不可重复读的问题,但是对于幻读我们继续验证。

  • 7、更新employee表中的所有数据, 将DepartmentId都设置为1

  • 8、更新操作执行后再次查询

    更新后再次查询

此时我们发现,在A事务中竟然能够查看到B事务插入的2条数据,我们再来回顾一下脏读、不可重复读和幻读的定义及现象:

以下内容均来自MySQL官网:https://dev.mysql.com/doc/refman/5.7/en/

脏读:

  • dirty read

    An operation that retrieves unreliable data, data that was updated by another transaction but not yet committed. It is only possible with the isolation level known as read uncommitted.

脏读意味着一个事务读取了另一个事务未提交的数据,而这个数据是有可能回滚。

不可重复读:

  • non-repeatable read

    The situation when a query retrieves data, and a later query within the same transaction retrieves what should be the same data, but the queries return different results (changed by another transaction committing in the meantime).

不可重复读意味着在同一个事务中,不同时间的相同查询的返回结果不一致,由于另外已提交的事务修改了数据。

幻读:

  • phantom

    A row that appears in the result set of a query, but not in the result set of an earlier query. For example, if a query is run twice within a transaction, and in the meantime, another transaction commits after inserting a new row or updating a row so that it matches the WHERE clause of the query.

幻读意味着在同一个事务中,相同的查询在最新的结果集中出现了新的行数据。

总结:根据上面的实验案例以及幻读的定义,上述案例确实存在幻读的问题,接下来我们简单介绍一下MVCC的实现原理,并结合MySQL源码分析一下幻读产生的原因。

什么是MVCC?

为什么会出现幻读的问题?在解释这个问题之前,我们先简单介绍一下MySQL中MVCC。

MVCC简介:

引用:[阿里数据库内核’2017/12’月报]

多版本并发控制(Multiversion concurrency control, MCC 或 MVCC)指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

下面两篇文章对MySQL的InnoDB的MVCC实现进行详细介绍:

为什么会出现幻读?

在了解了MVCC的原理之后我们再来看一下源码

UNIV_INTERN
dberr_t
row_search_for_mysql(
/*=================*/
  byte*   buf,    /* 用来存放记录的空间地址 */
  ulint   mode,   /* InnoDB页扫描顺序 */
  row_prebuilt_t* prebuilt, /* InnoDB扫描需要的所有信息都包含在这个结构体,比如表以及Index等信息 */
  ulint   match_mode, /* 对于Index的匹配模式,是精确匹配还是前缀索引匹配 */
  ulint   direction)  /* 指定扫描顺序,正序还是倒叙扫描 */
{
	...
	/* 从这里我们看出开始一个新事务,并非是从执行BEGIN语句位置开始,而是从其后开始执行的第一条语句开始分配事务ID */
	trx_start_if_not_started(trx, ((trx->mysql_thd
          && thd_is_select(trx->mysql_thd)
          ) || srv_read_only_mode) ? FALSE : TRUE); 

	...
	// 如果是SQL语句第一次开始执行,需要考虑对TABLE增加意向所

	 if (!prebuilt->sql_stat_start) {
	 // 这里标记SQL语句已经开始执行,处理一条SQL语句循环扫描记录的过程
    /* No need to set an intention lock or assign a read view */

    if (UNIV_UNLIKELY
        (trx->read_view == NULL 
         && prebuilt->select_lock_type == LOCK_NONE)) {
      ...
	    }
  } else if (prebuilt->select_lock_type == LOCK_NONE) {
	...
  } else {
	// 这里开始非INSERT的DML操作,因为DML会对记录增加记录排他锁。具体需要增加什么类型的锁,可以参考https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
 wait_table_again:
	// 这里要对TABLE加意向锁
    err = lock_table(0, index->table,
         prebuilt->select_lock_type == LOCK_S
         ? LOCK_IS : LOCK_IX, thr);

    if (err != DB_SUCCESS) {

      table_lock_waited = TRUE;
      goto lock_table_wait;
    }    
    prebuilt->sql_stat_start = FALSE;
  }

	...
  if (prebuilt->select_lock_type != LOCK_NONE) {
	 ulint lock_type;

    if (!set_also_gap_locks
        || srv_locks_unsafe_for_binlog
        || trx->isolation_level <= TRX_ISO_READ_COMMITTED
        || (unique_search && !rec_get_deleted_flag(rec, comp))) {
	  // 这里对于READ_UNCOMMITTED以及READ_COMMITTED,或者唯一键扫描不需要使用gap锁
      goto no_gap_lock;
    } else {
      lock_type = LOCK_ORDINARY;
    }
	
	/* If we are doing a 'greater or equal than a primary key
    value' search from a clustered index, and we find a record
    that has that exact primary key value, then there is no need
    to lock the gap before the record, because no insert in the
    gap can be in our search range. That is, no phantom row can
    appear that way.

    An example: if col1 is the primary key, the search is WHERE
    col1 >= 100, and we find a record where col1 = 100, then no
    need to lock the gap before that record. */

    if (index == clust_index
        && mode == PAGE_CUR_GE
        && direction == 0
        && dtuple_get_n_fields_cmp(search_tuple)
        == dict_index_get_n_unique(index)
        && 0 == cmp_dtuple_rec(search_tuple, rec, offsets)) {
no_gap_lock:
      lock_type = LOCK_REC_NOT_GAP;
    }

	    err = sel_set_rec_lock(btr_pcur_get_block(pcur),
               rec, index, offsets,
               prebuilt->select_lock_type,
               lock_type, thr);

    switch (err) {
      const rec_t*  old_vers;
    case DB_SUCCESS_LOCKED_REC:
      if (srv_locks_unsafe_for_binlog
          || trx->isolation_level
          <= TRX_ISO_READ_COMMITTED) {
        /* Note that a record of
        prebuilt->index was locked. */
        prebuilt->new_rec_locks = 1;
      }
      err = DB_SUCCESS;
    case DB_SUCCESS:
	 // 加锁成功后就认为记录可见了,并未像SELECT语句一样根据事务开始的READ_VIEW进行可见性判断。所以对于DML来说,所有提交的事务都是可见的。
      break;
    case DB_LOCK_WAIT:
	      /* Never unlock rows that were part of a conflict. */
	  // 如果存在锁冲突,也就是其他事务正在更新同一行
      prebuilt->new_rec_locks = 0;

      if (UNIV_LIKELY(prebuilt->row_read_type
          != ROW_READ_TRY_SEMI_CONSISTENT)
          || unique_search
          || index != clust_index) {

        goto lock_wait_or_error;
      }

      /* The following call returns 'offsets'
      associated with 'old_vers' */
	  // 这里需要查看是否有别的事务提交了,以便获取最新版本的记录
      row_sel_build_committed_vers_for_mysql(
        clust_index, prebuilt, rec,
        &offsets, &heap, &old_vers, &mtr);

      /* Check whether it was a deadlock or not, if not
      a deadlock and the transaction had to wait then
      release the lock it is waiting on. */
	        err = lock_trx_handle_wait(trx);

      switch (err) {
      case DB_SUCCESS:
        /* The lock was granted while we were
        searching for the last committed version.
        Do a normal locking read. */

        offsets = rec_get_offsets(
          rec, index, offsets, ULINT_UNDEFINED,
          &heap);
        goto locks_ok;
      case DB_DEADLOCK:
        goto lock_wait_or_error;
      case DB_LOCK_WAIT:
        err = DB_SUCCESS;
        break;
      default:
        ut_error;
      }
	        if (old_vers == NULL) {
        /* The row was not yet committed */

        goto next_rec;
      }
	  did_semi_consistent_read = TRUE;
      rec = old_vers;
      break;
    default:

      goto lock_wait_or_error;
    }

	}

从上面的代码我们可以看到,对于UPDATE操作更新的记录包含幻读读取到的已提交事务的最新记录。那么接下来看为什么UPDATE之后的SELECT语句对于UPDATE之后的所有语句都可见了? 原因是前面的UPDATE语句执行之后,会将当前记录上存储的事务信息更新为当前的事务,而当前事务所做的任何更新,对本事务所有SELECT查询都变的可见,因此最后输出的结果是UPDATE执行后更新的所有记录。

总结

从上我们可以知道,MySQL的**REPEATABLE READ **隔离级别和MVCC只解决了部分幻读的问题,意味着这一隔离级别仍旧可能出现幻读。该问题的发现同时也提醒着以后不能死读书,应该边读边想多实践,把书中的知识转换为我们自己的想法,而不是生搬硬套。

引用参考

MySQL官网

MySQL · 源码分析 · InnoDB Repeatable Read隔离级别之大不同

[阿里数据库内核’2017/12’月报]

[数据库内核月报 2017 / 06]