对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户数据的索引(聚簇索引、二级索引)还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个实际文件的抽象。InnoDB 存储引擎在处理客户端请求时,当需要访问某个页的数据时,会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘 I/O 的开销了。对于数据库中页的修改操作,则首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上。

InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动时就向操作系统申请了一片连续的内存,这片连续的内存就叫做 Buffer Pool。默认 Buffer Pool 只有 128M 大小。可通过配置 innodb_buffer_pool_size 参数值来调整缓冲区的大小,单位是字节。注意,Buffer Pool 的最小值为 5M,当小于该值时会自动设置成 5M。

Buffer Pool 组成

Buffer Pool 中默认的缓存页大小也是 16KB。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息等。每个缓存页对应的控制信息占用的内存大小是相同的,这块内存我们称之为控制块,控制块和缓存页一一对应,其中控制块存放在 Buffer Pool 的前边,缓存页存放在 Buffer Pool 的后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:
image.png

1. free 链表

在启动 MySQL 服务器时,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但此时并没有真实的磁盘页被缓存到 Buffer Pool 中,之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。当从磁盘上读取一个页到 Buffer Pool 中的时候该放到哪个缓存页上呢?InnoDB 是如何区分 Buffer Pool 中哪些缓存页是空闲的,哪些是已被使用的呢?

此时,缓存页对应的控制块就派上了用场,InnoDB 会把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表被称作 free 链表或空闲链表。刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free 链表中,如下图所示:
image.png
从图中可以看到,为了管理好 free 链表,特意为这个链表定义了一个基节点,里边儿包含了链表的头节点、尾节点地址,以及当前链表中节点的数量等信息。需要注意,链表的基节点占用的内存空间并不包含在为 Buffer Pool 申请的一大片连续内存空间内,而是单独申请的一块内存空间。

有了 free 链表后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息(该页所在的表空间、页号等信息)填上,然后把该缓存页对应的 free 链表节点从链表中移除,表示该缓存页已经被使用了。

2. flush 链表

如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重影响程序性能。所以每次修改缓存页后,InnoDB 并不会立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。

因此,InnoDB 需要知道 Buffer Pool 中哪些页是脏页。所以,InnoDB 不得不再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以这个链表也叫 flush 链表。flush 链表的构造和 free 链表差不多,假设某个时间点 Buffer Pool 中的脏页数量为 n,那么对应的 flush 链表就长这样:
image.png

3. LRU 链表

Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 的大小,也就是 free 链表中已经没有多余的空闲缓存页时,而此时又要从磁盘读入一个数据页,那肯定要淘汰一个旧数据页。InnoDB 的内存淘汰策略使用的是改进后的 LRU 算法。

为了知道哪些缓存页最近频繁使用,哪些最近很少使用,InnoDB 又创建了一个链表,由于这个链表是为了按照最近最少使用原则去淘汰缓存页的,所以这个链表也被称为 LRU 链表。但普通的 LRU 链表并不能满足 MySQL 的需求,考虑以下两种情况:

  • 情况一:InnoDB 提供了预读(read ahead)机制。所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。如果预读到 Buffer Pool 中的页成功的被使用到了,则会极大提高语句执行效率;如果没被用到,这些预读的页都会放到 LRU 链表的头部,如果此时 Buffer Pool 的容量不够而很多预读的页面都没有用到的话,就会导致处在 LRU 链表尾部的一些缓存页被淘汰掉,而这会大大降低缓存命中率。

  • 情况二:当执行全表扫描语句时,扫描全表过程会访问该表所在的所有页,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,这也意味着 Buffer Pool 中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这严重影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率。

总结一下上边说的可能降低 Buffer Pool 的两种情况:

  • 加载到 Buffer Pool 中的页不一定被用到。
  • 如果非常多的使用频率偏低的页被同时加载到 Buffer Pool 时,可能会把那些使用频率非常高的页从 Buffer Pool 中淘汰掉。

因为有这两种情况的存在,所以在 InnoDB 实现的 LRU 算法上,将 LRU 链表按照一定比例分成两截:

  • 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称 young 区域。
  • 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称 old 区域。

image.png
InnoDB 是按照某个特定的比例将 LRU 链表分成两半的,不是某些节点固定是 young 区域,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也可能发生变化。我们可以查看 innodb_old_blocks_pct 系统变量值来确定 old 区域在 LRU 链表中所占的比例,示例如下:
image.png
可以看到在默认情况下,old 区域在 LRU 链表中所占的比例是 37%,即 old 区域约占 LRU 链表的 3/8。有了这个被划分成 young 和 old 区域的 LRU 链表后,InnoDB 就可以针对上边提到的两种可能降低缓存命中率的情况进行优化了:

针对预读的页面可能不进行后续访问的优化
InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。

针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化
在进行全表扫描时,虽然首次被加载到 Buffer Pool 的页被放到了 old 区域的头部,但是后续会被马上访问到,每次进行访问时又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。由于全表扫描的执行频率非常低,而且在执行全表扫描的过程中,即使某个页中有很多条记录,但多次访问这个页面所花费的时间也是非常少的。所以 InnoDB 规定,在对某个处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则就将它移动到 young 区域的头部。上述的这个间隔时间是由系统变量 innodb_old_blocks_time 控制的。
image.png
默认情况下,这个时间间隔的值是 1000 毫秒,也就意味着对于从磁盘上被加载到 LRU 链表的 old 区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于 1s ,那么该页是不会被加入到 young 区域的。

综上所述,正是因为将 LRU 链表划分为 young 和 old 区域这两个部分,又添加了 innodb_old_blocks_time 这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到 old 区域,而不影响 young 区域中的缓存页。

对于 young 区域的缓存页来说,如果我们每次访问一个缓存页就要把它移动到 LRU 链表的头部,这样的开销是有点大的,毕竟在 young 区域的缓存页都是热点数据,也就是会被经常访问的,这样频繁的对 LRU 链表进行节点移动操作比较耗费性能。为此,InnoDB 规定只有被访问的缓存页位于 young 区域的 1/4 的后边,才会被移动到 LRU 链表头部,这样就可以降低调整 LRU 链表的频率,从而提升性能。

脏页刷盘策略

在 InnoDB 的后台有专门的线程(Master Thread)每隔一段时间负责把脏页刷新到磁盘中,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:

  • 从 LRU 链表的冷数据中刷新一部分页面到磁盘。后台线程会定时从 LRU 链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 来指定,默认值为 1024,如果发现了脏页,会把它们刷新到磁盘。这种刷新页面的方式被称为 BUF_FLUSH_LRU。

  • 从 flush 链表中刷新一部分页面到磁盘。后台线程会定时从 flush 链表中刷新一部分页面到磁盘,刷新的速率取决于当前系统是否繁忙。这种刷新页面的方式被称为 BUF_FLUSH_LIST。

有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页了,这时就会尝试看看 LRU 链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU 链表尾部的一个脏页同步刷新到磁盘,这个同步刷盘过程会降低处理用户请求的速度。这种刷新单个页面到磁盘中的刷新方式被称之为 BUF_FLUSH_SINGLE_PAGE。

当然,当系统特别繁忙时,如果 InnoDB 的 redo log 写满了,这时系统会停止所有更新操作,把 checkpoint 往前推进,使 redo log 留出空间可以继续写。这可能出现用户线程批量的从 flush 链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为,这属于一种迫不得已的情况。

虽然脏页刷盘是常态,但如果一个查询要淘汰的脏页个数太多,则会导致查询的响应时间明显变长。这种情况对敏感业务是不能接受的。所以平时要多关注脏页比例,不要让它经常接近 75%。

核心参数

1. innodb_buffer_pool_instances

Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要进行加锁处理,在 Buffer Pool 特别大且多线程并发访问量特别高的情况下,单一的 Buffer Pool 可能会影响请求的处理速度。

所以在 Buffer Pool 特别大时,我们可以将其拆分成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。可通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例数。
image.png
多个 Buffer Pool 实例会平分 innodb_buffer_pool_size 参数设定的内存大小。不过也不是说 Buffer Pool 实例创建的越多越好,分别管理各个 Buffer Pool 也是需要性能开销的,InnoDB 规定:当 innodb_buffer_pool_size 的值小于 1G 时设置多个实例是无效的,InnoDB 会默认把 innodb_buffer_pool_instances 的值修改为 1。

2. innodb_buffer_pool_chunk_size

在 MySQL 5.7.5 之前,Buffer Pool 的大小只能在服务器启动时通过配置 innodb_buffer_pool_size 启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过在 MySQL 5.7.5 以及之后的版本中支持了在服务器运行过程中调整 Buffer Pool 大小的功能。

