数据库内核月报 - 2020 / 01

MySQL · 引擎特性 · InnoDB redo log 之 write ahead

1. 背景

现代文件系统对文件的buffer IO,一般是按照page为单位进行处理的。假设page的大小4096字节,当要将数据写入到文件偏移范围为[6144, 8192)的区域,会先在内存里,将对应page cache的[2048, 4096)这个区域的数据修改为新值,然后将对应的page cache,整个的从内存刷到磁盘上。但是如果要写入的文件区域,因为还没有被缓存或者被置换出去了等原因,在内存里不存在对应的page cache,则需要先将对应page的内容从磁盘上读到内存里,修改要写入的数据,然后在将整个page写回到磁盘;在这种情况下,会有一次额外的读IO开销,IO的性能会有一定的损失。

InnoDB内redo log采用的是buffer write,也会遇到这种问题,而且mysql的整体性能对redo log写IO的性能比较敏感,为此InnoDB对该问题做了优化,结合redo log写入是append write的特性,引入了write ahead方法,尝试解决这个问题。主要原理是当某次写文件系统IO满足这两个条件:

a. 该IO的目的地址是文件内的某个page的起始偏移地址;

b. 改IO的数据大小为page大小的整数倍

则该IO的执行,不需要先从磁盘中读出对应page的数据,再做修改和和写入,而是直接将该IO所带的数据作为修改后的数据,缓存在内存里(即page cache),等后续刷盘操作将该page cache写入到磁盘上。这样就避免了额外的读IO开销。

write ahead的原理比较简单,但是InnoDB内的实现比较精炼,不易理解,容易淡忘。所以,本文以MySQL 8.0.12版本代码为参考,注释分析redo log的write ahead的工作机制,以备后续查记;并简单验证该write ahead机制是否有效。

2. redo log write ahead的工作流程

在MySQL 8.0,innodb将redo log的相关操作,按照功能划分成不同的阶段,由不同的线程负责不同阶段的逻辑。在mini transaction的commit阶段,将该mini transaction产生的redo log拷贝到log_sys->buf内,这部分逻逻辑比较分散,可以发生在用户线程内; log_writer线程,负责将全局的log_sys->buf内的redo log写入文件系统,暂时保存在page cache中;log_flusher线程,负责刷盘,将还处在文件系统的redo log写到磁盘上;log_write_notifier和log_flush_notifier线程,负责触发事件,分别提醒log_writer线程和log_flusher线程从等待中开始工作。

redo log的write ahead逻辑发生在log_writer线程内。该线程逻辑的代码入口在log0write.cc:log_writer函数处;它的工作流程比较简单:

  1. 循环等待条件:log_sys->m_recent_written->m_tail 大于 log_sys->m_written_lsn,条件满足时,说明有新的redo log产生,需要被写入;
  2. 有新的redo log时,则写redo log到文件系统中。

主路径的调用路径如下:

log_writer
->log_writer_write_buffer
->log_files_write_buffer // redo log的write ahead逻辑发生在这个函数内

下面结合代码分析log_files_write_buffer函数的实现:

//@log_files_write_buffer函数
static void log_files_write_buffer(log_t &log, byte *buffer, size_t buffer_size,
                                   lsn_t start_lsn) {
  ......
  // 该变量为true,表示将redo log直接从log_sys->buf内写入到文件系统;
  // 否则,表示需要先将要写入的redo log拷贝到log_sys->write_ahead_buf,
  // 然后从log_sys->write_ahead_buf,将redo log写入到文件系统中, 对于后者
  // 有两种情况:a. 执行write ahead逻辑;b. 需要对最后一个完整的log block填0。
  bool write_from_log_buffer;
  // 计算本次redo log IO的大小和判断write_from_log_buffer的值。
  // 后面分析其实现。 
  auto write_size = compute_how_much_to_write(log, real_offset, buffer_size,
                                              write_from_log_buffer);

  if (write_size == 0) {
    // 如果本次IO大小的计算值为0,表示当前刚好处在正在写入的redo log文件的结尾,
    // 需要先切换到下一个redo log文件
    start_next_file(log, start_lsn);
    return;
  }

  // 以512个字节为单位,计算并填入每个完整的log block的元信息,
  // 如:该log block的有效数据长度、该log block当前对应的checkpoint no,
  // 该log block的checksum等。
  // note: 这里计算的是完整log block的元信息,不完整的log block后面再做处理。
  prepare_full_blocks(log, buffer, write_size, start_lsn, checkpoint_no);
  ......
  if (write_from_log_buffer) {
    // 从log_sys->buf,将redo log直接写入到文件系统
    // 只需将写入的源端指向log_sys->buf内的正确位置,即
    // buffer所指向的地址。
    ......
    write_buf = buffer;
    .......

  } else {
    // 从log_sys->write_ahead_buf将redo log写入到文件系统,
    // 同样要讲写入的源端指向log_sys->write_ahead_buf
    write_buf = log.write_ahead_buf;
    // 先将要写入的redo log从全局log_sys->buf内拷贝到log_sys->write_ahead_buf
    // 中, 拷完后,将最后一个不完整的block的结尾区域填0,并填上checkpoint no,
    // checksum等元信息。
    copy_to_write_ahead_buffer(log, buffer, write_size, start_lsn,
                               checkpoint_no);
    //执行到这里的逻辑,有两种情况:
    //a. 当前要写入文件偏移量刚好log_sys->write_ahead_buf当前可以覆盖区域的结尾处
    //.  (这个地址一般都是page对齐的), 需要做write ahead操作;
    //b. 本次IO要写入的redo log量太少,小于一个log block的大小,需要一块额外的buffer
    //.  空间,将这块不完整log block的后端区域填0,并计算和填入checksum值等信息。如果直
    //   接在log_sys->buf内原地填0,可能会把mtr刚刚拷贝到该区域的log覆盖掉。
    // 下面的这个分支判断筛选出情况a.
    if (!current_write_ahead_enough(log, real_offset, 1)) {
      // 在执行write ahead的情况下,将log_sys->write_ahead_buf内未被有效redo log
      // 填充的区域都填0;同时更新write_size的值, 即write ahead buffer的大小
      written_ahead = prepare_for_write_ahead(log, real_offset, write_size);
    }
  }
  ......
  // 当刚才完成的写IO的目标范围的结束偏移(不是有效redo log的结束偏移),不在
  // log_sys->write_ahead_buf的当前覆盖范围内,则往后滑动
  // log_sys->write_ahead_buf的覆盖范围,以便计算后续的redo log写IO,
  // 是否需要执行write ahead,和截断要写入的log数据等操作.
  update_current_write_ahead(log, real_offset, write_size);
}                                              

