Innodb 的 Buffer Pool

一、啥是Buffer Pool

        为了缓存磁盘中的页,设计mysql的大叔在mysql服务启动时就向操作系统申请了一片连续的内存,他们给这片内存起了个名字叫做buffer pool(缓冲池)。他有多大呢?这个其实取决与我们机器的配置:如果你是土豪,有512G内存,分配个几百G作为buffer pool当然没有问题,如果没有钱,设置少点也问题不大。默认情况下,buffer pool 只有128M,如果嫌弃128M太大或者太小的话,可以在启动服务器时候配置innodb_buffer_pool_size启动选项。

innodb_buffer_pool_size=168435456

        innodb_buffer_pool_size的单位是字节,所以上面的配置指定的buffer pool的大小为256M。需要注意的是,boffer pool也不能太小,最小值为5M,如果设置的buffer pool比5M还小时,mysql会自动设置为5M。

二、buffer pool内部组成 

        buffer pool对应的一片连续的内存被划分为若干个页面,页面大小与innodb表空间使用的页面大小一致,默认都是16KB。为了与磁盘中的页面区分开来,我们这里把这些buffer pool的页面叫做缓冲页,为了更好管理这些缓冲页,设计mysql的大叔为每一个缓存页都创建了一些控制信息。这些控制信息包括改页所属的表空间编号、页号、缓冲页在buffer pool中的地址、链表节点信息等。除了这些信息之外,当然还有一些别的控制信息,这里暂时不说。

        每个缓冲页对应的控制信息占用的内存大小是相同的,我们把每个页对应的控制信息占用的一块内存成为一个控制块。控制块与缓存页是一一对应的,他们都存放在buffer pool中,其中控制块存放在buffer pool的前面,缓冲页放在buffer pool的会面,所以整个buffer pool对应的内存空间看起来如下图:

d01cb74b676a4cfda2c1ae240ad77a96.png

        咦?控制块和缓冲页之间的那个碎片是什么玩意儿?大家想想看,每一个控制块都对应一个缓冲页,那么在分配足够多的控制块和缓冲页后,剩下的那点空间可能不够一对控制块和缓冲页,自然也就用不到了。这个用不到的内存空间就称为碎片。当然,如果把buffer pool的大小设置的刚刚好,也可能不会产生碎片。

        在debug模式下,每个控制块大约占用缓冲大小的5%,非debug模式下会小一点,在mysql5.7.22版本的debug模式下,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,就是说innodb在为buffer pool向操作系统申请连续的内存空间时,这片连续的内存空间会比innodb_buffer_pool_size的值大5%左右。

三、free 链表的管理

注意:一个控制块和缓冲页不可能同时在free链表和flush链表。

        free链表其实就是用来存储空闲的控制块的。当我们最初启动mysql服务器的时候,需要完成buffer pool 的初始化过程。就是先向操作系统申请buffer pool的内存空间,然后把它划分成若干对控制块和缓冲页。但是此时并没有真实的磁盘页缓存到buffer pool中(因为还没执行过查询),之后随着程序的运行,会不断有磁盘上的页被缓存到buffer pool 中。

        那么问题来了,从磁盘上读取一页到buffer pool中时,该放到哪个缓存页的位置呢?或者说怎么区分buffer pool中有哪些缓冲页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录buffer pool中哪些缓冲页是可用的。这个时候缓冲页对应的控制块就派上用场了,我们可以把所有空闲的缓冲页对应的控制块作为一个节点放到一个链表只,这个链表也可以称为free链表或者说是空闲链表。刚刚初始化完的buffer pool中,所有的缓冲页都是空闲的,所以每一个缓冲页对应的控制块都会加入到free链表中。假设该buffer pool中可容纳的缓冲页数量为n,那么增加了free的效果图以下:

b6fe117ba75a430fba25bd5545766dfe.png

        上图可以看出,为了管理好这个free链表,我们特意为这个链表定义了一个基节点,里面包含了链表的头节点地址和尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为buffer pool申请的一大片连续内存空间之内,而是一块单独申请的内存空间。

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

缓冲页的hash处理

        当我们需要访问某个某个页中的数据时,就会把改业从磁盘加载到buffer pool中。如果该页已经在buffer pool中的话,直接使用就可以了。那么问题来了,我们怎么知道该页在不在buffer pool中呢?难不成需要依次遍历buffer pool中的各个缓冲页么?一个buffer pool中的缓冲页这个多,都遍历完岂不是累死?

        再回头想想,我们其实可以根据表空间好+页号来定位一个也得,也就是相单于表空间号+页号是一个key,缓冲页控制块就是对应的value,怎么通过一个key来快速去找到一个value呢?当然是哈希表了!

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

