Innodb引擎 · 基础模块篇(二) · 浅谈Change Buffer

6,508 阅读13分钟

上一小节,我们简单介绍了Innodb引擎中Buffer Pool的体系结构,简单回顾一下:Innodb引擎架构分为“内存结构”和“磁盘结构”,它们之间以“页”为单位进行数据交互, 为了避免频繁的将数据页从磁盘加载到内存,Buffer Pool设计了flush listfree listlru list等数据结构在内存中缓存并管理数据页。 我们再来看一下Innodb的引擎架构图:

Innodb引擎架构图

我们看到在Innodb引擎的内存结构Buffer Pool中,还包含一片Change Buffer的区域,相应的在磁盘System Tablespace结构中,也相应的包含一片Change Buffer区域。读者可能会有疑问:Innodb引擎为什么要设计Change Buffer?它的工作原理是什么?它适用于哪些业务场景?本节我们就来回答一下这些问题。

1. Innodb引擎为什么要设计Change Buffer

我们知道Innodb引擎使用B+树来组织管理表数据,下图是一个B+树结构图:

Innodb索引结构图

在Innodb引擎中数据即索引,索引即数据,也就是说数据表本身就是一颗聚簇索引,我们在创建表结构时定义的索引被称为辅助索引,或者二级索引,聚簇索引和辅助索引的区别这里就不多说了,读者可以自行查找资料学习。知道了Innodb引擎索引结构的读者应该明白:索引并不是越多越好,虽然索引可以大幅提升根据索引字段查询数据的速度,但是在索引字段更新的时候,也需要进行维护,在索引数据经常更新的情况下,这个成本还是很大的。

存储引擎设计的挑战之一是怎样减少数据读写操作期间的随机I / O。 对于读请求,索引就是专门为它设计的,数据通过主键和索引字段按序排列在B+树的叶子节点上,在数据查找时就会尽可能的使用顺序I / O,还记得我们上节说的Buffer Pool吗?如果数据页能缓存到Buffer Pool中的话,那么在读数据时就可以省去磁盘I / O(无论是顺序 I / O还是随机I / O),写操作也是如此,直接更新内存中的缓存页就可以了(数据一致性问题读者暂时不必担心,可以通过redo log来保证,后边我们会继续探索相关内容),当然了数据都在缓存页中是理想情况,假如写操作的数据页不在缓存中又该怎么办呢?根据我们目前掌握的知识,Innodb引擎的处理过程是这样的:

  • 根据写操作的条件,Innodb引擎将需要使用数据表的“聚簇索引”数据页加载到Buffer Pool中(这个查找过程是顺序I / O),然后进行数据记录插入或者更新、删除。
  • 如果数据表创建有二级索引,需要将相应的二级索引数据页加载到Buffer Pool中(这个查找过程是随机I / O),然后进行数据记录的插入或者更新、删除。当然了这个过程当中如果出现“页分裂”或者“空白页”,也需要对二级索引的B+树做调整。

可以看到,在查找二级索引记录时,我们遇到了大量的随机I / O,这是Innodb引擎的设计者所不能容忍的,所以设计了Change Buffer这个特殊的数据结构来解决这个问题。一句话总结:Change Buffer是为了缓解Innodb引擎在读请求期间产生的随机I / O而设计的一种特殊的数据结构

Change Buffer中文名叫“更改缓冲区”, 在其他文章和源码中,读者会看到ibuf和IBUF以各种内部名称使用。在之前的版本中,只有在数据insert的时候能使用到它,所以之前叫Insert Buffer, 在后续的版本中支持了insertupdatedelete,就改成了这个更通用的名字了。

2. Change Buffer的工作原理是什么?

本节我们先来概述一下Change Buffer的实现原理,然后再探索更多的实现细节,这对我们全面了解Innodb引擎很有帮助。

2.1 Change Buffer的实现原理

下边是官方文档给出的工作示意图:

Change Buffer官方文档

类似与军事上的兵马未到,粮草先行,当二级索引页不在Buffer Pool中时,在写请求到来时,Innodb引擎会缓存这些更改 。此后页面通过其他读取操作加载到Buffer Pool中时,可能由INSERT, UPDATE或 DELETE操作(DML)导致的缓冲更改将在以后合并到缓存页中。