//@log_files_write_buffer->compute_how_much_to_write
// 该函数主要是为了判断当前的redo log写IO,是否需要write ahead,
// 和计算本次IO应该写入的数据大小。
static inline size_t compute_how_much_to_write(const log_t &log,
                                               uint64_t real_offset,
                                               size_t buffer_size,
                                               bool &write_from_log_buffer) {
  size_t write_size;
  .......
  // 如果需要跨文件,则当前IO只写入当前正在写入的redo log文件可以装下的数据;
  // 这很容易理解,一般的同步IO都是这么操作的。
  if (!current_file_has_space(log, real_offset, buffer_size)) {
    ......
    // 如果已经处在当前正在写入的redo log文件的结尾处,则需要先切换redo log文件;
    // 设置当前IO大小为0,来通知上层调用切换到新的redo log文件
    if (!current_file_has_space(log, real_offset, 1)) {
      .......
      write_from_log_buffer = false;
      return (0);
    } else {
      // 设定本次写入IO的数据量为当前redo log文件可以容纳的最大数据
      write_size =
          static_cast<size_t>(log.current_file_end_offset - real_offset);
      ......
    }
  } else {
    // 如果不需要跨文件(当前redo log文件可以容纳要写入的log), 则暂时设定
    // 写入IO的大小为要写入的log的大小,这个值在后面可能还要受到
    // log_sys->write_ahead_buf能够容纳的数据量的限制,而被截断。
    write_size = buffer_size;
  }
  ......

  // InnoDB的redo log是按照log block进行管理的,一个log block的大小为
  // OS_FILE_LOG_BLOCK_SIZE字节,每个log block都有独立的元信息,如log
  // block no, checksum等。当某次写redo log的IO要写入的log数据不足一个
  // OS_FILE_LOG_BLOCK_SIZE时,该IO准备逻辑需要将该写入数据的对应的log
  // block的后端区域填0,然后计算和填入该block填0后的checksum值;但是这
  // 不能在全局的log_sys->buf原地做,需要一块额外buffer,否则可能会覆盖后
  // 续填入其中的log数据;这里我们将log_sys->write_ahead_buf选为我们
  // 的"额外buffer",所以这里当write_size小于OS_FILE_LOG_BLOCK_SIZE时,
  // 令'write_from_log_buffer'为false,表示本次写IO数据最后要从
  // log_sys->write_ahead_buf写入到文件系统中。
  write_from_log_buffer = write_size >= OS_FILE_LOG_BLOCK_SIZE;
  ......
  // 判断当前的write ahead区域,是否可以装得下我们这log IO要写入的数据,如
  // 果装不下,则需要截断本次IO要写入的数据;如果当前要写入的文件偏移,刚好处
  // 在log_sys->write_ahead_buf当前覆盖覆盖区域的结束位置(一般也是某个
  // page的起始或者结束地址处),这个时候,需要采用一次write ahead操作,具体
  // 逻辑为:将本次写IO要写入的数据从log_sys->buffer拷贝到
  // log_sys->write_ahead_buf内,将log_sys->write_ahead_buf后
  // 端未被有效数据填充的区域填0,然后将整个log_sys->write_ahead_buf的
  // 内容写入到文件系统中,避免可能出现的一次读IO开销
  // 
  // note. 这里有一个隐藏的假设:
  // a. 当某次写IO的目的偏移地址是与log_sys->write_ahead_buf当前覆盖范围
  //    的结束地址对齐时,则假定该次写IO目标区域在内存没有对应的page cache,需
  //.   要执行一次write ahead操作
  // b. 当执行一次write ahead逻辑后,在接下来的一段时间内,该区域对应的page cache
  //.   会保存在内存中,后续对当前write ahead buffer可以覆盖的文件区域的
  //.   写IO,都可以命中这些page cache, 从而避免额外的读IO开销。
  // 上面的假设a和b,真实情况下并不是百分百成立的。
  if (!current_write_ahead_enough(log, real_offset, write_size)) {
    if (!current_write_ahead_enough(log, real_offset, 1)) {
      // 本次写IO的目的地址不在write ahead buffer当前可以覆盖区域内
        
      // 计算write ahead buffer下一个覆盖区域的结尾偏移地址
      const auto next_wa = compute_next_write_ahead_end(real_offset);

      if (!write_ahead_enough(next_wa, real_offset, write_size)) {
        // log_sys->write_ahead buffer的下一个完整的覆盖区域都容纳不了本次
        // 写IO的log数据,则将本次IO要写入的数据截断到write ahead buffer的
        // 大小;并且不需要再从log_sys->write_ahead_buf写,可以直接从
        // log_sys->buf写入到文件系统,减少了一次内存拷贝的开销。
        ......
        write_size = next_wa - real_offset;
        ......
      } else {
        // 本次写IO执行write ahead逻辑
        write_from_log_buffer = false;
      }
    } else {
      // log_sys->write_ahead_buf的当前覆盖范围容纳不了本次IO要写入的log
      // 数据,将本次IO要写入的log数据按照可以容纳的量阶段。
      write_size =
          static_cast<size_t>(log.write_ahead_end_offset - real_offset);
      ......
    }
  } else {
    if (write_from_log_buffer) {
      // 走到这里,根据上面write_from_log_buffer的赋值逻辑,说明本次IO要写入的log数
      // 据是大于一个OS_FILE_LOG_BLOCK_SIZE的,在这种情况下,将写入的log数据按照向下
      // 对齐OS_FILE_LOG_BLOCK_SIZE进行截断,这样可以一定概率的避免对最后一个不完整
      // block的后面区域填0操作(填0操作,有拷贝到另外一块额外buffer内的开销),因为等下
      // 一次IO的时候,这个不完整的block可能又有新的log数据填入,变得完整了。
      write_size = ut_uint64_align_down(write_size, OS_FILE_LOG_BLOCK_SIZE);
    }
  }

  return (write_size);
}    

总的来说,某次写redo log的IO可能会有以下这四种情况:

a. 该IO的目标偏移量刚好是log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的log数据量小于srv_log_write_ahead_size, 则利用log_sys->write_ahead_buf,执行write ahead逻辑:将要写入的log数据拷贝到log_sys->write_ahead_buf内,对log_sys->write_ahead_buf后端未被有效数据填充的区域填0,然后将整个log_sys->write_ahead_buf写入到文件系统中;

b. 该IO的目标偏移量刚好是log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的log数据大于srv_log_write_ahead_size, 则不需要执行write ahead操作。将本次写入IO的数据量截断为srv_log_write_ahead_size大小,直接从log_sys->buf将这srv_log_write_ahead_size大小的数据写入到文件系统中,这样既起到了write ahead操作的作用,也避免了write ahead操作所产生的额外内存拷贝的开销。

c. 该IO的目标偏移量不在log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的数据小于一个log block的大小,则不需要执行write ahead 操作,但是需要利用log_sys->write_ahead_buf对这个不完整的log block的后端未填入有效log数据的区域填0,并计算checksum等信息,然后将整个log block从log_sys->write_ahead_buf处写入到文件系统中,这个过程会有一次额外的内存拷贝,从log_sys->buf将要写入的log数据拷贝到log_sys->write_ahead_buf内。

