缓存的重要性
对于使用InnoDB存储引擎的表来说,无论是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中。所谓的表空间,只不过是InnoDB对一个或几个实际文件的抽象。也就是说,我们的数据说到底还是存储在磁盘上。所以,InnoDB存储引擎在处理客户端请求时,如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中。也就是说,即使只需要访问一个页的一条记录,也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,而且在读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省下磁盘IO的开销了。
InnoDB的Buffer Pool
Buffer Pool
为了缓存磁盘中的页,MySQL在服务器启动的时候就向操作系统申请了一片连续的内存,这部分内存叫Buffer Pool,默认情况下Buffer Pool只有128M。可以在启动服务器的时候配置innodb_buffer_pool_size启动选项(这个启动选项表示Buffer Pool的大小)的值,innodb_buffer_pool_size的单位是字节。需要注意的是,Buffer Pool也不能太小,最小值为5MB。
Buffer Pool内部组成
Buffer Pool对应的一片连续的内存被划分为若干个页面,页面大小与InnoDB表空间使用的页面大小一致,默认是16KB,为了与磁盘中的页面区分开来,我们把这些Buffer Pool中的页面称为缓冲页。MySQL为了更好地管理Buffer Pool中的这些缓冲页,为每一个缓冲页都创建了一些控制信息。这些控制信息包括该页所属的表空间编号、页号、缓冲页在Buffer Pool中的地址、链表节点信息等。除了这些信息之外,当然还有一些别的控制信息。这里就不一一介绍了。
每个缓冲页对应的控制信息占用的内存大小是相同的,我们把每个页对应的控制信息占用的一块内存称为一个控制块。控制块与缓冲页是一一对应的,它们都放到Buffer Pool中。其中控制块存放到Buffer Pool的前面,缓冲页存放到Buffer Pool的后面,所以整个Buffer Pool对应的内存空间看起来如下所示:
每一个控制块都对应一个缓冲页,那么在分配足够多的控制块和缓冲页后,剩余的空间可能不够一对控制块和缓冲页的大小,自然也就用不到了。这个用不到的内存空间称为碎片。当然如果把Buffer Pool的大小设置的刚刚好,也可能不会产生碎片。
free链表的管理
当我们最初启动MySQL服务器的时候,需要完成Buffer Pool的初始化过程。就是先向操作系统申请Buffer Poll的内存空间,然后把它划分成若干对控制块和缓冲页。但是此时并没有真实的磁盘页被缓存到Buffer Pool中,之后随着程序的运行,会不断地有磁盘上的页被缓存到Buffer Pool中。
那么问题来了,从磁盘上读取一个页到Buffer Pool中时,该放到哪个缓冲页的位置呢?我们可以把所有空闲的缓冲页对应的控制块作为一个节点放到一个链表中,这个链表也可以称为free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中,所有的缓冲页都是空闲的,所以每一个缓冲页对应的控制块都会加入到free链表中。假设该Buffer Pool中可容纳的缓冲页数量为n,那么增加了free链表的效果图如下所示:
从图中可以看出,为了管理好这个free链表,我们特意为这个链表定义了一个基节点,里面包含了链表的头节点地址、尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool申请的一大片连续内存空间之内,而是一块单独申请的内存空间。
每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓冲页,并且把该缓冲页对应的控制块的信息填上(就是该页所在的表空间,页号之类的信息),然后把该缓存页对应的free链表节点(也就是对应的控制块)从链表中移除,表示该缓冲页已经被使用了。
缓冲页的哈希处理
当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中了直接使用就可以了。我们使用表空间号+页号来定位一个页的,也就相当于表空间号+页号是一个key,缓冲控制块就是对应的value值。所以我们可以用表空间号+页号作为key,用缓冲页控制块的地址作为value来创建一个哈希表。在需要访问某个页的数据时,先从哈希表中根据表空间号+页号看看是否有对应的缓冲页,如果有,直接使用该缓冲页;如果没有,就从free链表中选一个空闲的缓冲页,然后把磁盘中对应的页加载到该缓冲页的位置。
flush链表的管理
如果我们修改了Buffer Pool中某个缓冲页的数据,它就与缓冲页上的页就不一致了,这样的缓冲页也称为脏页(dirty page)。当然,我们可以每当修改完某个缓冲页时,就立即将其刷新到磁盘中对应的页上。但是频繁地往磁盘中写数据会严重影响程序性能,所以每次修改缓冲页后,我们并不着急立即把修改刷新到磁盘上,而是在未来某个时间进行刷新,至于这个刷新的时间点会在后面进行说明。
但是不立即将修改刷新到磁盘,那之后再刷新的时候我们怎么知道Buffer Pool中那么些页是脏页,哪些页修改过。所以我们不得不再创建一个存储脏页的链表,凡是被修改过的缓冲页对应的控制块都会作为一个节点加入到这个链表中。因为这个链表节点对应的缓冲页都是需要刷新到磁盘上的,所以也称为flush链表。flush链表结构可以参考free链表。
如果一个缓冲页是空闲的,那么肯定不是脏页。如果一个缓冲页是脏页,那么它肯定不是空闲的。也就是说,某个缓冲页对应的控制块不可能既是free链表的节点,也是flush链表的节点。
LRU链表的管理
1、缓冲区不够的窘境
Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool的大小,也就是free链表中已经没有多余的空闲缓冲页了,此时需要把某些旧的缓冲页从Buffer Pool中移除,然后再把新的页放进来。那么问题来了,移除哪些缓冲页呢。
为了回答这个问题,我们还需要回到设立Buffer Pool的初衷,想减少磁盘IO,最好每次在访问某个页的时候它已经被加载到Buffer Pool中了,假设我们一共访问了n次页,那么被访问的页已经在Buffer Pool中的次数除以n就是Buffer Pool的命中率。我们的期望是Buffer Pool命中率越高越好。从这个角度出发,回想一下我们的微信聊天记录,排在前面的都是最近频繁使用的,排在后面的自然就是最近最少使用的。
2、简单的LRU链表
管理Buffer Pool的缓冲页其实也是这个道理。当Buffer Pool中不再有空闲的缓冲页时,就需要淘汰最近最少使用的部分缓冲页。不过,我们怎么知道哪些缓冲页最近频繁使用,哪些最近最少使用呢?我们可以再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓冲页的,所以这个链表可以被称为LRU(Least Recently Used)链表。当需要访问某个页时,可以按照下面的方式处理LRU链表:
- 如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓冲页时,就把该缓冲页对应的控制块作为节点塞到LRU链表的头部。
- 如果该页已经被加载到Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。
也就是说,只要我们使用到某个缓冲页,就把该缓冲页调整到LRU链表的头部,这样LRU链表尾部就是最近最少使用的缓冲页了。所以,当Buffer Pool中的空闲缓冲页使用完时,到LRU链表的尾部找到这些缓冲页淘汰掉。
3、划分区域的LRU链表
上个LRU链表有个问题。它存在下面两种情况。
- 情况1:InnoDB提供了一个看起来比较贴心的服务,预读(read ahead)我们前面说过只有当我们用到某个页时,才会将其从磁盘加载到Buffer Pool中,用不到则不加载。所谓预读,就是InnoDB认为执行当前的请求时,可能会在后面读取某些页面,于是就预先把这些页面加载到Buffer Pool中。根据触发方式不同,预读又可以细分为下面两种。
线性预读:InnoDB提供了一个变量innodb_read_ahead_threshold,如果顺序访问的某个区的页面超过这个系统的变量的值,就是触发一次异步读取下一个区中全部的页面到Buffer Pool中的请求。注意异步读取意味着从磁盘中加载这些被预读的页面时,并不会影响当前工作线程的执行。
随机预读:如果某个区的13个连续的页面都被加载到了Buffer Pool中,无论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到Buffer Pool中的请求。
如果预读到Buffer Pool中的页被成功的使用到,那就可以极大地提高语句执行的效率。可是如果用不到,这些预读的页都会放到LRU链表的头部。但是,如果此时Buffer Pool的容量不太大,而且很多预读的页面都没有用到的话,就会导致处于LRU链表尾部的一些缓冲页很快被淘汰掉,从而大大降低了Buffer Pool命中率。 - 情况2:全表扫描
全表扫描意味着将访问该表的聚簇索引的所有叶子节点对应的页(当然,扫描叶子节点时,首先需要从B+树中定位到第一个叶子节点的第一条记录。这个过程还得扫描叶子节点)如果需要访问的页面特别多,而Buffer Pool又不能全部容纳他们的话,这就意味着需要将其他语句在执行过程中用到的页面排挤出Buffer Pool中。由于对很大的表执行全表扫描操作可能把Buffer Pool中的缓冲页换一次,这会严重影响到其他查询对Buffer Pool的使用,从而降低了Buffer Pool命中率。
总之,可能降低Buffer Pool命中率的两种情况如下所示: - 加载到Buffer Pool中的页不一定会被用到;
- 如果有非常多的使用频率偏低的页被同时加载到Buffer Pool中,则可能会把那些使用频率非常高的页从Buffer Pool中淘汰掉;
因为这两种情况的存在,InnoDB把这个LRU链表按照一定比例分成两截:
- 一部分存储使用频率非常高的缓冲页;这一部分链表也称为热数据,或者称为young区域;
- 另一部分存储使用频率不是很高的缓冲页;这一部分链表也称为冷数据,或者称为old区域。
需要注意的是,我们是按照某个比例将LRU链表分成两半的,而不是某些节点固定位于young区域,某些节点固定位于old区域。随着程序的运行,某个节点所属的区域也可能发生变化。那么,这个划分成两截的比例可以通过innodb_old_blocks_pct的值来确定old区域在LRU链表中所占的比例。
默认情况下,old区域在LRU链表中所占的比例是37%。也就是说,old区域大约占LRU链表的3/8.
有了这个被划分成young和old区域的LRU链表之后,InnoDB就可以针对前文提到的两种可能降低Buffer Pool命中率的情况下进行优化了。
- 针对预读的页面可能不进行后续优化。
InnoDB规定,当磁盘上的某个页面在初次被加载到Buffer Pool中的某个缓冲页时,该缓冲页对应的控制块会放到old区域的头部。这样一来,预读到Buffer Pool却不进行后续访问的页面会被逐渐从old区域逐出,而不会影响young区域中使用比较频繁的缓冲页。 - 针对全表扫描时,短时间内大量访问使用频率非常低的页面的优化。
在进行全表扫描时,虽然首次加载到Buffer Pool中的页放到了old区域的头部,但是后续会被马上访问到,每次进行访问时又会把该页放到young的头部,这样仍然会把那些使用频率比较高的页面排挤出去。但是全表扫描有一个特点,那就是它的执行频率非常低,而且在全表扫描过程中,即使某个页面中有很多条记录,尽管每读取一条记录都算是访问一次页面,但是这个过程所花费的时间也是非常少的。所以我们只需要规定,在对某个处于old区域的缓冲页进行第一次访问的时间在某个时间间隔内,那么该页面就不会从old区域移动到young区域的头部,否则将它移动到young区域的头部。这个间隔时间可以通过系统变量innodb_old_blocks_time控制的。
综上所述,正是因为将LRU链表划分为young区域和old区域这两部分,又添加了innodb_old_blocks_time系统变量,预读机制和全表扫描造成的Buffer Pool命中率降低的问题才得到了遏制-因为用不到的预读页面以及全表扫描的页面都只会放到old区域,而不影响young区域中的缓冲页。
4、更进一步优化LRU链表
对于young区域的缓冲页来说,我们每次访问一个缓冲页就要把它移动到LRU链表的头部,这样开销太大了。毕竟在young区域的缓冲页都是热点数据,也就是可能会经常访问。为了解决这个问题,其实我们还可以提出一些优化策略,比如只有被访问的缓冲页位于young区域1/4的后面时,才会被移动到LRU链表的头部。这样就可以降低调整LRU链表的频率,从而提升性能(也就是说,如果某个缓冲页对应的节点在young区域的1/4中,再次访问该缓冲页时也不会将其移动到LRU链表头部)。
只要从磁盘中加载一个页面到Buffer Pool的一个缓冲页中,该缓冲页对应的控制块就会作为一个节点加入到LRU链表中,这样一来,该缓冲页对应的控制块也就不在free链表中了。不过flush链表中的节点(控制块)肯定也是LRU链表中的节点。
刷新脏页到磁盘
后台专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。刷新方式主要有下面两种。
- 从LRU链表的冷数据中刷新一部分页面到磁盘。
后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定。如果在LRU链表中发现脏页,则把他们刷新到磁盘。这种刷新页面的方式称为BUF_FLUSH_LRU。
一个缓冲页对应的控制块占用了很大的存储空间,其中就会存储诸如该缓冲页是否被修改的信息,所以在扫描LRU链表时,可以获取到某个缓冲页是否是脏页的信息。
- 从flush链表中刷新一部分页面到磁盘。
后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的频率取决于当时系统是否繁忙。这种刷新的方式称为BUF_FLUSH_LIST。
有时,后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool中时没有可用的缓冲页。这是就会尝试查看LRU链表尾部,看是否存在可以直接释放掉的未修改的缓冲页,如果没有。则不得不将LRU链表尾部的一个脏页同步刷新到磁盘。这种将单个页面刷新到磁盘中的刷新方式称为BUF_FLUSH_SINGLE_PAGE。
多个Buffer Pool实例
前文说过,Buffer Pool的本质是InnoDB向操作系统申请一块连续的存储内存空间。在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理。在Buffer Pool特别大并且多线程并发访问量特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大时,可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例。它们都是相互独立的,从而提高并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instance的值来修改Buffer Pool实例的个数。
不过,并不是说Buffer Pool实例创建得越多越好,分别管理各个Buffer Pool也是需要性能开销的。InnoDB规定:当InnoDB_buffer_pool_size的值小于1GB时,设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances的值修改为1。
innodb_buffer_pool_chunk_size
在MySQL 5.7.5版本之前,只能在服务器启动时通过配置innodb_buffer_pool_size启动选项来调整Buffer Pool的大小;在服务器运行过程中是不允许调整该值的。不过MySQL在5.7.5版本之后支持了在服务器运行过程中调整Buffer Pool大小的功能。但是有一个问题,就是每次调整Buffer Pool的大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧Buffer Pool中的内容复制到这一块新空间;这是极其耗时的。所以,MySQL决定不再一次性为某个Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个chunk为单位向操作系统申请空间。也就是说,一个Buffer Pool实例其实是由若干个chunk组成的。一个chunk就代表一片连续的内存空间,里面包含了若干个缓冲页与其对应的控制块。
正是因为发明了chunk的概念,我们在服务器运行期间调整Buffer Pool的大小时,就可以以chunk为单位来增加或者删除内存空间,而不需要重新向操作系统申请一大片内存,然后进行缓冲页的复制。这个chunk的大小是在启动MySQL服务器时,通过innodb_buffer_pool_chunk_size启动项指定的,默认值是134217728,也就是128MB。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中不可以修改。
另外,这个innodb_buffer_pool_chunk_size的值并不包含缓冲页对应的控制块的内存空间大小,所以实际上innodb向操作系统申请连续的内存空间时,每个chunk大小要比innodb_buffer_pool_chunk_size的值大一些(在BEBUG模式下约5%)
总结
磁盘太慢,用内存作为缓冲区很有必要。
Buffer Pool本质上是InnoDB向操作系统申请的一段连续的内存空间。可以通过innodb_buffer_pool_size来调整它的大小。
Buffer Pool向操作系统申请的连续内存空间由控制块和缓冲页组成,每个控制块和缓冲页都是一一对应的。在填充了足够多的控制块和缓冲页,从而导致这部分空间无法使用,这部分空间也称为碎片。
InnoDB使用了许多链表来管理Buffer Pool。
在free链表中,每一个节点都代表一个空闲的缓冲页,在将磁盘中的页加载到Buffer Pool中时,会从free链表中寻找空闲的缓冲页。
为了快速定位到某个页是否被加载到Buffer Pool中,可使用表空间号+页号作为key,缓冲页控制块的地址作为value的形式来建立哈希表。
在Buffer Pool中,被修改的页称为脏页。脏页并不是立即刷新的,而是加入到flush链表中,待之后的某个时刻再刷新到磁盘中。
LRU链表分为young区域和old区域,可以通过innodb_old_blocks_pct来调节old区域所占的比例。首次从磁盘加载到Buffer Pool中的页会放到old区域的头部,在innodb_old_blocks_time间隔时间内访问该页时,不会把它移动到young区域的头部。在Buffer Pool中没有可用的空闲缓冲页时,会首先淘汰掉old区域中的一些页。
可以通过指定innodb_buffer_pool_instances来控制Buffer Pool实例的个数。每个Buffer Pool实例都有各自独立的链表,互不干扰。
自MySQL5.7.5版本之后,可以在服务器运行过程中调整Buffer Pool的大小。每个Buffer Pool实例由若干个chunk组成,每个chunk的大小可以在服务器启动时通过启动选项来调整。
可以通过下面的命令来查看Buffer Pool的状态信息。
SHOW ENGINE INNODB STATUS\G