2.2 Change Buffer的数据结构

Change Buffer在物理上是一颗B树,可以保存任何二级索引的记录。在源代码中也称为通用树。InnoDB中只有一个更改缓冲区,它一直保存在系统表空间中。此更改缓冲区树的根页固定在系统表空间(空间ID为0)中的FSP_IBUF_TREE_ROOT_PAGE_NO(等于4)上。启动服务器时,将使用此固定页号加载它。一条ibuf记录大概是这个样子的:

ibuf记录的格式

ibuf btree通过{space_id,page_no,count}作为主键唯一标示一条记录,counter是一个递增值,其中的count有助于维护对该特定页面缓冲更改的顺序,更改缓冲区记录的行格式本身始终是REDUNDANT。

2.3 Change Buffer被使用的必要条件

更改缓冲仅适用于非唯一二级索引(NUSI)。InnoDB在NUSI上缓冲3种类型的操作:插入,删除标记和删除。这些操作由InnoDB引擎源码ibuf_op_t内部枚举:

//代码路径:storage/innobase/include/ibuf0ibuf.h

/* Possible operations buffered in the insert/whatever buffer. See
ibuf_insert(). DO NOT CHANGE THE VALUES OF THESE, THEY ARE STORED ON DISK. */
typedef enum {
  IBUF_OP_INSERT = 0,
  IBUF_OP_DELETE_MARK = 1,
  IBUF_OP_DELETE = 2,

  /* Number of different operation types. */
  IBUF_OP_COUNT = 3
} ibuf_op_t;

用户可以通过参数innodb_change_buffering来控制缓存何种操作, 代码中的枚举值设置如下:

//代码路径:storage/innobase/include/ibuf0ibuf.h

enum ibuf_use_t {
  IBUF_USE_NONE = 0,
  IBUF_USE_INSERT,             /* insert */
  IBUF_USE_DELETE_MARK,        /* delete */
  IBUF_USE_INSERT_DELETE_MARK, /* insert+delete */
  IBUF_USE_DELETE,             /* delete+purge */
  IBUF_USE_ALL                 /* insert+delete+purge */
};

可以将innodb_change_buffering设置为下列选项值来缓存相应的操作:

--all:      默认值。开启buffer inserts、delete-marking operations、purges
--none: 不开启change buffer
--inserts:  只是开启buffer insert操作
--deletes:  只是开delete-marking操作
--changes:  开启buffer insert操作和delete-marking操作
--purges:   对只是在后台执行的物理删除操作开启buffer功能

innodb_change_buffering默认值为all,表示缓存所有操作。注意由于在二级索引上的更新操作总是先delete-mark,再insert新记录,因此update会产生两条ibuf entry。

下边列举Innodb引擎缓存数据到change buffer的一些必要条件(能不能缓存更新操作到change buffer取决于很多因素):

  • 用户必须设置选项innodb_change_buffering
  • 只有叶子节点才会去考虑是否使用ibuf
  • 对于聚集索引,不可以缓存操作
  • 对于唯一二级索引(unique key),由于索引记录具有唯一性,因此无法缓存插入操作,但可以缓存删除操作;
  • 表上没有flush 操作,例如执行flush table for export时,不允许对表进行 ibuf 缓存

2.4 Change Buffer位图页面

ibuf 缓存的操作都是针对NUSI的特定子页面,这使得必须跟踪NUSI页面中的可用空间,因为 Change Buffer中的这些缓冲操作合并到NUSI叶页面上一定不能导致B树页面的合并或者B树页面拆分。

针对B树页面的合并,问题的关键是怎样避免空page,主要是针对purge线程而言,只有purge线程才会真正的删除二级索引上的物理记录。所以在准备插入IBUF_OP_DELETE的操作缓存时,Innodb引擎会预估在apply完成该page上所有的ibuf entry后还剩下多少记录,如果只剩下一条记录,则拒绝本次purge操作缓存,改走正常的读入物理页逻辑。

针对如何防止B树页面的分裂,Innodb引擎ibuf bitmap page使用一种特殊的page来维护每个NUSI数据页空闲空间大小,该page存在每个idb文件中,具有固定的page no,文件结构如下:

buf bitmap page结构示意图

