1、Buffer Pool简介

1.1 什么是Buffer Pool

InnoDB存储引擎会将用户数据记录和索引以的形式存放在表空间中(MySQL5.7以后是独立表空间),而表空间就是数据存储在文件系统中的几个文件(.frm文件、.rbd文件)的抽象,归根到底数据还是存储在磁盘上的。即使我们要访问一个页中的一条记录,也需要先把整个页加载到访问数据库的进程的内存中,再进行读写,读写完之后并不是直接把页所在的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘**IO**的开销了。缓存在哪里呢?就是这节介绍的Buffer Pool
InnoDB存储引擎为了缓存这些页(也叫缓存页),在MySQL服务端程序启动时就向操作系统申请了一片连续的内存空间,这片内存空间就是Buffer Pool(缓存池)。Buffer Pool的大小可以根据机器实际情况来设置,默认情况只有128M的大小,可以在配置文件中设置innodb_buffer_pool_size参数,如下:

  1. [server]
  2. innodb_buffer_pool_size = 268435456

我们项目中MySQL实例的Buffer Pool大小为8G。
可以通过以下命令查看Buffer Pool的状态信息,所谓的状态信息包括Buffer Pool的内存空间大小,Buffer Pool实例个数等信息:

  1. # \G是打印出来的结果美观,自动换行
  2. SHOW ENGINE INNODB STATUS\G

总结一下,Buffer Pool的作用的就是缓存数据页,InnoDB向操作系统申请一片内存空间,将读写的数据页缓存到这片内存中,下次相同访问数据页的读写请求过来时,就从缓存的数据页中读取,而不是从磁盘的表空间对应的文件系统中读取数据,减少了磁盘的IO次数,进而提高性能。

1.2 Buffer Pool的组成

Buffer Pool主要有三部分组成:缓存页、控制块和碎片,如下图所示:
image.png
(1)缓存页
缓存页就是Buffer Pool真正要缓存的数据页,页里包含了用户真实记录以及索引的目录项记录,一个Buffer Pool实例里有多个缓存页。缓存页在Buffer Pool实例的后端。
(2)控制块
每一个缓存页都有一些控制信息,比如该缓存页的表空间编号、页号、缓存页在Buffer Pool中的地址等,每个缓存页都对应的控制信息占用的内存大小是相同的,把每个缓存页控制信息占用的那块内存叫做控制块,控制块和缓存页是一一对应的。控制块在Buffer Pool实例的前端。
(3)碎片
每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能Buffer Pool实例中剩余的空间不够一对控制块和缓存页的大小,自然就用不到了,这个用不到的内存空间称为碎片。

1.3 多个Buffer Pool实例

1.3.1 Buffer Pool实例的概念

Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理的,在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把一个大的Buffer Pool拆分成若干个小的Buffer Pool,每个小的Buffer Pool都称为一个实例,它们都是独立的去申请内存空间,独立的管理各种链表。
可以在MySQL配置文件中通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数,如下:

  1. [server]
  2. innodb_buffer_pool_instances = 4

项目中MySQL实例的Buffer Pool实例的个数就是4。

1.3.2 Buffer Pool的组成单位chunk

Buffer Pool的大小只能在服务器启动时通过配置innodb_buffer_pool_size启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过设计MySQL 5.7.5以及之后的版本中支持了在服务器运行过程中调整Buffer Pool大小的功能,但是有一个问题,就是每次当我们要重新调整Buffer Pool大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool中的内容复制到这一块新空间,这是极其耗时的。所以InnoDB存储引擎不再一次性为某个Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个所谓的chunk为单位向操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk实例组成的,一个chunk就代表一片连续的内存空间,chunk也是由缓存页和控制块组成的。
Buffer Pool实例和chunk实例如下图所示:
image.png
上图代表的Buffer Pool就是由2个实例组成的,每个Buffer Pool实例中又包含2chunk实例。
我们在服务器运行期间调整Buffer Pool的大小时就是以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,chunk的大小是我们在启动操作MySQL服务器时通过innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。

2、free链表

2.1 free链表的组成

第一节中介绍了Buffer Pool实例就是操作系统中一片连续的内存空间,那缓存下来的页是如何在Buffer Pool中缓存的呢?怎么区分Buffer Pool中哪块内存被使用了,哪块内存没被使用呢?
控制块里记录了缓存页的描述信息,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个双向链表中,这个双向链表被称作**free**链表(或者说空闲链表)。刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,如下图所示:
image.png
每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了。
一句话:free链表是一个双向链表,由空闲缓存页对应的控制块组成,每一个控制块里包含了**pre****next**指针。

2.2 缓存页的哈希处理

我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中的话直接使用就可以了。那么问题来了,我们怎么知道该页在不在Buffer Pool中呢?难不成需要依次遍历Buffer Pool中各个缓存页么?对缓存页生成一个哈希表可以解决这个问题。
我们可以用表空间序号页号来唯一确定一个页,因此可以构建一个哈希表,key是表空间序号 + 页号,value是缓存页。在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

