BufferPoo到底什么鬼和多个链表的关系

Buffer Pool这个内存数据结构到底长个什么样子?

​ 首先我们要知道Buffer Pool默认情况下是128MB,还是有一点偏小了,我们实际生产环境下完全可以对Buffer Pool进行调整(bufferpool分配的内存,我们建议是系统机器总内存的%50-%60)。

​ 比如我们的数据库如果是2核4G的机器,那么你就可以给Buffer Pool分配个2GB的内存,使用下面的配置就可以了。

[server]
innodb_buffer_pool_size = 2147483648

首先我们要知道,磁盘中的数据到底是以什么样的形式存在的?是以一行一行存储还是?

按照数据页存储,一个数据页里面存在多行数据

image-20200514173147742

​ 所以实际上假设我们要更新一行数据如果该行数据所在的数据页不在bufferpool中,则先从磁盘中获取数据页加载到buffer pool,如果在bufferpool则进行内存中的更新),此时数据库会找到这行数据所在的数据页,然后从磁盘文件里把这行数据所在的数据页 直接给加载到Buffer Pool里去。(为什么直接加载数据页呢?避免磁盘随机读的频率,因为磁盘随机读的效率太低
​ 也就是说,Buffer Pool中存放的是一个一个的数据页(缓存页)。

数据页包含多条数据,例如我们要更新update xxx set xxx=xxx where id=1那么此时他会把id=1这条数据所在的一页数据都加载到内存里去,这一页数据里,可能还包含了id=2,id=3等其他数据。

磁盘上的数据页和Buffer Pool中的缓存页是如何对应起来的?

​ 实际上默认情况下,磁盘中存放的数据页的大小是16KB,也就是说,一页数据包含了16KB的内容。
​ 而Buffer Pool中存放的一个一个的数据页,我们通常叫做缓存页,因为毕竟Buffer Pool是一个缓冲池,里面的数据都是从磁 盘缓存到内存去的。
​ 而Buffer Pool中默认情况下,一个缓存页的大小和磁盘上的一个数据页的大小是一一对应起来的,都是16KB

缓存页对应的描述信息是什么?

​ 接着我们要了解下一个概念,对于每个缓存页,他实际上都会有一个描述信息,这个描述信息大体可以认为是用来描述这个缓 存页的
​ 比如包含如下的一些东西:这个数据页所属的表空间、数据页的编号、这个缓存页在Buffer Pool中的地址以及别的一些杂七杂 八的东西。
​ 每个缓存页都会对应一个描述信息,这个描述信息本身也是一块数据,在Buffer Pool中,每个缓存页的描述数据放在最前面, 然后各个缓存页放在后面
​ 所以此时我们看下面的图,Buffer Pool实际看起来大概长这个样子。

image-20200514173718695

​ 而且这里我们要注意一点,Buffer Pool中的描述数据大概相当于缓存页大小的5%左右,也就是每个描述数据大概是800个字 节左右的大小,然后假设你设置的buffer pool大小是128MB,实际上Buffer Pool真正的最终大小会超出一些,可能有个130 多MB的样子,因为他里面还要存放每个缓存页的描述数据

数据库启动的时候,是如何初始化Buffer Pool的?

首先我们知道,buffer pool包含了缓存页,和么一个缓存页对应的缓存元数据信息(描述数据)。

那么在数据库启动的时候,他是如何初始化Buffer Pool的呢?

​ 其实这个也很简单,数据库只要一启动,就会按照你设置的Buffer Pool大小,稍微再加大一点,去找操作系统申请一块内存区 域,作为Buffer Pool的内存区域。

​ 然后当内存区域申请完毕之后,数据库就会按照默认的缓存页的16KB的大小以及对应的800个字节左右的描述数据的大小,在 Buffer Pool中划分出来一个一个的缓存页和一个一个的他们对应的描述数据。

​ 然后当数据库把Buffer Pool划分完毕之后,看起来就是之前我们看到的那张图了,如下图所示。

image-20200514174143081

​ 只不过这个时候,Buffer Pool中的一个一个的缓存页都是空的,里面什么都没有,要等数据库运行起来之后,当我们要对数据 执行增删改查的操作的时候,才会把数据对应的页从磁盘文件里读取出来,放入Buffer Pool中的缓存页中。

mysql 怎么维护缓存页的空闲状态? - free链表

​ 数据库会为Buffer Pool设计一个free链表,他是一个双向链表数据结构,这个free链表里,每个节点就是一个空闲的缓存页的描述数据块的地址,也就是说,只要你一个缓存页是空闲的,那么他的描述数据块就会被放入这个free链表中。
​ 刚开始数据库启动的时候,可能所有的缓存页都是空闲的,因为此时可能是一个空的数据库,一条数据都没有,所以此时所有 缓存页的描述数据块,都会被放入这个free链表中。

image-20200514174531206

​ 大家可以看到上面出现了一个free链表,这个free链表里面就是各个缓存页的描述数据块,只要缓存页是空闲的,那么他们对应的描述数据块就会加入到这个free链表中,每个节点都会双向链接自己的前后节点,组成一个双向链表。
​ 除此之外,这个free链表有一个基础节点,他会引用链表的头节点和尾节点,里面还存储了链表中有多少个描述数据块的节 点,也就是有多少个空闲的缓存页。

重要提示!!!!:

可能有的人会以为这个描述数据块,在Buffer Pool里有一份,在free链表里也有一份,好像在内存里有两个一模一样的描述数 据块,是么?

​ 其实这么想就大错特错了。

​ 这里要给大家讲明白一点,这个free链表,他本身其实就是由Buffer Pool里的描述数据块组成的,你可以认为是每个描述数据 块里都有两个指针,一个是free_pre,一个是free_next,分别指向自己的上一个free链表的节点,以及下一个free链表的节 点。

​ 通过Buffer Pool中的描述数据块的free_pre和free_next两个指针,就可以把所有的描述数据块串成一个free链表,大家可以 自己去思考一下这个问题。上面为了画图需要,所以把描述数据块单独画了一份出来,表示他们之间的指针引用关系。

​ 对于free链表而言,只有一个基础节点是不属于Buffer Pool的他是40字节大小的一个节点,里面就存放了free链表的头节点 的地址,尾节点的地址,还有free链表里当前有多少个节点。

如何将磁盘上的页读取到Buffer Pool的缓存页中去?

其实有了free表这个操作就变得很简单了。

​ 首先,我们需要从free链表里获取一个描述数据块(因为free表中的描述快对应的就是空闲的缓存页),然后就可以对应的获取到这个描述数据块对应的空闲缓存页。

​ 接着我们就可以把磁盘上的数据页读取到对应的缓存页里去,同时把相关的一些描述数据写入缓存页的描述数据块里去,比如 这个数据页所属的表空间之类的信息,最后把那个描述数据块从free链表里去除就可以了,如下图所示。

image-20200514175129506

怎么判断要查询/更新的数据存在于bufferpool中? hash

​ 我们在执行增删改查的时候,肯定是先看看这个数据页有没有被缓存,如果没被缓存就走上面的逻辑,从free链表中找到一个 空闲的缓存页,从磁盘上读取数据页写入缓存页,写入描述数据,从free链表中移除这个描述数据块。
​ 但是如果数据页已经被缓存了,那么就会直接使用了。

所以其实数据库还会有一个哈希表数据结构,他会用表空间号+数据页号,作为一个key,然后缓存页的地址作为value。

​ 当你要使用一个数据页的时候,通过“表空间号+数据页号”作为key去这个哈希表里查一下,如果没有就读取数据页,如果 已经有了,就说明数据页已经被缓存了。

​ 我们看下图,又引入了一个数据页缓存哈希表的结构。

​ 也就是说,每次读取一个数据页到缓存之后,都会在这个哈希表中写入一个key-value对,key就是表空间号+数据页号, value就是缓存页的地址,那么下次如果你再使用这个数据页,就可以从哈希表里直接读取出来他已经被放入一个缓存页了。

image-20200514175351651

bufferpool中脏数据怎么刷回磁盘 - flush链表

​ 我们知道几乎所有的更新操作都是先操作内存中的bufferpool,那么很明显并不是bufferpool中所有数据都需要刷回磁盘(因为有的缓存页可能是因为查询的时候被读取到Buffer Pool里 去的,可能根本没修改过),那么我们怎么判断或者怎么拿到需要刷回磁盘的缓存页呢?

​ 所以数据库在这里引入了另外一个跟free链表类似的flush链表这个flush链表本质也是通过缓存页的描述数据块中的两个指针,让被修改过的缓存页的描述数据块,组成一个双向链表
凡是被修改过的缓存页,都会把他的描述数据块加入到flush链表中去,flush的意思就是这些都是脏页,后续都是要flush刷新 到磁盘上去的。
​ 所以flush链表的结构如下图所示,跟free链表几乎是一样的。

image-20200515082201508

当Buffer Pool中的缓存页不够的时候,如何基于LRU算法淘汰部分缓存?

​ 我们知道,随着不断加载数据到bufferpool,那么空闲缓存页(free链表)的个数终将会变为0,那么当下次再加载进bufferpool时,怎么办呢?此时你只有一个办法,就是淘汰掉一些缓存页。

​ 那什么叫淘汰缓存页呢?

​ 顾名思义,你必须把一个缓存页里被修改过的数据,给他刷到磁盘上的数据页里去,然后这个缓存页就可以清空了, 让他重新变成一个空闲的缓存页(加入到free链表中)

接着你再把磁盘上你需要的新的数据页加载到这个腾出来的空闲缓存页中去。

那么下一个问题来了,如果要把一个缓存页里的数据刷入磁盘,腾出来一个空闲缓存页,那么应该把哪个缓存页的数据给刷入磁盘呢?

是根据flush链表?那如果是根据flush链表的话,是吧所有flush链表对应的缓存页都刷回磁盘呢?还是只刷一部分,如果只刷一部分,那么刷那部分呢?

很明显示是最近用的最少,修改的最少那个数据页写入到磁盘,因为毕竟占着茅坑不拉屎。

接着我们就要解决下一个问题了,就是你怎么知道哪些缓存页经常被访问,哪些缓存页很少被访问?

很明显flush链表的作用只是记录那些缓存页被修改了,并没有记录哪个缓存页被修改的次数,而且没有提供排序的规则,什么意思呢?就是没有把修改最多的缓存页排到最前,修改最少的排到最后,flush链表并没有这样的规则

​ 此时就要引入一个新的LRU链表了,这个所谓的LRU就是Least Recently Used,最近最少使用的意思。
通过这个LRU链表,我们可以知道哪些缓存页是最近最少被使用的,那么当你缓存页需要腾出来一个刷入磁盘的时 候,不就可以选择那个LRU链表中最近最少被使用的缓存页了么?

LRU链表工作原理

​ 简单来说,假设我们从磁盘加载一个数据页到缓存页的时候就把这个缓存页的描述数据块放到LRU链 表头部去,那么只要有数据的缓存页,他都会在LRU里了,而且最近被加载数据的缓存页,都会放到LRU链表的头部 去。

​ 然后假设某个缓存页的描述数据块本来在LRU链表的尾部,后续你只要查询或者修改了这个缓存页的数据,也要把这 个缓存页挪动到LRU链表的头部去,也就是说最近被访问过的缓存页,一定在LRU链表的头部

​ 那么这样的话,当你的缓存页没有一个空闲的时候(free链表为空,而且IO线程没有及时把flush链表中映射的缓存页刷回磁盘),你是不是要找出来那个最近最少被访问的缓存页去刷入磁盘?此 时你就直接在LRU链表的尾部找到一个缓存页,他一定是最近最少被访问的那个缓存页!

​ 然后你就把LRU链表尾部的那个缓存页刷入磁盘中,然后把你需要的磁盘数据页加载到腾出来的空闲缓存页中就可以 了!

LRU链表在Buffer Pool实际运行中,可能导致预读问题

​ 会带来隐患的就是MySQL的预读机制,这个所谓预读机制,说的就是当你从磁盘上加载一个数据页的时候,他可 能会连带着把这个数据页相邻的其他数据页,也加载到缓存里去!(空间局部性原则,如果访问某个数据块,那么他的临近数据块可能也会被访问,所以临近数据块会连同被访问数据块一起加入内存中。-目的就是为了提升访问效率

​ 举个例子,假设现在有两个空闲缓存页,然后在加载一个数据页的时候,连带着把他的一个相邻的数据页也加载到缓 存里去了,正好每个数据页放入一个空闲缓存页!

​ 但是接下来呢,实际上只有一个缓存页是被访问了,另外一个通过预读机制加载的缓存页,其实并没有人访问,此时 这两个缓存页可都在LRU链表的前面,如下图。

image-20200515090748744

​ 我们可以看到,这个图里很清晰的表明了,前两个缓存页都是刚加载进来的,但是此时第二个缓存页是通过预读机制 捎带着加载进来的,他也放到了链表的前面,但是他实际没人访问他。

​ 除了第二个缓存页之外,第一个缓存页,以及尾巴上两个缓存页,都是一直有人访问的那种缓存页,只不过上图代表 的是刚刚把头部两个缓存页加载进来的时候的一个LRU链表当时的情况。

​ 这个时候,假如没有空闲缓存页了,那么此时要加载新的数据页了,是不是就要从LRU链表的尾部把所谓的“最近最 少使用的一个缓存页”给拿出来,刷入磁盘,然后腾出来一个空闲缓存页了?

​ 这个时候,如果你把上图中LRU尾部的那个缓存页刷入磁盘然后清空,你觉得合理吗?他可是之前一直频繁被人访问 的啊!只不过在这一个瞬间,被新加载进来的两个缓存页给占据了LRU链表前面的位置,尤其是第二个缓存页,居然 还是通过预读机制加载进来的,根本就不会有人访问!

​ 那么这个时候,你要是把LRU链表尾部的缓存页给刷入磁盘,这是绝对不合理的,最合理的反而是把上图中LRU链表的 第二个通过预读机制加载进来的缓存页给刷入磁盘和清空,毕竟他几乎是没什么人会访问的!

哪些情况下会触发MySQL的预读机制?

(1)有一个参数是innodb_read_ahead_threshold,他的默认值是56,意思就是如果顺序的访问了一个区里的多个 数据页,访问的数据页的数量超过了这个阈值,此时就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓 存里去
(2)如果Buffer Pool里缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁会被访问的,此时就会 直接触发预读机制,把这个区里的其他的数据页都加载到缓存里去

这个机制是通过参数innodb_random_read_ahead来控制的,他默认是OFF,也就是这个规则是关闭的

​ 所以默认情况下,主要是第一个规则可能会触发预读机制,一下子把很多相邻区里的数据页加载到缓存里去,这些缓 存页如果一下子都放在LRU链表的前面,而且他们其实并没什么人会访问的话,那就会如上图,导致本来就在缓存里 的一些频繁被访问的缓存页在LRU链表的尾部。

​ 这样的话,一旦要把一些缓存页淘汰掉,刷入磁盘,腾出来空闲缓存页,就会如上所述,把LRU链表尾部一些频繁被 访问的缓存页给刷入磁盘和清空掉了!这是完全不合理的,并不应该这样!

另外一种可能导致频繁被访问的缓存页被淘汰的场景

接着我们讲另外一种可能导致频繁被访问的缓存页被淘汰的场景,那就是全表扫描

​ 这个所谓的全表扫描,意思就是类似如下的SQL语句:SELECT * FROM USERS
此时他没加任何一个where条件,会导致他直接一下子把这个表里所有的数据页,都从磁盘加载到Buffer Pool里去。
​ 这个时候他可能会一下子就把这个表的所有数据页都一一装入各个缓存页里去!此时可能LRU链表中排在前面的一大 串缓存页,都是全表扫描加载进来的缓存页!那么如果这次全表扫描过后,后续几乎没用到这个表里的数据呢?
​ 此时LRU链表的尾部,可能全部都是之前一直被频繁访问的那些缓存页!

​ 然后当你要淘汰掉一些缓存页腾出空间的时候,就会把LRU链表尾部一直被频繁访问的缓存页给淘汰掉了,而留下了 之前全表扫描加载进来的大量的不经常访问的缓存页!

总而言之,这个版本的LRU算法,是存在问题的,虽然初衷是好的(数据局部性原理),但是可能因为预读原因导致某些还在被使用的缓存页,加载到磁盘中,那么下次再使用的时候,还得重新从磁盘中获取。

那么mysql是怎么解决的呢?

基于冷热数据分离的思想设计LRU链表

基于冷热数据分离的思想设计LRU链表 解决预读问题

​ 所以为了解决我们说的简单的LRU链表的问题,真正MySQL在设计LRU链表的时候,采取的实际上是冷热数据 分离的思想。

​ 之前一系列的问题,说白了,不都是因为所有缓存页都混在一个LRU链表里,才导致的么?

​ 所以真正的LRU链表,会被拆分为两个部分,一部分是热数据,一部分是冷数据,这个冷热数据的比例是由 innodb_old_blocks_pct参数控制的,他默认是37,也就是说冷数据占比37%

这个时候,LRU链表实际上看起来是下面这样子的。

image-20200515091819132

数据页第一次被加载到缓存的时候 - 冷数据什么时候加载到热数据中??

​ 首先数据页第一次被加载到缓存的时候,这个时候缓存页会被放在LRU链表的哪个位置呢?

​ 实际上这个时候,缓存页会被放在冷数据区域的链表头部,我们看下面的图,也就是第一次把一个数据页加载到缓存 页之后,这个缓存页实际上是被放在下图箭头的位置,也就是冷数据区域的链表头部位置。

image-20200515091921628

​ 接着我们来思考一个问题,第一次被加载了数据的缓存页,都会不停的移动到冷数据区域的链表头部,如上图所示。
那么你要知道,冷数据区域的缓存页肯定是会被使用的,那么冷数据区域的缓存页什么时候会放到热数据区域呢?

实际上肯定很多人会想,只要对冷数据区域的缓存页进行了一次访问,就立马把这个缓存页放到热数据区域的头部行 不行呢?如下图所示。
image-20200515092020503

​ 其实这也是不合理的,如果你刚加载了一个数据页到那个缓存页,他是在冷数据区域的链表头部,然后立马(在1ms 以内)就访问了一下这个缓存页,之后就再也不访问他了呢?难道这种情况你也要把那个缓存页放到热数据区域的头 部吗?

所以MySQL设定了一个规则,他设计了一个innodb_old_blocks_time参数,默认值1000,也就是1000毫秒

​ 也就是说,必须是一个数据页被加载到缓存页之后,在1s之后,如果你再访问这个缓存页,他才会被挪动到热数据区域的链 表头部去。

​ 因为假设你加载了一个数据页到缓存去,然后过了1s之后你还访问了这个缓存页,说明你后续很可能会经常要访问 它,这个时间限制就是1s,因此只有1s后你访问了这个缓存页,他才会给你把缓存页放到热数据区域的链表头部去。

所以是数据加载到缓存页之后过了1s,你再访问这个缓存页,他就会被放 入热数据区域的链表头部,如果是你数据刚加载到缓存页,在1s内你就访问缓存页,此时他是不会把这个缓存页放入 热数据区域的头部的。

那么我们回到之前说过的预读和全表扫描问题,使用冷热数据分离LRU策略怎么解决这个问题呢?

​ 首先我们思考一下,在这样的一个冷热分离LRU链表方案下,预读机制以及全表扫描加载进来的一大堆缓存页,他们会放在哪里?

明显是放在LRU链表的冷数据区域的前面啊!

假设这个时候热数据区域已经有很多被频繁访问的缓存页了,你会发现热数据区域还是存放被频繁访问的缓存页的, 只要热数据区域有缓存页被访问,他还是会被移动到热数据区域的链表头部去。

这样就做到了冷热数据的分离,避免误删热数据。

​ 在这样的一套缓存页分冷热数据的加载方案,以及冷数据转化为热数据的时间限制方案,还有就是淘汰缓存页的时候优先淘汰冷数据区域的方案,基于这套方案,大家会发现,之前发现的问题,完美的被解决了。

​ 因为那种预读机制以及全表扫描机制加载进来的数据页,大部分都会在1s之内访问一下,之后可能就再也不访问了, 所以这种缓存页基本上都会留在冷数据区域里。然后频繁访问的缓存页还是会留在热数据区域里。

​ 当你要淘汰缓存的时候,优先就是会选择冷数据区域的尾部的缓存页,这就是非常合理的了!他不会让刚加载进来的 缓存页占据LRU链表的头部,频繁访问的缓存页在LRU链表的尾部,淘汰的时候淘汰尾部的频繁访问的缓存页了!

​ 问题完美的被解决了。

这就是LRU链表冷热数据分离的一套机制。

总结

​ 接着我们来讲讲,你的Buffer Pool在运行中被使用的时候,实际上会频繁的从磁盘上加载数据页到他的缓存页里去, 然后free链表、flush链表、lru链表都会在使用的时候同时被使用。

​ 比如bufferpool在初始化完成后,会相应的构建free链表,表示当前bufferpool中空闲的缓存页。

​ 那么当数据加载到一个缓存页,free链表里会移除这个缓存页,然后LRU链表的冷数据区域的头部会放入这个缓存页

​ 然后如果你要是修改了一个缓存页,那么flush链表中会记录这个脏页,LRU链表中还可能会把你从冷数据区域移动到热 数据区域的头部去

​ 如果你是查询了一个缓存页,那么此时就会把这个缓存页在lru链表中移动到热数据区域去,或者在热数据区域中也有 可能会移动到头部去。

​ 总之,MySQL在执行CRUD的时候,首先就是大量的操作缓存页以及对应的几个链表。然后在缓存页都满的时候,必 然要想办法把一些缓存页给刷入磁盘,然后清空这几个缓存页,接着把需要的数据页加载到缓存页里去!

​ 我们已经知道,他是根据LRU链表去淘汰缓存页的,那么他到底是什么时候把LRU链表的冷数据区域中的缓存页刷入磁 盘的呢?实际上他有几个时机。

(1) 首先第一个时机,并不是在缓存页满的时候,才会挑选LRU冷数据区域尾部的几个缓存页刷入磁盘,而是有一个后台 线程,他会运行一个定时任务,这个定时任务每隔一段时间就会把LRU链表的冷数据区域的尾部的一些缓存页,刷入 磁盘里去,清空这几个缓存页,把他们加入回free链表去!

image-20200515094416088

经过上面的学习,我们知道flush链表也会有个线程同时把缓存页刷新回磁盘,同时也会删除flush链表和增加free链表空闲缓存页个数。

总而言之:

​ 所以你可以理解为,你一边不停的加载数据到缓存页里去,不停的查询和修改缓存数据,然后free链表中的缓存页不停 的在减少,flush链表中的缓存页不停的在增加,lru链表中的缓存页不停的在增加和移动。

​ 另外一边,你的后台线程不停的在把lru链表的冷数据区域的缓存页以及flush链表的缓存页,刷入磁盘中来清空缓存 页,然后flush链表和lru链表中的缓存页在减少,free链表中的缓存页在增加。

这就是一个动态运行起来的效果!

​ 实际上你会发现lru跟flush的作用有点类似,但是我们可以这么理解LRU是flush的应急措施(当负责将flush链表中的缓存页刷回磁盘的线程还没来得及将数据刷回磁盘时,此时free链表已经没有空闲块也就是bufferpool已经满了,那么如果再有查询请求到来,怎么让bufferpool腾出空间呢?这个时候LRU链表的好处就来了。),而且他们两者存放的数据类型不同(flush存放的是发生了更改操作时额数据描述块,LRU存放的时使用时的数据描述块(包含查询操作产生的数据描述块))

可以看到,mysql为了让CRUD尽可能在bufferpool中进行,做了很多工作,其中三个链表(free、flush、lru)就是提现

如果你感觉文章对你又些许感悟,你可以支持我!!
-------------本文结束感谢您的阅读-------------