四、flush链表管理

        flush链表其实就是用来存储已经被修改过的页,并且未来得及写入磁盘的控制块或者叫缓冲页。

        如果我们修改了buffer pool中的某个缓冲页的数据,它就与磁盘上的页不一致了,这样的缓冲页页称为脏页。当然,我们也可以每当修改完某个缓冲页时,就立即将其刷到磁盘中对应的页上。但是频繁地往磁盘中写数据会影响程序的性能。所以每次修改完缓冲页后,我们并不着急立即把修改刷到磁盘上,而是在未来的某个时间点进行刷盘。

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

五、LRU链表管理

1、缓冲区不够的窘境

        buffer bool 对应的内存大小毕竟是有限的。如果需要缓存的页占用的内存大小超过了buffer pool的大小,也就是free链表中已经没有多余的空闲缓冲区了,这岂不是很尴尬!发生了这样的事儿该咋办?当然是把某些旧的缓冲页从buffer pool中移除,然后把新的页放进去,那么问题来了,移除哪些缓冲页呢?

        为了回答这个问题,我们还需要回到设立buffer pool的初衷---想减少磁盘i/o,最好每次在访问某个页的时候它已经在buffer pool中了。假设我们一共访问了n次页,那么被访问的页已经在buffer pool中的次数除以n就是buffer pool命中率。我们的期望是buffer pool命中率越高越好。从这个角度出发,回想一下我们的微信聊天记录,排在前面的都是最近频繁联系的,排在后面的自然就是最近很少联系的。加入列表能容纳的联系人有限,你是吧最近很频繁使用的留下,还是把最近很少使用的留下呢?当然是留下最近很频繁使用的人啦。

2、简单的LRU链表 

        管理好buffer pool的缓冲页其实就是这个道理。当buffer pool中不再有空闲的缓冲页时,就需要淘汰最近很少使用的部分缓冲页。不过,我们怎么知道哪些缓冲页最近频繁使用,哪些最近很少使用呢?神奇的链表再一次派上用场。我们可以再创建一个链表,由于这个链表是为了按照最近很少使用的原则去淘汰缓冲页的,所以这个链表被称为LRU链表、当需要访问某个页时,可以按照下面的方式处理lru链表:

  • 如果该页不在buffer pool中,,在把该页从磁盘加载到buffer pool中的缓冲页时,就把该缓冲页对应的控制块作为节点塞到lru链表的头部;
  • 如果改业已经被加载到buffer pool中,则直接把该页对应的控制块移到LRU链表的头部。

也就是说,只要我们使用到某个缓冲页,就把该缓冲页调整到LRU链表的头部,这样LRU链表尾部就是最近很少使用的缓冲页。所以,让buffer pool中空闲缓冲页使用完时,到LRU链表的尾部找些缓冲页淘汰就ok了。真简单!

3、划分区域的LRU链表

         我们高兴的太早了。上面的这个简单LRU链表用了没多长时间就出问题了,它存在下面这两种比较尴尬的问题:

  • 情况1:innodb提供了一个看起来比较贴心的服务--预读,我们前边说过只有当我们用到某个页的时,才会将其从磁盘加载到buffer 中,用不到则不加载。

所谓预读,就是InnoDB 认为执行当前的请求时,可能会在后面读取某些页面于是就预先把这些页面加载到 Buffer Poo中。根据触发方式的不同,预读又可以细分为下面两种:

  • 线性阅读,设计InnoDB 的大叔提供系统变量 innodb_read_ahead_threshold 如果顺序访问的某个区的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 中的请求。注意异步读取意味着从磁盘中加载这些被预读的页面时,并不会影响到当前工作线程的正常执行。inmodb read_abead_threshold 系统变量的值默认是 56。
  • 随机预读:如果某个区的13个连续的页面都被加载到了Buffer Pool中,页面是不是顺序读取的,都会触发一次异步读取本区中所有其他页面到Bu中的请求。设计 InnoDB的大叔同时提供了innodb_random_read_ahead系它的默认值为OFF,也就意味着InnoDB并不会默认开启随机预读的功能开启该功能,可以通过修改启动选项或者直接使用SET GLOBAL命令把值设置为ON。

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

  • 情况2:有的小伙伴可能会写一些需要进行全表扫描的语句(比如在没有建索引或者压根儿没有WHERE子句的查询时)。

全表扫描意味着什么?意味着将访问该表的聚簇索引的所有叶子节点对应的扫描叶子节点时,首先需要从B+树中定位到第一个叶子节点的第一条记录。这个访问一些内节点)!如果需要访问的页面特别多,而Buffer Pool又不能全部容纳这就意味着需要将其他语句在执行过程中用到的页面“排挤”出Buffer Pool,之语句重新执行时,又需要重新将需要用到的页从磁盘加载到Buffer Pool中(这就个饭店吃着好好的,忽然来了一群人把我从饭店中赶了出去,等他们吃完之后我又菜吃)。
我们在业务中一般不对很大的表执行全表扫描操作,这是一个很耗时的操作,只场景下偶尔对很大的表执行全表扫描操作。由于对很大的表执行全表扫描操作可能要Pool中的缓冲页换一次,这会严重影响到其他查询对Buffer Pool的使用,从而降Pool命中率,可能降低Buffer Pool命中率的两种情况如下所示:

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