但是有一个问题,就是每次当我们要重新调整 Buffer Pool 大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的 Buffer Pool 中的内容复制到这一 块新空间,这是极其耗时的。所以 MySQL 决定不再一次性为某个 Buffer Pool 实例向操作系统申请一大片连续的内存空间,而是以一个所谓的 chunk 为单位向操作系统申请空间。也就是说一个 Buffer Pool 实例其实是由若干个 chunk 组成的,一个 chunk 就代表一片连续的内存空间,里边儿包含了若干缓存页及其对应的控制块:
image.png
正是因为有了这个 chunk 的概念,我们在服务器运行期间调整 Buffer Pool 的大小时就是以 chunk 为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的 chunk 的大小是我们在启动 MySQL 服务器时通过 innodb_buffer_pool_chunk_size 参数指定的,默认值为 128M。需要注意的是:innodb_buffer_pool_chunk_size 参数在服务器运行过程中是不可以修改的。

查看 Buffer Pool 状态信息

通过 SHOW ENGINE INNODB STATUS 语句,我们可以查看关于 InnoDB 存储引擎运行过程中的一些状态信息,其中就包括 Buffer Pool 的一些信息,下面我们着重看一下 Buffer Pool 的部分。
image.png

  • Buffer pool size:代表该 Buffer Pool 可以容纳多少缓存页,单位是页。
  • Free buffers:代表当前 Buffer Pool 还有多少空闲缓存页,也就是 free 链表中还有多少个节点。
  • Database pages:代表 LRU 链表中的页的数量,包含 young 和 old 两个区域的节点数量。
  • Old database pages:代表 LRU 链表 old 区域的节点数量。
  • Modified db pages:代表脏页数量,也就是 flush 链表中节点的数量。
  • Pending reads:正在等待从磁盘上加载到 Buffer Pool 中的页面数量。当准备从磁盘中加载某个页时,会先为这个页在 Buffer Pool 中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到 LRU 的 old 区域的头部,但这个时候真正的磁盘页并没有被加载进来,Pending reads 的值会跟着加 1。
  • Pending writes:
    • LRU:即将从 LRU 链表中刷新到磁盘中的页面数量。
    • flush list:即将从 flush 链表中刷新到磁盘中的页面数量。
    • single page:即将以单个页面的形式刷新到磁盘中的页面数量。
  • Pages made:
    • young:代表 LRU 链表中曾经从 old 区域移动到 young 区域头部的节点数量。注意,一个节点每次只有从 old 区域移动到 young 区域头部时才会将 Pages made young 的值加 1。
    • not young:在将 innodb_old_blocks_time 设置的值大于 0 时,首次访问或者后续访问某个处在 old 区域的节点时由于不符合时间间隔的限制而不能将其移动到 young 区域头部时,该值加 1。注意,对于处在 young 区域的节点,如果由于它在 young 区域的 1/4 处而导致它没有被移动到 young 区域头部,这样的访问并不会将 Page made not young 的值加 1。
  • youngs/s:代表每秒从 old 区域被移动到 young 区域头部的节点数量。
  • non-youngs/s:代表每秒由于不满足时间限制而不能从 old 区域移动到 young 区域头部的节点数量。
  • Pages read、created、written:代表读取、创建、写入了多少页。后边跟读取、创建、写入的速率。
  • Buffer pool hit rate:表示在过去某段时间,平均访问 1000 次页,有多少次该页已经被缓存到 Buffer Pool 中了,即缓存命中率。
  • young-making rate:表示在过去某段时间,平均访问 1000 次页面,有多少次访问使页面移动到 young 区域的头部了。注意,这里统计的将页面移动到 young 区域的头部次数不仅仅包含从 old 区域移动到 young 区域头部的次数,还包括从 young 区域移动到 young 区域头部的次数。
  • not(young-making rate):表示在过去某段时间,平均访问 1000 次页面,有多少次访问没有使页面移动到 young 区域的头部。注意,这里统计的没有将页面移动到 young 区域的头部次数不仅仅包含因为设置了 innodb_old_blocks_time 系统变量而导致访问了 old 区域中的节点但没把它们移动到 young 区域的次数,还包含因为该节点在 young 区域的前 1/4 处而没有被移动到 young 区域头部的次数。
  • LRU len:代表 LRU 链表中节点的数量。
  • unzip_LRU len:代表 unzip_LRU 链表中节点的数量。
  • I/O sum:最近 50s 读取磁盘页的总数。
  • I/O cur:现在正在读取的磁盘页数量。
  • I/O unzip sum:最近 50s 解压的页面数量。
  • I/O unzip cur:正在解压的页面数量。