InnoDB Multi-Versioning原理分析

1,180 阅读10分钟

一、mvcc简介

         这篇就聊下InnoDB的mvcc(多版本并发控制),mysql的一致读(可重复读)就是通过mvcc进行实现,下面也会通过源码层面介绍事务读取行数据如何保证可重复读,mvcc如何对二级索引进行处理。

         Innodb是一个多版本存储引擎:它保存已更改行的旧版本的信息,以支持事务性功能,如并发和回滚。这些信息存储在表空间中的数据结构称为回滚段(这跟Oracle方式类似)。InnoDB使用回滚段中的信息来执行事务回滚所需的撤消操作。它还使用回滚段信息来构建一个行的早期版本以实现一致性读取。

         在内部,InnoDB向数据库中存储的每一行添加三个字段。一个6字节的DB_TRX_ID字段表示插入或更新行的最后一个事务的事务标识符。此外,删除在内部被视为更新,其中行中的特殊位(delete_flag)被设置为已删除。每一行还包含一个名为roll pointer的7字节DB_ROLL_PTR字段。滚动指针指向写入回滚段的撤消日志记录。如果行已更新,则撤消日志记录将包含在更新该行之前重新生成该行内容所需的信息。一个6字节的DB_ROW_ID字段包含一个随着新行插入而单调增加的行ID。如果InnoDB自动生成聚集索引,则索引包含行ID值。否则,DB_ROW_ID列不会出现在任何索引中。 所以一张表的数据结构如下,只有在聚簇索引上才有真正的行数据

         回滚段中的撤销日志分为插入和更新撤销日志。insert undo log仅在事务回滚中需要,并且可以在事务提交后立即丢弃。update undo log也用于一致读取,但只有在不存在innodb为其分配快照的事务之后才能丢弃它们,在一致读取中可能需要update undo log中的信息来构建数据库行的早期版本。

         定期提交事务,包括那些只发出一致读取的事务。否则innodb无法丢弃update undo日志中的数据,回滚段可能会变得太大,填满您的表空间。

         回滚段中撤消日志记录的物理大小通常小于相应的插入或更新行。您可以使用此信息计算回滚段所需的空间。

         在innodb multi-version方案中,使用SQL语句删除行时,不会立即从数据库中删除该行。InnoDB在丢弃为删除而写的update undo log记录时,只物理删除对应的行及其索引记录。这个删除操作称为清除,它非常快速,通常与执行删除的SQL语句的时间顺序相同。

         如果在表中以几乎相同的速度批量插入和删除行,则清除线程可能会开始落后,并且由于所有“死”行,表可能会变得越来越大,使所有内容都绑定在磁盘上,速度非常慢。在这种情况下,限制新行操作,并通过调整innodb_max_purge_lag系统变量为清除线程分配更多资源。如果事务 rollback,innodb 通过执行 undo log 中的所有反向操作,实现事务中所有操作的回滚,随后就会删除该事务关联的所有 undo log 段。如果事务 commit,对于 insert undo log,innodb 会直接清除,但对于 update undo log,只有当前没有任何事务存在时,innodb 的 purge 线程才会清理这些 undo log 段。purge线程,他是一个周期运行的垃圾收集线程,主要用来收集 undo log 段,以及已经被废弃的索引 在事务提交时,innodb 会将所有需要清理的任务添加到 purge 队列中,可以通过 innodb_max_purge_lag 配置项设定purge 队列的大小 purge 线程会在周期执行时,对 purge 队列中的任务进行清理,innodb_max_purge_lag_delay 配置项说明了purge 线程的执行周期间隔,所以尽量缩短使用中每个事务的持续时间,可以让 purge 线程有更大概率回收已经没有存在必要的 undo log 段,从而尽量释放磁盘空间的占用。

         undo log的相关配置innodb 通过段的方式来管理 undo log,每一条记录占用一个 undo log segment,每 1024 个 undo log segment 被组织为一个回滚段(rollback segment) mysql 5.6 版本以后可以通过 innodb_undo_logs 配置项设置系统支持的最大回滚段个数,默认为 128。通过 innodb_undo_directory配置项可以设置undo log存储的目录通过 innodb_undo_tablespaces可以设置将undo log平均分配到多少个文件中,默认为 0,即全部写入同一个文件中