d. 该IO的目标偏移量不在log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的数据大于一个log block的大小,则也不需要执行write ahead操作。将本次写入IO的数据大小按下截断到OS_FILE_LOG_BLOCK_SIZE的整数倍,然后从log_sys->buf直接写入到文件系统,这样可以较大概率的避免对最后一个不完整log block的填0操作所引入的开销。

下图可以简要的是示意上面介绍的InnoDB内redo log写IO的情况:

tmp

3. 主要的数据结构和参数

a. log_sys->write_ahead_buf 该buffer主要有两个作用:a. 用于redo log的write ahead,先将要写入的redo log从log_sys->buf拷贝到log_sys->write_ahead_buf, 再对log_sys->write_ahead_buf后端未被有效数据填充的区域填0;b. 用于对不完整block的后端区域填0。因为原地填0等操作,可能会覆盖后续填入的有效log数据。

b. 参数innodb_log_write_ahead_size

​ 用于控制log_sys->write_ahead_buf的大小,默认为8092;一般需要设置为内存页大小的整数倍,linux下内存页的大小可通过‘getconf PAGE_SIZE’命令获取,内存页的大小一般为4096字节。

4. 验证write ahead是否有效

附录里有测试的代码,大致的思路是在清空page cache的情况下,按照append write的方式,单线程同步的对一个文件写入1G数据,按两种方式进行对比:a. 普通写入方式,每次写入的数据为512B,直至写完1GB;b. 采用write ahead的方式进行写入,当写入的地址为一个page的起始地址时,则写入一个后端填0的完整page,否则写入512B数据,也是直至写完1GB数据。

对比测试是在同一个物理机的同一块磁盘上进行的(这里就不给出软硬件型号参数了),磁盘采用的是nvme盘;测试前清空缓存。

分别执行如下命令,进行对比

// 命令说明:
//     a. 先给tmp.txt写入1G的数据,在进行测试,是为了避免文件第一次写入时,元信息修改产生的影响;
//.    b. echo 3 >/proc/sys/vm/drop_caches 用于清空缓存。


// write ahead写入方式, 
> dd if=/dev/zero of=./tmp.txt bs=1048576 count=1024 && g++ -O3 -DWRITE_AHEAD append_write.cc -o append_write && echo 3 >/proc/sys/vm/drop_caches && time ./append_write ./tmp.txt
    
// 普通写入方式
> dd if=/dev/zero of=./tmp.txt bs=1048576 count=1024 && g++ -O3 -DNORMAL_WRITE append_write.cc -o append_write && echo 3 >/proc/sys/vm/drop_caches && time ./append_write ./tmp.txt

跑3次取平均值,结果为:

不同的写入方式 write ahead写入方式 普通写入方式
耗时/second 2.878 11.515

可以看到在这种方式,write ahead的收益还是很明显的,有差不多4倍的收益。

结论: 在page cache不命中的情况下,采用write ahead的方式进行写入的优化效果还是很明显的。

##附:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>

char filepath[128];
uint32_t len_per = 512;
char buf[4096];
char buf2[4096];
const uint64_t file_size = 1024*1024*1024*1ul;
const uint64_t block_size = 4096;

void usage() {
  fprintf(stderr, "usage:\n\t./append_write filepath [len_per]\n");
}
int main(int argc, char* argv[]) {
  if (argc > 3) {
    usage();
    return -1;
  }
  strcpy(filepath, argv[1]);

  if (argc == 3) {
    len_per = atoi(argv[2]);
  }
  int32_t fd = open(filepath, O_RDWR);
  if (fd == -1) {
    fprintf(stderr, "create new file failed, errno: %d\n", errno);
    return -1;
  }
  fprintf(stderr, "start writing...\n");
  for (uint64_t sum = 0; sum < file_size; sum += len_per) {
#ifdef WRITE_AHEAD
    if (sum % block_size == 0) {
      memcpy(buf2, buf, len_per);
      memset(buf2+len_per, 0, block_size - len_per);
      if (pwrite(fd, buf2, block_size, sum) != block_size) {
        fprintf(stderr, "write failed, errno: %d\n", errno);
        close(fd);
        return -1;
      }
    } else if (pwrite(fd, buf, len_per, sum) != len_per) {
      fprintf(stderr, "write failed, errno: %d\n", errno);
      close(fd);
      return -1;
    }
#elif defined(NORMAL_WRITE)
    if (pwrite(fd, buf, len_per, sum) != len_per) {
      fprintf(stderr, "write failed, errno: %d\n", errno);
      close(fd);
      return -1;
    }
#endif
  }
  fprintf(stderr, "finish writing...\n");

  close(fd);
  return 0;
}