细说MySQL中磁盘与CPU的交互——神秘的Buffer Pool

1.MySQL是如何读取记录的——缓存的重要性

对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚集索引和非聚集索引),还是各种系统数据,都是以页的形式存放在磁盘上,而CPU与内存的交互远远快于与磁盘的交互,所以InnoDB存储引擎在处理客户端的请求时,如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中,也就是说,即使我们只需要访问一个页中的一条记录,也需要先把整个页的数据加载到内存中。

将整个页中的数据加载到内存中,就可以进行读写访问了,在进行读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样下次有请求再次访问该页面时,就可以省去磁盘I/O的开销了。

1.1 局部性原理

上面提到,即使我们只需要访问一个页中的一条记录,也需要先把整个页的数据加载到内存中。这个页大小是多少呢?答案是16kb。可以使用以下命令查看

show VARIABLES like 'innodb_page_size'

在这里插入图片描述
可能有人会问了,为什么不直接读出一条数据,而是把一个页的数据都读出来呢?其实这涉及到操作系统里的知识。一个是磁盘预读。另外一个是局部性原理

什么是磁盘预读?

磁盘预读:内存跟磁盘在进行的交互的时候,有一个最基本的逻辑单位称之为页,也叫datapage,大小一般是4k或者8k,我们在进行数据读取的时候,一般读取的是页的整数倍

什么是局部性原理?

  • 时间局部性:之前被访问过的数据,很有可能很快会被再次访问。
  • 空间局部性:数据和程序都要聚集成群的倾向,具备某些特征的数据可以放在一起。

2. InnoDB的Buffer Pool

2.1 什么是Buffer Pool

为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,并给这片内存起了个名——Buffer Pool。默认情况下Buffer Pool只有128M大小。

show variables like 'innodb_buffer_pool_size'

在这里插入图片描述
innodb_buffer_pool_size的单位是字节,134217728 Byte=128MB

如果你想修改大小,可以在启动服务器的时候配置innodb_buffer_pool_size参数的值,它表示Buffer Pool的大小,就像这样:

[server]
innodb_buffer_pool_size = 268435456

上面指定Buffer Pool大小是256M,注意,Buffer Pool不能太小,最小值是5MB,即使innodb_buffer_pool_size的值小于5MB,也会被自动设置成5MB

2.2 Buffer Pool的内部结构

Buffer Pool 对应的一片连续的内存被划分为若干个页,页大小默认是16KB,我们前文说过,页是磁盘与内存之间交互的基本单位,为了将磁盘中的页和Buffer Pool中的页区分开,我们把Buffer Pool中的页称为缓存页

为了更好的管理这些在Buffer Pool中的缓存页,InnoDB在每一个缓存页都创建了一些控制信息,这些控制信息包括该页所属的表空间编号,页号,缓存页在Buffer Pool中的地址,链表节点信息等等。

每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个控制块。控制块和缓存页是一一对应的,它们都被存放到Buffer Pool中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来就是这样的:

在这里插入图片描述
细心的小伙伴会发现,上图的控制块和缓存页之间还有个碎片。因为每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,剩余的那点儿空间可能不够一对控制块和缓存页的大小,自然就用不到了,这个用不到的内存空间就被称为碎片。当然,如果你把Buffer Pool的大小设置的刚刚好的话,也可能不会产生碎片。

注意:在DEBUG模式下,每个控制块大约占用缓存页大小的5%(非DEBUG模式下会更小一点),在MySQL 5.7.22这个版本的DEBUG模式下,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值5%左右。

2.3 free链表

在我们最初启动MySQL服务器的时候,需要完成对Buffer Pool的初始化过程,就是先向操作系统申请Buffer Pool的内存空间,然后把它划分成若干对控制块缓存页的位置(用来缓存后续从磁盘读取的页)。但是此时并没有真实的磁盘页被缓存到Buffer Pool中(因为还没有用到),下次查询某条记录时,如果Buffer Pool中没有这个磁盘页,那么包含这条记录的磁盘页就会被缓存到Buffer Pool中。