更改缓冲区位图页面使用4位(IBUF_BITS_PER_PAGE)描述每个页面。它包含一个由4位组成的数组,用于描述每个页面。整个数组称为“ ibuf位图”(插入/更改缓冲区位图)。下表提供了有关这4位的详细信息:

值(第几个位index)描述
IBUF_BITMAP_FREE0前两位用于表示NUSI的叶子页中的可用空间。
IBUF_BITMAP_BUFFERED2如果设置了第三位,则意味着叶子页在更改缓冲区中具有缓冲的条目。
IBUF_BITMAP_IBUF3如果设置了第四位,则表示该页面是更改缓冲区的一部分。

此数组在页眉之后以等于IBUF_BITMAP(等于94)的偏移量开始。给定页码,包含给定页面上4位信息的ibuf位图页面可以计算为(参考函数:ibuf_bitmap_page_no_calc) :

//代码路径:mysql-8.0.20/storage/innobase/ibuf/ibuf0ibuf.cc

/** Calculates the bitmap page number for a given page number.
@param[in]	page_id		page id
@param[in]	page_size	page size
@return the bitmap page id where the file page is mapped */
UNIV_INLINE
const page_id_t ibuf_bitmap_page_no_calc(const page_id_t &page_id,
                                         const page_size_t &page_size) {
  page_no_t bitmap_page_no;

  bitmap_page_no = FSP_IBUF_BITMAP_OFFSET +
                   (page_id.page_no() & ~(page_size.physical() - 1));

  return (page_id_t(page_id.space(), bitmap_page_no));
}

ibuf bitmap page的设计我们知道只有2位能用于存储页面的可用空间信息。也就是只有0、1、2、3四种可能的值。使用这2个位,我们尝试对页面的可用空间信息进行编码。规则如下-UNIV_PAGE_SIZE / IBUF_PAGE_SIZE_PER_FREE_SPACE使用更改缓冲区必须至少有字节的可用空间:

//代码路径:mysql-8.0.20/storage/innobase/include/ibuf0ibuf.ic

/** An index page must contain at least UNIV_PAGE_SIZE /
IBUF_PAGE_SIZE_PER_FREE_SPACE bytes of free space for ibuf to try to
buffer inserts to this page.  If there is this much of free space, the
corresponding bits are set in the ibuf bitmap. */
#define IBUF_PAGE_SIZE_PER_FREE_SPACE 32

有了ibuf bitmap page,Innodb引擎在IBUF_OP_INSERT缓冲插入操作之前,使用更改缓冲区位图页中的可用信息近似计算目标NUSI叶页中的可用空间。计算方法在ibuf_index_page_calc_free_from_bits() 中体现:

//代码路径:mysql-8.0.20/storage/innobase/include/ibuf0ibuf.ic

/** Translates the ibuf free bits to the free space on a page in bytes.
@param[in]	page_size	page_size
@param[in]	bits		value for ibuf bitmap bits
@return maximum insert size after reorganize for the page */
UNIV_INLINE
ulint ibuf_index_page_calc_free_from_bits(const page_size_t &page_size,
                                          ulint bits) {
  ut_ad(bits < 4);
  ut_ad(!page_size.is_compressed() ||
        page_size.physical() > IBUF_PAGE_SIZE_PER_FREE_SPACE);

  if (bits == 3) {
    return (4 * page_size.physical() / IBUF_PAGE_SIZE_PER_FREE_SPACE);
  }

  return (bits * (page_size.physical() / IBUF_PAGE_SIZE_PER_FREE_SPACE));
}

以16KB的page size为例,能表示的空闲空间范围为0(0 bytes)、1(512 bytes)、2(1024 bytes)、3(2048 bytes):

IBUF位图页面上可用空间信息(IBUF_CODE)NUSI叶子页的可用空间(大致假定)
00字节
1512字节
21024字节
32048字节

使用此信息,我们可以确定要缓冲的记录是否适合页面。如果有足够的空间,则将对插入内容进行缓冲。使用这种方法,我们确保将这些记录合并到目标NUSI中不会导致页面拆分。

在缓冲插入或删除操作之后,必须相应地更新更改缓冲区位图页面中的可用空间信息(删除标记操作不会更改可用空间信息)。要更新可用空间信息,我们需要将以字节为单位的可用空间转换回IBUF编码值。这个工作在ibuf_index_page_calc_free_bits() 函数中完成:

//代码路径:mysql-8.0.20/storage/innobase/include/ibuf0ibuf.ic

/** Translates the free space on a page to a value in the ibuf bitmap.
@param[in]	page_size	page size in bytes
@param[in]	max_ins_size	maximum insert size after reorganize for
the page
@return value for ibuf bitmap bits */
UNIV_INLINE
ulint ibuf_index_page_calc_free_bits(ulint page_size, ulint max_ins_size) {
  ulint n;
  ut_ad(ut_is_2pow(page_size));
  ut_ad(page_size > IBUF_PAGE_SIZE_PER_FREE_SPACE);

  n = max_ins_size / (page_size / IBUF_PAGE_SIZE_PER_FREE_SPACE);

  if (n == 3) {
    n = 2;
  }

  if (n > 3) {
    n = 3;
  }

  return (n);
}

在上面的计算中,max_ins_size是页面重组后页面中可用的最大插入大小(最大可用空间)。

2.5 Change Buffer合并到目标MUSI页面

如果NUSI叶子页尚未在缓冲池中,则对NUSI叶子页的更改将存储在更改缓冲区中。在各种情况下,这些缓冲的操作将合并回到实际的NUSI叶页面中:

  • 用户线程选择二级索引进行数据查询,这时候必须要读入二级索引页,相应的ibuf entry需要merge到Page中。

  • 当InnoDB的主线程定期执行更改时,通过调用合并缓冲区ibuf_merge_in_background()。

  • 当为特定的NUSI叶页缓冲了太多操作时。

  • 当更改缓冲区树达到其最大允许大小时。

更改缓冲区合并操作通过调用ibuf_merge_in_background()或 ibuf_contract()函数来启动。更改缓冲区合并在前台或后台完成。前景更改缓冲区合并是DML操作的一部分,因此将影响最终用户的性能。相反,当服务器中的活动较少时,将定期进行后台更改缓冲区合并。

我们确保更改缓冲区合并不会导致B树页面拆分或页面合并操作。它还不应导致叶页为空。在将目标NUSI叶页面放入缓冲池之前,已将缓冲的更改应用于它们。一旦为页面合并了缓冲的更改,更改缓冲区位图页面中其相关的4位信息也将更新。

2.6 Change Buffer参数配置

Innodb引擎对Change Buffer使用参数的配置主要有两个:innodb_change_buffer_max_size、innodb_change_buffering;

Change Buffer参数配置

  • innodb_change_buffer_max_size配置写缓冲的大小占整个缓冲池的比例,默认是25%,最大值是50%;之所以这样设计是为了防止Change Buffer占用过多的Buffer Pool空间。

  • innodb_change_buffering前边提到过,用来配置哪些写操作启用写缓冲,可以设置成all/none/inserts/deletes等。

3. Change Buffer适用于哪些业务场景

业务数据库大都使用Innodb引擎,当数据表定义大都是非唯一索引并且是写多读少的业务场景时,非常适合启用Change Buffer(例如我们业务中的账单流水服务)。相反的当数据库中存在很多非唯一索引时,很多写操作是用不到Change Buffer的;另外还有一种情况就是当写入操作之后,马上读取这个页面时,或者对二级索引边读边写时,Change Buffer反倒会成为负担,我们上边说的Change Buffer合并到目标MUSI页面也是一件比较耗时的工作。

4. 小结与预告

本文从3个基本问题出发,介绍了Innodb引擎中Change Buffer的设计与实现,读者抓住兵马未到,粮草先行的设计思想来阅读本文,一定会有所收获的,然后可以再尝试回答这3个问题:为什么要设计Change Buffer?它的工作原理是什么?它适用于哪些业务场景?。

有关Change Buffer工作过程中的很多细节本文并没有深入,不过有时候思路比结论和实现更加重要,源码剖析这部分内容就放到以后有时间了再研究吧。笔者想说的是关于兵马未到,粮草先行的军事思想,Innodb引擎中还有一部分设计将它发挥的淋漓尽致,比如保证数据一致性、可靠性的基石:Redo Log!,WAL(Write Ahead Log)技术相信读者早有耳闻,它是Innodb引擎高性能实现的杀手锏,下一小结内容,我们来介绍Redo Log,今天的内容就到这里,谢谢大家!