3、flush链表

如果修改了Buffer Pool中某个缓存页中的数据,就和磁盘上对应页不一致了,这样的Buffer Pool中的缓存页叫做脏页dirty page)。为了避免频繁往磁盘中写入数据影响系统性能,InnoDB会周期性的将脏页上的修改刷新到磁盘中对应的索引页。为了知道同步刷新时哪些是脏页,InnoDB创建了一个存储脏页数据的链表,这个链表就是**flush**链表。
flush链表也是一个双向链表,节点是脏页对应的控制块,每个节点包含着pre和next指针,如下图所示:
image.png

4、LRU链表

4.1 LRU链表简介

一个Buffer Pool实例上的存储空间也是有限的,即使可以通过innodb_buffer_pool_size参数结合机器情况设置的很大,因此当Buffer Pool中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页,如何确定哪些缓存页最近不经常使用呢?LRU链表就是用来解决这个问题的。
LRU链表也是一个双向链表,由已经使用了的缓存页对应的控制块(链表节点)组成,该链表是根据最近最少使用规则去淘汰缓存页的。当我们需要访问某个缓存页时,对LRU链表做如下操作:

  • 如果该页不在Buffer Pool中,把该页从磁盘加载到Buffer Pool中的缓存页中,将缓存页对应的控制块作为节点在LRU链表进行头插
  • 如果该页已经在Buffer Pool中了,就将该页在Buffer Pool中的缓存页对应的控制块移动到LRU链表的头部。

这样做,可以使经常使用或者最近使用到的缓存页对应的控制块保持在LRU链表的前端区域,当Buffer Pool中空闲的缓存页使用完时,只需淘汰掉LRU链表尾部区域对应的缓存页即可。

4.2 LRU链表的温冷存储

4.2.1 为什么需要做温冷存储

InnoDB提供了一个服务——预读(英文名:read ahead)即InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool中(就是InnoDB做了一个预判,将之后可能会读取到的页面提前加载到Buffer Pool中,这些页有可能之后被读取也有可能不被读取)。根据触发方式的不同,预读又可以分为以下两种:

  • 线性预读InnoDB提供了一个系统变量innodb_read_ahead_threshold(默认值是56),如果顺序(注意是顺序!)访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求;
  • 随机预读:如果Buffer Pool中已经缓存了某个区的**13**个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其的页面到Buffer Pool的请求。

预读本来是个好事儿,如果预读到Buffer Pool中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到LRU链表的头部,如果此时Buffer Pool的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表尾部的一些缓存页会很快的被淘汰掉,会大大降低缓存命中率。

当查询语句是全表扫描时(比如没有where查询条件或者没有建立合适的索引),此时会将表所在的所有的页中的数据都加载到Buffer Pool中,这就意味着Buffer Pool中的数据可能在短时间内就会被全部换血一次,相当于之前缓存的数据会被迅速淘汰掉,大大降低缓存命中率。

4.2.2 LRU链表温冷区域

为了解决上面的情况,InnoDB存储引擎对LRU链表做了优化,将LRU链表按照一定比例分为两部分,如下:

  • **young**区域(热数据):这部分在LRU链表前端区域,存储使用频率非常高的缓存页的控制块;
  • **old**区域(冷数据):这部分在LRU链表尾部区域,存储使用频率不高的缓存页对应的控制块。

这个比例也是在InnoDB的系统变量里设置的,可以通过以下语句查询这个值:

  1. SHOW VARIABLES LIKE 'innodb_old_blocks_pct';

也可以在MySQL的配置文件中设置这个值,如下:

  1. [server]
  2. innodb_old_blocks_pct = 40

该值默认为37,即热数据占LRU链表的前37%部分。

LRU链表带温冷区分的结构图如下:
image.png

对于4.2.1中提到的两个问题,使用温冷数据处理后的LRU链表是如下过程:
(1)针对预读页面可能不进行后续访问情况的优化
当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块不再直接头插到LRU链表的头部,而是会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问的页面就会被逐渐从old区域逐出,而不会影响young区域中被频繁使用的缓存页。
(2)针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化
在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内(因为全表扫描会反复对一个页中的不同记录访问,会频繁地访问同一个缓存页),那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的,默认值为1000,即1秒。

5、刷新脏页到磁盘

第三小节介绍了flush链表,InnoDB会起一个工作线程周期性地将脏页刷新到磁盘中,刷新的路径主要有以下2种:

  • 从LRU链表的old区域(冷数据)中刷新一部分页到磁盘中,这种方式被称为BUF_FLUSH_LRU,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定;
  • 从flush链表中刷新一部分页到磁盘中,这种方式被称为BUF_FLUSH_LIST

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