海山数据库(He3DB)源码详解:海山MySQL redo日志-MTR

39 阅读8分钟

一、Mini-Transaction

MySQL对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr。一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志。

在这里插入图片描述

1.1 原子操作

所谓原子操作即要么全部成功,要么全部失败,不存在中间状态。

在事务执行过程中,每条语句作为一个mtr来执行。而在执行语句的过程中产生的redo日志被划分成了若干个不可分割的组

如:

  • 更新Max Row ID属性时产生的redo日志是不可分割的。
  • 向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。
  • 向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。

以向某个索引对应的B+树插入一条记录为例,在向B+树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:

  • 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERT的redo日志就好了,我们把这种情况称之为乐观插入。假如某个索引对应的B+树长这样:

在这里插入图片描述

现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,由于页b现在有足够的空间容纳一条记录,所以直接将该记录插入到页b中就好了,如图所示:

在这里插入图片描述

  • 情况二:该数据页剩余的空闲空间不足。遇到这种情况要进行所谓的页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条redo日志,因此将这种情况称之为悲观插入。假如某个索引对应的B+树长这样:

在这里插入图片描述

现在如果要插入一条键值为10的记录,很显然需要被插入到页b中,但是从图中也可以看出来,此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:

在这里插入图片描述

如果作为内节点的页a的剩余空闲空间也不足以容纳增加一条目录项记录,那需要继续做内节点页a的分裂操作,也就意味着会修改更多的页面,从而产生更多的redo日志。

二、mtr源码解析

2.1 prepare_write()

/** Prepare to write the mini-transaction log to the redo log buffer.
@return number of bytes to write in finish_write() */
ulint mtr_t::Command::prepare_write() {
  switch (m_impl->m_log_mode) {
    case MTR_LOG_SHORT_INSERTS:
      ut_ad(0);
      /* fall through (write no redo log) */
    case MTR_LOG_NO_REDO:
    case MTR_LOG_NONE:
      ut_ad(m_impl->m_log.size() == 0);
      log_mutex_enter();
      m_end_lsn = m_start_lsn = log_sys->lsn;
      return (0);
    case MTR_LOG_ALL:
      break;
  }

  ulint len = m_impl->m_log.size();
  ulint n_recs = m_impl->m_n_log_recs;
  ut_ad(len > 0);
  ut_ad(n_recs > 0);

  if (len > log_sys->buf_size / 2) {  // 如果当前日志的大小(len)超过了redo日志缓冲区大小的一半
    log_buffer_extend((len + 1) * 2);  // 扩展缓冲区大小
  }

  ut_ad(m_impl->m_n_log_recs == n_recs);

  fil_space_t *space = m_impl->m_user_space;

  if (space != NULL && is_system_or_undo_tablespace(space->id)) {
    /* Omit MLOG_FILE_NAME for predefined tablespaces. */
    space = NULL;
  }

  log_mutex_enter();

  if (fil_names_write_if_was_clean(space, m_impl->m_mtr)) {
    /* 如果这是自上次检查点以来第一次修改表空间,则需要添加一些文件名记录到日志中,
    并在日志末尾添加MLOG_MULTI_REC_END标记。 */
    ut_ad(m_impl->m_n_log_recs > n_recs);
    mlog_catenate_ulint(&m_impl->m_log, MLOG_MULTI_REC_END, MLOG_1BYTE);
    len = m_impl->m_log.size();
  } else {
    /* 如果这不是第一次修改表空间,则检查是否只有一个日志记录。
    如果是,则将该记录标记为单记录(MLOG_SINGLE_REC_FLAG)。
    如果有多个记录,则在日志末尾添加MLOG_MULTI_REC_END标记。 */

    ut_ad(n_recs == m_impl->m_n_log_recs);

    if (n_recs <= 1) {
      ut_ad(n_recs == 1);

      /* Flag the single log record as the
      only record in this mini-transaction. */
      *m_impl->m_log.front()->begin() |= MLOG_SINGLE_REC_FLAG;
    } else {
      /* Because this mini-transaction comprises
      multiple log records, append MLOG_MULTI_REC_END
      at the end. */

      mlog_catenate_ulint(&m_impl->m_log, MLOG_MULTI_REC_END, MLOG_1BYTE);
      len++;
    }
  }

  /* 检查是否需要触发检查点,以确保日志系统不会超出其容量限制 */
  log_margin_checkpoint_age(len);

  return (len);
}

  • 该函数是数据库事务日志系统的一部分,用于准备将mtr写入重做日志缓冲区。它根据事务日志的模式(m_log_mode)来决定如何处理日志写入。

  • 代码逻辑:

    • 1、日志模式判断
    • 2、日志长度和记录数检查
    • 3、日志缓冲区扩展
    • 4、表空间处理
    • 5、进入日志互斥锁
    • 6、文件名记录处理
      如果这是自最新检查点以来首次修改表空间,则需要添加文件名记录到日志中。
      更新记录数,并在日志末尾添加MLOG_MULTI_REC_END标记。
      重新计算日志长度。
    • 7、非首次修改表空间:
      如果不是首次修改表空间,则根据记录数判断是单个记录还是多个记录。
      单个记录:标记该记录为单记录事务。
      多个记录:在日志末尾添加MLOG_MULTI_REC_END标记,并增加日志长度。
    • 8、检查点处理
    • 9、返回日志长度