问题来了,从磁盘上读取一个页到Buffer Pool中的时候,该放到哪一个缓存页的位置呢?怎么知道Buffer Pool中哪些缓存页是空闲的,哪些已经被使用了呢?所以需要记录一下Buffer Pool中哪些缓存页是可用的。这个时候控制块就派上大用场了——我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样的:
在这里插入图片描述
为了管理好这个free链表,特意为这个链表定义了一个基节点,里边包含着链表的头节点地址,尾结点地址以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool申请的一大片连续的内存空间之内,而是单独申请的一块内存空间。

链表基节点占用的内存空间并不大,在MySQL 5.7.22中,每个基节点只占用40字节,后面会介绍的flush链表、LRU链表的基节点也是一样,它们的基节点在内存分配方式上与free链表的基节点一样,都是一块单独申请的40字节的内存空间,并不包含在为Buffer Pool申请的一大片连续内存空间之内。

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

综上所述,free链表是干什么的?

free链表是用来记录Buffer Pool中哪些缓存页是空闲的,空闲的缓存页对应的控制块就是free链表中的节点,有了空闲的缓存页,才能将读取的磁盘页缓存起来。

2.4 如何知道磁盘页在Buffer Pool是否已存在——缓存页的hash

当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中的话就不用从磁盘读取了,这样可以加快速度。

怎么知道该页在不在Buffer Pool中呢?总不能依次遍历Buffer Pool中各个缓存页吧?其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key,缓存页就是对应的value,要通过一个key来找一个value,哈希表就能解决 。

你查找这个表,表空间号一定知道,关于页号查找过程,下面马上会讲。

补充知识:

  • 表空间中的每一个页都对应着一个页号,也就是FIL_PAGE_OFFSET,我们可以通过这个页号在表空间中快速定位到指定页面,这个页号由4个字节组成,也就是32个比特位,所以一个表空间最多可以拥有2³²个页,如果按照页的默认大小16KB来算,一个表空间最多支持64TB的数据。表空间的第一个页的页号为0,之后的页号分别是1,2,3...依此类推。

写到这里,我自己也在思考一个问题,当sql需要查询某条记录时,首先会去判断这条记录所属的页是在磁盘上还是已经被加载到内存中的Buffer Pool,具体过程是怎么样的?

  1. sql从磁盘中读取一页到内存的时候,页号表空间号就已经知道了(页结构的File Header部分存储页的各种状态信息,其中有2个状态变量这里要关注。FIL_PAGE_OFFSET就是记录的页号,FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID就是表示页属于哪个表空间)。
  2. 再利用表空间+页号就可以hash定位到该页是否在Buffer Pool中,如果在Buffer Pool中,直接从Buffer Pool中返回记录,如果不在,那么从磁盘读取该页,然后缓存到Buffer Pool,再返回记录。

如果你不知道怎么从B+树查找记录对应的页,建议阅读我的前面的基础博文图文并茂说MySQL索引——入门进阶必备

简单描述一下怎么从B+树查找记录对应的页,直接从磁盘B+树的根节点往下找,利用二分缩小查找范围,最后到对应范围的页去进行槽点二分查找,确定这个页有没有这条记录,如果有,就直接返回这个页。

总结一下:
我们可以用表空间号+页号作为key缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号+页号看看有没有对应的缓存页,如果有直接使用该缓存页就好,如果没有,就从free链表中选一个控制块,找到对应的空闲缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

2.5 flush链表

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

但是,如果不立即将修改刷新到磁盘,那之后再刷新的时候我们怎么知道Buffer Pool中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都刷新到磁盘上吧,假如Buffer Pool被设置的很大,那一次性刷新这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是被修改过的缓存页对应的控制块都会作为一个节点加入到这个链表中。因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多,那么对应的flush链表就长这样

在这里插入图片描述
综上所述,flush链表是用来干啥的?

