17.1 缓存的重要性

对于使用InnoDB存储引擎的表来说,无论是存储用户数据的索引、还是各种系统数据,都以页的形式存放在表空间中

即使只需要访问一个页的一条记录,也要先把整个页的数据加载到内存中。在读写后不释放该页对应的内存空间,而是缓存起来;下次再有就可以省下磁盘I/O。

17.2 InnoDB的Buffer Pool

在MySQL服务器启动时就向OS申请了一片连续的内存——Buffer Pool。可以在启动服务器时配置innodeb_buffer_pool_size启动选项(单位是字节,最小值为5MB):

image.png

Buffer Pool内部组成

Buffer Pool对应的一片连续的内存被划分为若干个页面(缓冲页),大小为16KB。每个缓冲页都有一些控制信息,把每个页对应的控制信息占用的一块内存称为控制块。控制块与缓冲页一一对应,都存放在Buffer Pool中(控制块在前,缓冲页在后):
image.png

free链表的管理

为了管理Buffer Pool中可用的缓冲页,将所有空闲的缓冲页对应的控制块作为一个节点放入一个链表中——free链表
image.png
链表有一个基节点,包含链表的头结点地址、尾结点地址以及当前链表中节点数量等信息。但是基节点占用的内存空间不包含在为Buffer Pool申请的一大片连续的内存之内。

之后每一次需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓冲页,并把该缓冲页对应的控制块的信息填上,然后把该缓冲页对应的free链表节点从链表中移除。

缓冲页的哈希处理

如果每次要访问页面中的数据时,都遍历一次Buffer Pool,那么太复杂了;我们其实是根据表空间号+页号定位一个页的。所以空间号+页号相当于key,缓冲页控制块地址就是value——采用哈希表。

flush链表的管理

如果修改了Buffer Pool中某个缓冲页的数据,那么它就与磁盘上的页不一致了,这样的缓冲页称为脏页

但是如果为了避免脏页每次修改完缓冲页就刷新到磁盘中,会严重影响程序性能。所以,每次修改缓冲页后都不立刻刷新到磁盘上,而是在未来某个时间点进行刷新

如果不立刻将改动刷新到磁盘,之后再刷新的时候怎么知道哪些页是脏页呢?所以创建一个链表用于存储脏页,凡是被修改过的缓冲页对应的控制块都会作为一个节点加入该链表——flush链表

LRU链表的管理

Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存超过了 Buffer Pool 大小,也就是free链表中没有多余的空闲缓冲页了,就需要将某些旧的缓冲页从Buffer Pool中移除,然后再将新的页放入。(最近最少被使用的页面)

再创建一个LRU链表,按照最近最少使用的原则去淘汰缓冲页,在访问某个页时

  • 如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓冲页时,就把该缓冲页对应的控制块作为节点放到LRU链表的头部;
  • 如果该页已被加载到Buffer Pool中,则直接把该页对应的控制块移动到LRU链表头部。

也就是,只要使用到某个缓冲页,就把该缓冲页调整到LRU链表的头部,所以LRU链表尾部就是最近最少使用的缓冲页了。


然而,上述的LRU链表也有问题:

  1. InnoDB提供了一项服务——预读。就是InnoDB认为执行当前的请求时,可能会在后面读取某些页面,于是预先将这些页面加载到Buffer Pool中,根据触发方式不同,预读可分为以下两种:
  • 线性预读:InnoDB提供一个系统变量innodb_read_ahead_threshold,如果顺序访问某个区的页面超过该系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool中的请求。该系统变量的值默认为56,可以在服务器启动时调整它,或在服务器运行过程中直接调整。由于是一个全局变量,因此要使用SET GLOBAL命令来修改。

  • 随机预读:如果某个区的13个连续页面都被加载到了Buffer Pool中,无论这些页面是不是顺序读取,都会触发一次异步读取本区中所有其他页面到Buffer Pool中的请求。系统变量innodb_random_read_ahead默认为OFF,可以修改启动选项或者直接使用SET GLOBAL命令设置为ON。

预读是本来是好事,如果预读到Buffer Pool中的页被成功使用到,就可以提高效率;但是如果用不到,这些预读的页都会放到LRU链表头部占用空间,LRU链表后面的缓冲页就会很快被淘汰掉。

  1. 写出一些需要全表扫描的语句(没有建立合适的索引 or 没有WHERE字句的查询)

全表扫描意味着将访问该表对聚簇索引的所有叶子节点对应的页。如果需要访问的页特别多,而Buffer Pool又不能全部容纳的话,就意味着需要将其他语句在执行过程中用到的页面移除出Buffer Pool。

新的LRU处理方式

将LRU链表按照一定比例分为两截:

  • 一部分存储使用频率非常高的缓冲页;这部分链表也称为热数据,或young区域
  • 另一部分存储使用频率不是很高的缓冲页;冷数据或old区域

image.png
可以查看系统变量innodb_old_blocks_pct来确定old区域在LRU链表中所占的比例:
image.png

有了young和old区域的LRU链表后,就可以对上述两种可能降低Buffer Pool命中率的情况进行优化:

  • 针对预读的页面可能不进行后续访问的优化

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

  • 针对全表扫描时,短时间内访问大量使用频率非常低的页面的优化

在进行全表扫描时,虽然由于上面那条规则,首次加载到Buffer Pool中的页被放到old区域头部,但是后续会被马上访问到,每次访问时又会把该页放到young区域的头部,从而将使用频率高的页面排挤下去。

注意到全表扫描有一个特点:执行频率非常低,很少有人会写全表扫描的语句。所以只需要规定,在对某个处于old区域的缓冲页第一次访问时,就在它对应的控制块中记录下首次访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,就不把该页面从old区域移动到young区域的头部,反之则移入。(该间隔时间由系统变量innodb_old_blocks_time控制,默认值为1000,单位ms)
image.png

刷新脏页到磁盘

后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,刷新方式有以下两种:

  • 从LRU链表的冷数据中刷新一部分页面到磁盘

后台线程会定时从LRU链表尾部开始扫描一些页面(数量可通过系统变量innodb_lru_scan_depth指定)。如果在LRU链表中发现脏页(缓冲页对应的控制块会存储是否被修改的信息),就刷新到磁盘。这种刷新页面的方式称为BUF_FLUSH_LRU。

  • 从flush链表中刷新一部分页面到磁盘

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

有时后台线程刷新脏页进度较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool中时没有可用的缓冲页。此时就会尝试查看LRU链表尾部,看 是否存在可直接释放掉的未修改缓冲页。如果没有,就不得不将LRU链表尾部的一个脏页同步刷新到磁盘。该方式称为BUF_FLUSH_SINGLE_PAGE。

多个Buffer Pool实例

在多线程环境下,访问Buffer Pool中的各个链表都需要加锁处理。在Buffer Pool特别大且多线程并发访问量特别高的情况下,单一的Buffer Pool可能会影响请求处理速度。

所以可以将Buffer Pool拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例。它们都是独立的,独立地申请内存空间、独立管理各种链表等。在并发访问时,互不影响,从而提高并发处理能力。可以在启动服务器时设置innodb_buffer_pool_instances来修改Buffer Pool实例的个数:
image.png
每个 Buffer Pool 实例实际占用的内存空间:
第 17 章 InnoDB的Buffer Pool - 图8

但也不是 Buffer Pool 实例创建的越多越好,分别管理各个 Buffer Pool 实例也需要性能开销。所以当innodb_buffer_pool_size值小于 1GB 时,设置多个实例是无效的,默认将innodb_buffer_pool_instances修改为1。