二、Multi-Versioning and Secondary Indexes

         InnoDB多版本并发控制(MVCC)对二级索引的处理不同于聚集索引。聚集索引中的记录会就地更新,其隐藏的系统列(DB_ROLL_PTR)指向撤消日志项,可以从中重建早期版本的记录。与聚集索引记录不同,二级索引记录不包含隐藏的系统列,也不会就地更新。

        更新二级索引列时,旧的二级索引记录将被删除标记,新记录将被插入,并最终清除已删除标记的记录。当二级索引记录被删除标记或二级索引页被更新在事务时,InnoDB会在聚集索引中查找数据库记录。在聚集索引中,检查记录的DB_TRX_ID,如果在读取事务启动后修改了记录,则从撤消日志中检索记录的正确版本。 

        如果二级索引记录被标记为删除,或者二级索引页被更新的事务更新,则不使用覆盖索引技术。InnoDB没有从索引结构返回值,而是在聚集索引中查找记录。 

        但是,如果启用了索引条件下推(ICP)优化,并且部分WHERE条件仅使用索引中的字段进行计算,那么MySQL服务器仍然会将WHERE条件的这一部分向下推送到存储引擎,在存储引擎中使用索引对其进行评估。如果找不到匹配的记录,将避免聚集索引查找。如果找到匹配的记录,即使在删除标记的记录中,InnoDB也会在聚集索引中查找该记录。  

        二级索引由于没有三个隐藏列(DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID)如何实现一致读,可重复读?①更新二级索引列时,旧的二级索引记录将被删除标记,新记录将被插入②根据二级索引进行查询,如何保证可重复读,由于二级索引不维护版本信息,无法判读二级索引中记录的可见性,还是需要回表到聚簇索引,根据二级索引上叶子节点主键值去聚簇索引中查找记录(使用mvcc规则),如果查出来的结果跟二级索引里维护的字段值结果相同直接返回,否则丢弃。③旧的二级索引记录被打上删除标记何时删除,由于更新二级索引列时,旧的二级索引记录将被删除标记,新记录将被插入,目前个人理解,还未实际验证过,当undo log中的历史记录被删除时,会回收被打上删除标记二级索引记录。

三、mvcc如何保证事务可重复读,一致读

          由于InnoDB向数据库中存储的每一行添加三个字段(DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID),一行数据是否对一个事务可见,使用行上的隐藏列DB_TRX_ID和事务的read view进行比较,首先我们先理解下read view是什么、如何创建、何时创建,Read view主要是来辅助事务进行对行的可见性判断的。作用是判断当前事务可以看到哪些快照。内部重要的参数如下Read View是在一个事务开启时,当前所有活跃事务的一个集合,Read View数据结构中存储了当前活跃的最大、最小的事务ID。接下来看下mysql源码对此Read View数据结构的定义

/** Read view列出一些事务的trx id,和此Read view关联的事务一致读取不应该看到这些对数据库的修改. */
struct read_view_struct{
	ulint		type;	/*!< VIEW_NORMAL, VIEW_HIGH_GRANULARITY */
	undo_no_t	undo_no;/*!< 0 or if type is
				VIEW_HIGH_GRANULARITY
				transaction undo_no when this high-granularity
				consistent read view was created */
	trx_id_t	low_limit_no;
				/*!< The view does not need to see the undo
				logs for transactions whose transaction number
				is strictly smaller (<) than this value: they
				can be removed in purge if not needed by other
				views */
	trx_id_t	low_limit_id;
				/*!< The read should not see any transaction
				with trx id >= this value. In other words,
				this is the "high water mark". */
	trx_id_t	up_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". */
	ulint		n_trx_ids;
				/*!< Number of cells in the trx_ids array */
	trx_id_t*	trx_ids;/*!< Additional trx ids which the read should
				not see: typically, these are the active
				transactions at the time when the read is
				serialized, except the reading transaction
				itself; the trx ids in this array are in a
				descending order. These trx_ids should be
				between the "low" and "high" water marks,
				that is, up_limit_id and low_limit_id. */
	trx_id_t	creator_trx_id;
				/*!< trx id of creating transaction, or
				0 used in purge */
	UT_LIST_NODE_T(read_view_t) view_list;
				/*!< List of read views in trx_sys */
};
  • type

  • VIEW_NORMAL, VIEW_HIGH_GRANULARITY,没理解这个字段的含义,有知道的大佬麻烦告知下

  •  undo_no

  • 0,或者如果类型为VIEW_HIGH_GRANULARITY transaction undo_no,则创建此高粒度一致性读取视图时 ,undo日志号,因为HIGH GRANULARITY限制下读操作看不到read_view_t创建后其他事务的改动。

  • low_limit_no

  • 对于undo log segment中的事务号严格小于(<)这个值的,当前视图不需要再查看的撤消日志:如果其他视图也不需要,可以在清除中删除它们,即提交事务值早于此值的事务undo log segment,可以被purge线程回收

  • low_limit_id

  • 表示创建read view时,当前事务活跃read view链表最大的事务ID,即最近创建的除自身外最大的事务ID,当前事务读取不应看到任何事务trx id>=此值的update或insert的行数据。换句话说,这就是“高水位线”

  • up_limit_id

  • 表示创建read view时,当前事务活跃read view链表最小的事务ID,当前事务读取应该看到严格小于(<)这个值的所有trx id。换句话说,这就是“低水位线”  

  • n_trx_ids

  • 表示当前事务开始时活跃事务数量,trx_ids的大小,即当前事务开始时活跃事务个数

  • trx_ids

  • 数组,表示当前事务开始时存在的其他活跃事务,降序排序,即活跃的最大事务ID在low_limit_id, 最小的活跃事务在up_limit_id

  • creator_trx_id

  • 当前事务ID,和此read view关联的事务ID,或0清除中使用

  • view_list

  • trx_sys里的read view链表,即所有活跃事务的read view链表