flush链表是一个存储脏页对应控制块的链表,被修改过的缓存页对应的控制块都会作为一个节点加入到这个链表中,如果一个缓存页是空闲的,那它肯定不可能是脏页。如果一个缓存页是脏页,那它肯定就不是空闲的。所以,某个缓存页对应的控制块不可能既是free链表的节点,又是flush链表的节点,只能二者居其一。

2.6 LRU链表

2.6.1 Buffer Pool不够怎么办

Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,这样该怎么办?当然是把某些旧的缓存页Buffer Pool移除,然后再把新的页放进来。应该移除哪些旧的缓存页吗?

设计Buffer Pool的初衷就是想减少和磁盘的I/O,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool中了。假设我们一共访问了n次页,页已在缓存中的次数除以n就是所谓的缓存命中率,缓存命中率当然是越高越好。 比如我们的微信最近聊天列表,排在前边的都是最近很频繁使用的,每聊天一次就将这个聊天会话项排在了最前面,假如最近聊天列表能容纳下的会话数有限,你是会把最近聊天很频繁的留下还是最近很少聊天的留下呢? 当然那些聊天少的就给移除了,Buffer Pool管理缓存页也是如此。

2.6.2 简单的LRU链表

Buffer Pool中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的部分缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?

我们可以再创建一个链表,和上面free链表和flush链表结构图类似,这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU链表(Least Recently Used。当需要访问某个页时,可以按照下面的方式处理LRU链表:

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

也就是说,只要我们使用到某个缓存页,就把该缓存页对应的控制块调整到LRU链表的头部,这样LRU链表尾部就是最近最少使用的缓存页了。 所以当Buffer Pool中的空闲缓存页使用完时,到LRU链表的尾部找些缓存页对应的控制块,将这些控制块和对应缓存页淘汰就OK了。

2.6.3 划分区域的LRU链表(内容难度提升 ↑)

上面只是简单的LRU链表,用了没多长时间就会发现问题,因为存在这两种比较尴尬的情况:

  • 情况一:我们上面说过,只有当我们用到某个页时,才会将其从磁盘加载到Buffer Pool中,用不到则不加载,而InnoDB提供了预读(read ahead)的功能,所谓预读,就是InnoDB认为执行当前的请求时,可能会在后面读取某些页面,于是就预先把这些页面加载到Buffer Pool中,根据触发方式的不同,预读又可以细分为下边两种:

    • 线性预读:InnoDB提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求。注意异步读取意味着从磁盘中加载这些被预读的页面,并不会影响到当前工作线程的正常执行。这个innodb_read_ahead_threshold系统变量的值默认是56,我们可以在服务器启动时通过启动选项来调整该值,或者在服务器运行过程中直接调整该系统变量的值,由于它是一个全局变量,因此要使用SET GLOBAL命令来修改。
    • 随机预读:如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到Buffer Pool的请求,InnoDB同时提供了innodb_random_read_ahead系统变量,它的默认值为OFF,也就意味着InnoDB并不会默认开启随机预读的功能,如果想开启该功能,可以通过修改启动选项或者直接使用SET GLOBAL命令把该变量的值设置为ON

扩展了解
InnoDB是怎么实现异步读取的呢?在Windows或者Linux平台上,可能是直接调用操作系统内核提供的AIO接口,在其它类Unix操作系统中,使用了一种模拟AIO接口的方式来实现异步读取,其实就是让别的线程去读取需要预读的页面。

InnoDB提供预读本来是想提高效率的,如果预读到Buffer Pool中的页用不到呢?原本这些预读的页的控制块先会放到LRU链表的头部,但是如果此时Buffer Pool 的容量不大,而且很多预读的页面都没有用到的话,随着数据的不断读取,这将会导致LRU链表头部预读的缓存页的控制块很快会到尾部,这意味着这些控制块和对应的缓存页很快会被淘汰掉,从而大大降低Buffer Pool命中率。

  • 情况二:如果写了一些需要全表扫描的查询语句(比如没有建立合适的索引或者没有where子句的查询)

    全表扫描意味着将访问该表的聚集索引的所有叶子节点对应的页(由于需要找到第一个叶子节点,首先从B+树的根节点一步步定位到第一个叶子节点的第一条记录,这个过程不得不访问少量的非叶子节点,这个细节问题大家可以注意一下。)!如果访问的页面特别多,而Buffer Pool又不能全部容纳它们的话,这就意味着需要将其他语句在执行过程中用到的页面移出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存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct的值来确定old区域在LRU链表中所占的比例,比方说这样:

show variables like 'innodb_old_blocks_pct'

在这里插入图片描述
从结果可以看出来,默认情况下old区域在LRU链表中所占的比例是37%,也就是说old区域大约占LRU链表的3/8。这个比例我们是可以设置的,我们可以在启动服务器时修改innodb_old_blocks_pct启动选项来控制old区域在LRU链表中所占的比例,比方在配置文件中输入如下:

[server]
innodb_old_blocks_pct = 40

这样我们在启动服务器后,old区域占LRU链表的比例就是40%。当然,在服务器运行期间也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量,所以我们要使用SET GLOBAL命令来修改:

SET GLOBAL innodb_old_blocks_pct = 40;

有了这个被划分成youngold区域的LRU链表之后,InnoDB就可以针对我们上边提到的两种可能降低Buffer Pool命中率的情况进行优化了。

(下面的2条优化项有点难以理解,需要大家多读2遍)

  • 针对预读的页面的处理

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

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

InnoDB规定,每次去页面中读取一条记录时,都算是访问一次页面。

在进行全表扫描时,首次被加载到Buffer Pool中的页被放到了old区域的头部,而一个页面中可能会包含很多条记录,读取完某个页面的记录就相当于访问了这个页面好多次,并且这些记录顺序访问时间间隔非常短,也就是InnoDB会认为old区域短时间内某一页被多次访问了。

这种情况是短时间内频繁访问同一页面,而后续不在访问,如果此时把这种页放到yound区域的头部,会把哪些真正使用频率比较高的页面给顶下去。这样会降低Buffer Pool的命中率就像一个新玩具,前几天每天都玩,后续这个玩具放在一旁不再使用,那么不能认定你喜欢玩这个玩具)。

