前言:前面在学习一条update语句是如何执行的时候,提到了Buffer Pool,它是数据库中一个非常重要的核心组件,里面缓存了磁盘上的真实数据,对数据库操作提升效率作出了重大贡献,对数据的增删改查操作主要就是基于Buffer Pool里的数据实现的。
接下来主要内容包括以下几个部分:
1、Buffer Pool的内存数据结构
2、数据页如何加载到Buffer Pool的缓存页
3、Buffer Pool中的缓存页如何刷回磁盘
4、Buffer Pool缓存页的LRU淘汰机制和冷热数据区分离的优化
5、什么时候把缓存页刷回磁盘
一、Buffer Pool 的内存数据结构
1、Buffer Pool 的大小
Buffer Pool的本质其实就是数据库的一个内存组件,可以理解为一片内存数据结构,默认情况下是128MB, 也可以配置自定义,如下配置为2G
innodb_buffer_pool_size=2147483648
2、Buffer Pool 的数据存储结构
数据是如何存储在Buffer Pool中的?
数据库的数据模型是 表+字段+行 的概念,数据库里有一个一个的表,一个表中有很多个字段,有很多行数据,每行数据有不同的字段值,那么这些数据加载到Buffer Pool缓冲池中是如何存放的呢?
**数据页**:MySQL磁盘存储中有数据页的概念,每一页数据里放了很多行数据,默认情况下,磁盘中的数据页大小是16KB。 当我们要更新某一行数据时,存储引擎会找到这行数据所在的数据页,然后从磁盘里把这个数据页整个加载进Buffer Pool中,所以,在Buffer Pool中存放的实际上也是一个一个的数据页。
**缓存页**:在Buffer Pool中,是把磁盘里的一个个数据页整个加载进来的,默认情况下,一个缓存页的大小和磁盘里的一个数据页的大小是一一对应的,都是16KB。
**描述信息**:Buffer Pool中除了包含一个个缓存页之外,还会有一块数据用来存储这些缓存页的描述信息,用来描述缓存页,包含对应数据页所属的表空间、数据页的编号、缓存页在Buffer Pool中的地址以及其它的杂七杂八的东西。每个缓存页都会对应一个描述信息。
在Buffer Pool中,每个缓存页的描述信息存放在最前面,然后各个缓存页存放在后面。描述信息的数据大小大概相当于缓存页的5%左右,即800字节左右,所以假设设置的Buffer Pool大小为128MB,实际上真正的大小会超出一些,可能有130MB,因为里面还存放了各个缓存页的描述信息。
二、数据页如何加载到Buffer Pool的缓存页
接下来了解下数据从磁盘加载到Buffer Pool中的过程:
1、Buffer Pool初始化
数据库启动的时候,会按照设置的Buffer Pool的大小,稍微加大一点,向操作系统申请一块内存区域,作为buffer pool的内存区域,然后按照默认的缓存页的16KB大小以及对应的800字节左右的描述数据块的大小,将buffer pool划分成一个一个的缓存页和对应的描述数据。
这个时候,缓存页里面都是空的,等数据库运行起来后,再按照一定的规则,将数据页从磁盘中加载到缓存页中。
2、free链表的实现
free链表用来存储Buffer Pool中的空闲页,数据页从磁盘加载到Buffer Pool中的时候,会优先加载到空闲页中,通过free链表的记录,可以找到哪些缓存页是空闲的。
free链表是一个双向链表的数据结构,每个节点存储的是空闲的缓存页的描述数据块的地址。数据库刚启动的时候,所有的缓存页都是空闲的,因此所有的描述数据块的地址都在free链表中。
free链表是如何实现的呢?
实际上,free链表并没有在内存中开启一个新的内存空间,它本身就是由Buffer Pool中的描述块组成的,可以理解为,每个描述块中都有两个指针free_pre,free_next,分别指向自己的上一个空闲的描述块和下一个空闲的描述块,除此之外,free链表有一个额外的基础节点不属于Buffer Pool,大概40字节左右,存放的是free链表的头节点的地址和尾节点的地址,以及free链表的节点个数。
3、如何将磁盘上的数据页加载到Buffer Pool中的缓存页?
首先,从free链表中获取一个描述数据块,根据描述数据块就可以获取到对应的空闲缓存页,然后把磁盘上的数据页读取到对应的缓存页中去,同时把相关的描述信息写入到描述数据块中,比如这个数据页所属的表空间等,最后从free链表中去除这个描述数据块的节点。(双向链表删除节点修改指针地址即可)
4、如何知道一个数据页有没有被缓存呢?
在执行增删改查操作的时候,会先看看这个数据页有没有被缓存,如果没有被缓存,就走上面缓存的逻辑,从free链表中获取一个空闲的缓存页,从磁盘上读取数据页写入缓存页,写入描述数据,最后从free链表中移除这个描述数据块。但是,如果数据页已经被缓存,就可以直接使用了。
所以,数据库还有一个数据页缓存哈希表。用表的空间号+数据页号,作为key值,缓存页的地址作为value值,存储数据页的缓存信息。
当要使用一个数据页时,会先查这个哈希表,就可以找到对应的缓存页,或者知道要从磁盘加载数据页。
三、Buffer Pool中的数据如何刷回磁盘
数据的增删改查都是基于Buffer Pool中的缓存数据进行实现的,最终为了数据的持久化,都会将缓存页中的数据刷回到磁盘里。
脏数据、脏页:缓存页中的数据被修改,与磁盘数据页中的数据不一致了,那么就是脏页。
1、flush链表记录脏页
不是所有的缓存页都是脏页,所以不需要将所有的缓存页都刷回磁盘,因为有的缓存页是因为查询被读取到缓存页中去的,没有被修改过。
因此,Buffer Pool引入了一个跟free链表类似的flush链表,用来存储被修改过的缓存页,即脏页。
flush链表的实现与free链表类似,本质也是通过描述数据块中的两个指针,将被修改过的缓存页的描述数据块,组成一个双向链表。凡是被修改过的缓存页,都会把它的描述块加入到flush链表中,flush链表也有一个Buffer Pool之外的基础节点,用来存储指向头指针和尾指针。
flush链表节点所对应的缓存页,都要刷新到磁盘里去。
四、Buffer Pool缓存页的淘汰机制
Buffer Pool缓存页的个数是有限的,不停的把磁盘中的数据页加载进缓存页的时候,free链表上的节点也会不断减少,当Buffer Pool中的缓存页都用完,free链表上也就没有空闲缓存页的描述数据块节点了。此时,如果还要加载新的数据页到缓存页,就必然要淘汰掉一些已经缓存的数据页。
淘汰缓存页:把缓存页里修改过的数据刷回到磁盘,然后把缓存页清空,重新变成一个空闲的缓存页,加入free链表中。
1、LRU链表淘汰机制
LRU原理:Least Recently Used 最近最少使用。类似于操作系统中学过的内存页的淘汰机制,淘汰掉最近一段时间内最久未被访问的页。认为那些刚被使用过的页,可能还要立即被使用,而那些在较长时间内未被使用的页可能不会使用。
实现原理:维护一个LRU链表,链表的本质与free链表、flush链表类似,都是基于描述块的指针实现的。对于新加载的缓存页,都放在LRU链表的头部,只要访问了哪个缓存页,就将这个缓存页的描述块节点移动到链表头部,即最近被访问的缓存页,一定在LRU链表的头部,而最久未被访问的缓存页,一定在LRU链表的尾部。
当缓存页没有空闲的时候,只需要从LRU链表的尾部找到一个缓存页,并且该缓存页也在flush链表中,将其刷入磁盘就可以腾出空闲缓存页了。
2、LRU链表淘汰机制的缺点
预读机制和全表扫描机制,会把大量未来可能并不怎么被访问的数据页加载到LRU链表的头部,而把经常访问的数据页挤到尾部被淘汰。
(1)预读机制导致的问题
预读机制:当需要从磁盘加载一个数据页的时候,会将数据页相邻的其它数据页,也加载到缓存中去。因为数据库认为,一个数据页被访问,其相邻的数据页很可能接下来也会被访问,所以预先加载进来。
预读机制导致新加载到Buffer Pool的缓存页有多个,实际只有其中1个被访问了,另外未被访问的数据页,也随之放到了LRU链表的头部,而实际上这些加载到头部的缓存页可能根本不会有人访问,却会将其它可能经常被访问的缓存页挤到LRU链表尾部,从而被淘汰,这也是非常不合理的。
MySQL为什么设置预读机制?
提升性能。当触发预读机制时,MySQL会认为你可能会接着顺序读取后面的数据,所以就干脆提前把后续的数据读到内存,这样后续就可以直接用了。
什么时候会触发预读机制?
(a) innodb_read_ahead_threshold 默认参数值为56,意思是如果顺序访问了一个数据区里的数据页的数量超过了这个阈值,就会触发预读机制,将下一个相邻的数据区里的所有数据页都加载到缓存页中。
(b) innodb_random_read_ahead 默认是OFF,即这个规则是关闭的。当这个规则打开,如果Buffer Pool里缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁被访问的,此时就会触发预读机制,将这个区里的其它数据页都加载到缓存页中。
默认情况下,主要是第一种预读机制会被触发。一下子把相邻区的数据页都加载到缓存页,这些缓存页都放在LRU链表的前面,而实际并没有被访问的时候,就非常不合理。
(2)全表扫描导致的问题
全表扫描时,如执行 select * from users. 这种类似的语句,会触发全表扫描。会把这个表里所有的数据页都从磁盘加载到Buffer Pool中,导致LRU链表前面都是全表扫描加载进来的缓存页,后续可能几乎用不到。
3、基于冷热数据分离来优化的LRU算法
真正的MySQL在设计LRU链表的时候,采取的是冷热数据分离的思想。
把LRU链表拆分成2个部分,一部分是热数据,一部分是冷数据,参数 innodb_old_blocks_pct 用来设置冷数据的比例,默认是37,即LRU链表中冷数据占比37%。
(a) 数据第一次被加载到缓存的时候,会将缓存页放到冷数据区的头部
(b) 数据页被加载到缓存页之后,在1s之后,如果缓存页再次被访问,就会从冷数据区移动到热数据区的链表头部。因为过了1s之后还被访问的缓存页,后续很可能还会经常被访问。
这个时间设置的参数为 innodb_old_blocks_time ,默认为1000,单位为毫秒,即1s。
(c) 当空闲缓存页用完,需要淘汰缓存页时,只需要从冷数据区的尾部淘汰即可。
4、冷热数据分离的好处
预加载机制和全表扫描机制加载进来的缓存页,都会被放在冷数据区,只有在1s之后再次被访问,才会被移动到热区。而那些被加载进来不会被访问的数据页,则在冷数据区很快被淘汰掉。
5、冷热数据分离的优化
热数据区域的缓存页是经常会被访问的,如果频繁的移动也是会影响性能的,因此LRU链表的热区访问规则被优化了一下。
只有在热数据区域后3/4部分的缓存页被访问了才会被移动到链表到头部,如果是前1/4被访问,则不需要移动。
五、什么时候会把缓存页刷回磁盘
上面已经了解到,缓存页是通过LRU冷热数据分离的淘汰机制刷回磁盘的,那么什么时候会触发这个刷回磁盘的机制呢。实际上有好几个时机。
1、定时把LRU尾部的部分缓存页刷回磁盘
有一个后台线程,运行一个定时任务,会每隔一段时间就会把LRU链表的冷数据区域尾部的一些缓存页刷回到磁盘。
这个并不一定是在缓存页用完的时候执行的,可能缓存页还没用完,就会把一些冷数据区的缓存页清空出来。
2、把flush链表中的一些缓存页定时刷入磁盘
LRU链表的热数据区域里有很多的缓存页可能被频繁修改,这些缓存页也是需要刷回到磁盘的。
所以会有一个后台线程,在MySQL不怎么繁忙的时候,找个时间把flush链表中的一波缓存页刷入磁盘,这些缓存页刷回磁盘之后,也会从flush链表和LRU链表中移除,然后加入到free链表中去。
3、空闲缓存页用完的时候
当空闲缓存页用完,而又要加载新的数据页进来,此时就会从LRU链表的冷数据区尾部找到一个最久不使用的缓存页刷回磁盘。
六、总结
上述内容主要是Buffer Pool缓存页的加载、使用和淘汰机制,重点要了解Buffer Pool的数据结构,free链表、flush链表、lru链表在这些过程中是如何使用的,以及缓存页是如何刷回磁盘腾出来空闲缓存页的,以及没有空闲缓存页的时候应该怎么处理。
比如数据页要加载到一个缓存页中,会从free链表查找一个空闲的缓存页,把数据从磁盘读入到缓存页中,free链表会移除这个缓存页,然后lru链表的冷数据区的头部会加入这个缓存页。
然后如果修改了一个缓存页,那么flush链表中会加入这个脏页,然后lru链表还可能会把这个缓存页从冷数据区移动到热数据区的头部。
总之,MySQL执行CRUD的时候,会大量操作缓存页以及对应几个链表的变化。在对MySQL内核参数进行优化的时候,应该尽可能避免的在执行CRUD的时候,需要经常先刷新一个缓存页到磁盘,再从磁盘读取数据页到缓存。