2.2 execute()

/** Write the redo log record, add dirty pages to the flush list and release
the resources. */
void mtr_t::Command::execute() {
  ut_ad(m_impl->m_log_mode != MTR_LOG_NONE);

  if (const ulint len = prepare_write()) {
    finish_write(len);
  }

  if (m_impl->m_made_dirty) {
    log_flush_order_mutex_enter();
  }

  /* It is now safe to release the log mutex because the
  flush_order mutex will ensure that we are the first one
  to insert into the flush list. */
  log_mutex_exit();

  m_impl->m_mtr->m_commit_lsn = m_end_lsn;

  release_blocks();

  if (m_impl->m_made_dirty) {
    log_flush_order_mutex_exit();
  }

  release_all();

  release_resources();
}

  • 该函数的作用是执行一系列与事务相关的操作,包括写入重做日志记录、将脏页添加到刷新列表,并释放相关资源。

1、检查前置条件

ut_ad(m_impl->m_log_mode != MTR_LOG_NONE);
  • 使用ut_ad调试宏,用于在开发过程中捕获逻辑错误。
  • 这里用于检查日志模式是否是MTR_LOG_NONE,确保在尝试写入日志之前,日志模式是有效的。 2、准备写入日志
if (const ulint len = prepare_write()) {
    finish_write(len);
  }
  • 首先调用prepare_write函数准备写入日志,并获取要写入的日志长度。
  • 如果返回长度不为0,则表示有日志需要写入,调用finish_write函数完成日志的写入。

3、处理脏页

if (m_impl->m_made_dirty) {
    log_flush_order_mutex_enter();
  }
  • 如果事务过程中产生了脏页,则需要进入log_flush_order_mutex互斥锁。
  • 这个锁用于确保在将脏页添加到刷新列表时,没有其他线程同时修改这个列表。

4、释放日志互斥锁

release_blocks();
  • 在确保脏页将被安全处理后,可以释放log_mutex。

5、更新提交日志序列号

m_impl->m_mtr->m_commit_lsn = m_end_lsn;
  • 更新事务的提交日志序列号(LSN)为当前操作的结束LSN。

6、释放资源并退出锁

  release_blocks();  // 释放数据块

  if (m_impl->m_made_dirty) {
    log_flush_order_mutex_exit();   // 退出互斥锁
  }

  release_all();   // 释放所有资源

  release_resources();  // 释放额外资源

2.3 finish_write()

/** Append the redo log records to the redo log buffer
@param[in] len	number of bytes to write */
void
mtr_t::Command::finish_write(
	ulint	len)
{
	ut_ad(m_impl->m_log_mode == MTR_LOG_ALL);
	ut_ad(log_mutex_own());
	ut_ad(m_impl->m_log.size() == len);
	ut_ad(len > 0);

	if (m_impl->m_log.is_small()) {
		const mtr_buf_t::block_t*	front = m_impl->m_log.front();
		ut_ad(len <= front->used());

		m_end_lsn = log_reserve_and_write_fast(
			front->begin(), len, &m_start_lsn);

		if (m_end_lsn > 0) {
			return;
		}
	}

	/* Open the database log for log_write_low */
	m_start_lsn = log_reserve_and_open(len);

	mtr_write_log_t	write_log;
	m_impl->m_log.for_each_block(write_log);

	m_end_lsn = log_close();
}

  • 该函数的目的是将指定长度的redo日志记录追加到redo日志缓冲区中

1、断言检查

    ut_ad(m_impl->m_log_mode == MTR_LOG_ALL);  // 确保当前的日志模式是记录所有更改 
	ut_ad(log_mutex_own());  // 确保当前线程持有日志互斥锁
	ut_ad(m_impl->m_log.size() == len);  // 确保redo日志缓冲区中的日志记录大小与要写入的大小相同
	ut_ad(len > 0);  // 确保要写入的长度大于0

2、快速写入检查

if (m_impl->m_log.is_small()) {
		const mtr_buf_t::block_t*	front = m_impl->m_log.front();
		ut_ad(len <= front->used());

		m_end_lsn = log_reserve_and_write_fast(
			front->begin(), len, &m_start_lsn);

		if (m_end_lsn > 0) {
			return;
		}
	}
  • 如果redo日志缓冲区中的日志记录较小,则使用快速写入路径。
  • 获取缓冲区的前端块(front),并检查要写入的长度是否小于或等于该块已使用的空间。
  • 调用log_reserve_and_write_fast函数尝试快速写入。成功则直接返回。

3、常规写入路径

    m_start_lsn = log_reserve_and_open(len);

	mtr_write_log_t	write_log;
	m_impl->m_log.for_each_block(write_log);

	m_end_lsn = log_close();
  • 如果快速写入失败或不适用于当前情况,则进入常规写入路径。
  • 调用log_reserve_and_open函数为日志写入预留空间,并获取起始日志序列号。
  • 使用m_impl->m_log.for_each_block(write_log);遍历redo日志缓冲区中的每个块,并准备将它们写入到日志文件中。
  • 调用log_close函数完成日志写入,并获取结束日志序列号。