所以我们规定,计算上次访问old区域的时间戳本次访问的时间戳如果小于某一时间间隔,该页面就不会从old区域移动到young区域的头部,否则将它移动到young区域的头部。每次访问old区域后就刷新时间戳,方便下次计算时间间隔。

上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的,默认是1000 ms

show variables like 'innodb_old_blocks_time'

在这里插入图片描述
对于从磁盘上被加载到LRU链表的old区域的某个页来说,如果第一次(就是最后一次的上一次)和最后一次访问该页面的时间间隔小于1sinnodb_old_blocks_time的值),那么该页是不会被加入到young区域的,很明显在一次全表扫描过程中,多次访问同一个页面(也就是读取同一个页面中的多条记录)的时间不会超过1s

如果我们把innodb_old_blocks_time的值设置为0,那么每次我们访问一个页面时就会把该页面放到young区域的头部。

综上所述

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

2.6.4 进一步优化LRU链表

对于young区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表的头部,开销还是有点大,毕竟在young区域的缓存页都是高热度的页,也就是可能被经常访问的,这样频繁的对LRU链表进行节点移动操作不太好。

为了解决这个问题其实我们还可以提出一些优化策略,比如某个缓存页对应的节点在young区域的前1/4时,再次访问该缓存页时也不会将其移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能。(你可以认为young区域的前1/4是超高热度的页,后3/4是普通热度的页,超高热度的页之间不必来回切换。)

注意: 上面2.6.3介绍随机预读的时候提到,如果Buffer Pool中有某个区的13个连续页面就会触发随机预读,这其实是不严谨的,其实还要求这13个页面是超高热度的页面,也就是指的是这些页面在整个young区域的头1/4处。

只要从磁盘中加载一个页面到Buffer Pool的一个缓存页中,该缓存页对应的控制块就会作为一个节点加入到LRU链表中,这样一来,该缓存块对应的控制块就不在free 链表中了,flush链表中的节点(控制块)肯定也是LRU链表中的节点。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值