因为这两种情况的存在,设计InmoDB的大叔把这个LRU链表按照一定的比例分为两截:

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

需要特别注意的一点是,我们是按照某个比例将LRU链表分为两半,而不思某些节点固定位于young区域,某些节点固定位于old区域。随着程序的运行,某个节点所属的区域页可能发生变化。那么,这个划分成两截的比例是怎么确定的呢?对于innodb存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pck的值来确定old区域在LRU链表中所占的比例。

        有了这个被划分为young和old区域的LRU链表之后,设计innodb的mysql大叔就可以针对前面提到的两种可能降低buffer pool命中率的情况进行优化了:

  • 针对于预读的页面可能不进行后续的访问优化。设计mysql的大叔规定,当磁盘上的某个页面被初次加载到buffer pool中的某个缓冲页时,该缓冲页对应的控制块会被放到old区域。这样依赖,预读到buffer pool却不进行后续的访问的页面就会被逐渐从old区域逐出,而不影响young区域中使用比较频繁的缓冲页。
  • 针对全表扫描时,短时间内访问大量使用频繁非低的页面的优化。在进行全表扫描时,虽然首次加载到buffer pool中的页放到old区域的头部,但是后续会被马上访问,每次进行访问时又会把该页放到young的头部,这样仍然会把那些使用比较频繁的页面排挤下去。有点人就会想:是否可以在第一次访问该页面时不将其从old区域移到young区域的头部,而是在后续访问时再将其移动到young区域的头部?回答是行不通!因为设计innodb的大叔规定,每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能存在多条记录,也就是说读取完某个页面的记录相当于访问了这个页面好多次。

        咋办?全表扫描有一个特点,那就是它的执行频率比较低,谁也不会没事写全表扫描的语句玩。而且在执行全表扫描的过程中,即使某个页面中有很多条记录,尽管每读取一条记录都算是一次访问,但是这个过程所花费的时间也是非常少的。所以我们只需要规定,在某个处于old区域的缓冲页进行第一次访问时,就在它对应的控制块中记录下这个访问时间,如果后续的访问时间与第一次访问时间在某个间隔内,那么该页面就不会从old区域移到young区域的头部,否则将他移到young区域的头部,这个时间间隔是有系统变量innodb_old_blocks_time控制的。

        这个innodb_old_blocks_time变量的默认值是1000ms,也意味着对于从磁盘加载到LRU链表中old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s,那么改页不会加入到young区域中。很明显,在一次全表扫描的过程中,多次访问一个页面(也就是读取同一个页面的多条记录)的时间不会超过1s。当然,与innodb_old_blocks_pct一样,我们也可以在服务器启动时设置。

        这就完了吗?没有,还早呢?对于young区域的缓冲页来说,我们每次访问一个缓冲页就要把它移到LRU链表的头部,这样开销是不是太大了。毕竟在young区域的缓冲页都是热数据,也就是可以经常被访问。这样频繁的对LRU链表执行节点的移动操作是不是不太好?是的,为了解决这个问题,其实我们还可以提出一些优化策略,比如我们只有被访问到的缓冲页位于young区域的1/4的后面时,才会被移动到LRU链表头部。这样就可以减低调整LRU链表的频率。从而提升性能

4、刷新脏页到磁盘

        后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘。这样可以不影响用户现场处理正常的请求。刷新方式有以下两种:

  • 从LRU链表的冷数据中刷新一部分页面到磁盘。后台线程会定时从LRU链表尾部开始扫描一些页面,扫描页面数量可以通过系统变量innodb_lru_scan_depth来指定。如果在LRU链表中发现脏页,则把他们刷新到磁盘,这种刷新页面的方式成BUF_FLUSH_LRU。
  • 从flush链表中刷新一部分页面到磁盘。后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的数量取决于当时系统是否繁忙。这种刷页方式称为BUF_FLUSH_LIST。
  • 有时,后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到buffer pool中时没有可用的缓冲页。这时就会尝试查看LRU链表尾部,看是否存在可以直接释放掉的未修改缓冲页。如果没有,则不得不将LRU链表尾部的一个脏页同步刷新到磁盘(与磁盘交互是很忙的,这样会降低用户请求的速度)。这种将单个页刷新到磁盘中的刷新方式称为BUF_PLUSH_SINGLE_PAGE。当然,在系统特别繁忙时,也可能出现用户线程从flush链表中刷新脏页的情况,很显然,处理用户请求的过程中去刷新脏页是一种严重降低处理速度的行为(毕竟磁盘的速度太慢了)。这属于是一种迫不得已的情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值