假设mysql当前全局事务链表(trx_sys)有6个活跃事务,根据事务ID降序存放,因此最小的事务ID在尾端: cr_trx => trx9 -> trx7 -> trx6 -> trx5 -> trx3 -> trx2 在这个例子里,cr_trx为当前创建read_view_t的事务,其状态大致为: read_view -> creator_trx_id = cr_trx; read_view -> up_limit_id = trx2; read_view -> low_limit_id = trx9; read_view -> trx_ids = [trx9, trx7, trx6, trx5, trx3, trx2]; read_view -> n_trx_ids = 6;

接下来我们看下mysql源码如何保证事务读取行数据可重复读,此源码是mysql5.6版本

UNIV_INLINE
bool
read_view_sees_trx_id(
/*==================*/
	const read_view_t*	view,	/*!< in: read view */
	trx_id_t		trx_id)	/*!< in: trx id */
{
	if (trx_id < view->up_limit_id) {

		return(true);
	} else if (trx_id >= view->low_limit_id) {

		return(false);
	} else {
		ulint	lower = 0;
		ulint	upper = view->n_trx_ids - 1;

		ut_a(view->n_trx_ids > 0);

		do {
			ulint		mid	= (lower + upper) >> 1;
			trx_id_t	mid_id	= view->trx_ids[mid];

			if (mid_id == trx_id) {
				return(FALSE);
			} else if (mid_id < trx_id) {
				if (mid > 0) {
					upper = mid - 1;
				} else {
					break;
				}
			} else {
				lower = mid + 1;
			}
		} while (lower <= upper);
	}

	return(true);

      行数据对事务的可见性规则

  •  行记录的DATA_TRX_ID < view->up_limit_id:在创建当前事务的read view时,修改该行记录的事务已提交,该记录对当前事务可见
  •  行记录的DATA_TRX_ID >= view->low_limit_id:当前事务启动后该记录被后来启动的事务修改,该记录对当前事务不可见
  • 行记录的DATA_TRX_ID 位于(view->up_limit_id,view->low_limit_id):需要在活跃读写事务数组查找trx_id是否存在,如果存在,该记录被启动当前事务时,还活动的那些事务更改,记录对于当前事务是不可见的,如果不存在说明在创建当前事务的read view时,修改该行记录的事务已提交,该记录对当前事务可见。                      

四、不同隔离级别ReadView实现方式 

  • 读未提交(Read Uncommitted) 

  • 读最新的行数据,不管这条记录是不是已提交。不会遍历版本链,少了查找可见的版本的步骤。会导致脏读。

  • 读已提交(Read Committed)

  • MySQL的读已提交实际是语句级别快照。 与可重复读级别不同:获得ReadView的时机。每个语句开始执行时,获得ReadView,可见性判断是基于语句级别的ReadView。

  • 可重复读(Repeatable Read)可重复读是MySQL默认的隔离级别,可以称作快照(Snapshot)隔离级别。在事务开始时创建一个ReadView,当读一条记录时,会遍历版本链表,通过当前行数据的事务与ReadView判断可见性,找到第一个对当前事务可见的版本,读这个版本的行数据

  • 可串行化(Serializable) 在可串行化级别上,MySQL执行S2PL并发控制协议, 一阶段申请,一阶段释放。读写都要加锁,当前读。

  • mysql的几个事务隔离级别介绍可以看下我的这篇

后面会慢慢的运营自己的公众号,有兴趣的关注下哦