Buffer Pool

Buffer Pool缓冲池是主存中的一个区域,InnoDB在这里缓存表和索引数据。缓冲池允许从内存中直接访问经常使用的数据,这加快了处理速度。在专用服务器上,高达80%的物理内存通常分配给缓冲池。

存储结构

MySQL对数据抽象出来了一个数据页的概念,他是把很多行数据放在了一个数据页里,磁盘文件中有很多的数据页,每一页数据里放了很多行数据。
假设我们要更新一行数据,此时数据库会找到这行数据所在的数据页,然后从磁盘文件里把这行数据所在的数据页 直接给加载到Buffer Pool里去。
实际上默认情况下,磁盘中存放的数据页的大小是16KB,也就是说,一页数据包含了16KB的内容。 而Buffer Pool中存放的一个一个的数据页,我们通常叫做缓存页,因为毕竟Buffer Pool是一个缓冲池,里面的数据都是从磁盘缓存到内存去的。 而Buffer Pool中默认情况下,一个缓存页的大小和磁盘上的一个数据页的大小是一一对应起来的,都是16KB
对于每个缓存页,他实际上都会有一个描述信息,这个描述信息大体可以认为是用来描述这个缓存页的 比如包含如下的一些东西:这个数据页所属的表空间、数据页的编号、这个缓存页在Buffer Pool中的地址以及其他一些数据。 每个缓存页都会对应一个描述信息,这个描述信息本身也是一块数据,在Buffer Pool中,每个缓存页的描述数据放在最前面, 然后各个缓存页放在后面
存储结构
存储结构
Buffer Pool中的描述数据大概相当于缓存页大小的5%左右,也就是每个描述数据大概是800个字节左右的大小,然后假设你设置的Buffer Pool大小是128MB,实际上Buffer Pool真正的最终大小会超出一些,可能有个130MB的样子,因为这个设置参数是不包含描述数据的。

初始化

数据库只要一启动,就会按照你设置的Buffer Pool大小,稍微再加大一点,去找操作系统申请一块内存区域,作为Buffer Pool的内存区域。然后当内存区域申请完毕之后,数据库就会按照默认的缓存页的16KB的大小以及对应的800个字节左右的描述数据的大小,在Buffer Pool中划分出来一个一个的缓存页和一个一个的他们对应的描述数据。
 

free 链表

数据库会为Buffer Pool设计一个free链表,他是一个双向链表数据结构。
这个free链表里,每个节点就是一个空闲的缓存页的描述数据块的地址,也就是说,只要你一个缓存页是空闲的,那么他的描述数据块就会被放入这个free链表中。
刚开始数据库启动的时候,可能所有的缓存页都是空闲的,因为此时可能是一个空的数据库,一条数据都没有,所以此时所有缓存页的描述数据块,都会被放入这个free链表中 每个节点都会双向链接自己的前后节点,组成一个双向链表。
除此之外,这个free链表有一个基础节点,他会引用链表的头节点和尾节点,里面还存储了链表中有多少个描述数据块的节 点,也就是有多少个空闲的缓存页。
notion image
这个free链表,他本身其实就是由Buffer Pool里的描述数据块组成的,你可以认为是每个描述数据块里都有两个指针,一个是free_pre,一个是free_next,分别指向自己的上一个free链表的节点,以及下一个free链表的节点。 通过Buffer Pool中的描述数据块的free_prefree_next两个指针,就可以把所有的描述数据块串成一个free链表。
对于free链表而言,只有一个基础节点是不属于Buffer Pool的,他是40字节大小的一个节点,里面就存放了free链表的头节点 的地址,尾节点的地址,还有free链表里当前有多少个节点。

读取修改数据

在执行增删改查的时候,肯定是看这个数据页有没有被缓存,如果没被缓存就从free链表中找到一个空闲的缓存页,从磁盘上读取数据页写入缓存页,写入描述数据,从free链表中移除这个描述数据块。但是如果数据页已经被缓存了,那么就会直接使用了。 所以其实数据库还会有一个哈希表数据结构,他会用表空间号+数据页号,作为一个key,然后缓存页的地址作为value。当你要使用一个数据页的时候,通过“表空间号+数据页号”作为key去这个哈希表里查一下,如果没有就读取数据页,如果已经有了,就说明数据页已经被缓存了。

flush链表

要更新的数据页都会在Buffer Pool的缓存页里,在内存中直接执行增删改的操作,会去更新Buffer Pool的缓存页中的数据,此时更新了缓存页中的数据,那么缓存页里的数据和磁盘上的数据页里的数据就不一致了,不一致的数据所在的页称为脏页
数据库在这里引入了另外一个跟free链表类似的flush链表,这个flush链表本质也是通过缓存页的描述数据块中的两个指针,让被修改过的缓存页的描述数据块,组成一个双向链表。 凡是被修改过的缓存页,都会把他的描述数据块加入到flush链表中去,flush的意思就是这些都是脏页,后续都是要flush刷新到磁盘上去的
notion image
当你更新缓存页的时候,通过变换缓存页中的描述数据块的flush链表的指针,就可以把脏页的描述数据块组成一个双向链表,也就是flush链表,而且flush链表的基础节点会指向起始节点和尾巴节点。
 

缓存淘汰策略(基于LRU)

随着你不停的把磁盘上的数据页加载到空闲的缓存页里去,free链表中的空闲缓存页是不是会越来越少?因为只要你把一个数据页加载到一个空闲缓存页里去,free链表中就会减少一个空闲缓存页。 所以,当你不停的把磁盘上的数据页加载到空闲缓存页里去,free链表中不停的移除空闲缓存页,迟早有那么一瞬间,你会发现free链表中已经没有空闲缓存页了。这时候需要把脏的缓存页刷到磁盘,释放缓存页,free链表才会有多余的缓存页存储数据。
为了保证缓存命中率,优先考虑将不常用的脏页先刷到磁盘中。
LRU算法模型
 
InnoDB内存管理用的是最近最少使用 (Least Recently Used, LRU)算法,这个算法的核心就是淘汰最久未使用的数据。
下图是一个LRU算法的基本模型。
LRU算法的基本模型
LRU算法的基本模型
InnoDB管理Buffer Pool的LRU算法,是用链表来实现的。
  1. 在上图的状态1里,链表头部是P1,表示P1是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
  1. 这时候有一个读请求访问P3,因此变成状态2,P3被移到最前面;
  1. 状态3表示,这次访问的数据页是不存在于链表中的,所以需要在Buffer Pool中新申请一个数据页Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾Pm这个数据页的内存,存入Px的内容,然后放到链表头部。
  1. 从效果上看,就是最久没有被访问的数据页Pm,被淘汰了。
普通LRU算法的问题
 
预读带来的问题
首先会带来隐患的就是MySQL的预读机制,这个所谓预读机制,说的就是当你从磁盘上加载一个数据页的时候,他可能会连带着把这个数据页相邻的其他数据页,也加载到缓存里去! 举个例子,假设现在有两个空闲缓存页,然后在加载一个数据页的时候,连带着把他的一个相邻的数据页也加载到缓存里去了,正好每个数据页放入一个空闲缓存页! 但是接下来呢,实际上只有一个缓存页是被访问了,另外一个通过预读机制加载的缓存页,其实并没有人访问,此时这两个缓存页可都在LRU链表的前面,
notion image
我们可以看到,这个图里很清晰的表明了,前两个缓存页都是刚加载进来的,但是此时第二个缓存页是通过预读机制捎带着加载进来的,他也放到了链表的前面,但是他实际没人访问他。 除了第二个缓存页之外,第一个缓存页,以及尾巴上两个缓存页,都是一直有人访问的那种缓存页,只不过上图代表的是刚刚把头部两个缓存页加载进来的时候的一个LRU链表当时的情况。
全表扫描带来的问题
 
假设按照这个算法,我们要扫描一个200G的表,而这个表是一个历史数据表,平时没有业务访问它。
那么,按照这个算法扫描的话,就会把当前的Buffer Pool里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说Buffer Pool里面主要放的是这个历史数据表的数据。
对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool的缓存命中率急剧下降,磁盘压力增加,SQL语句响应变慢。
 
 
LRU 模式改良
 
所以,InnoDB不能直接使用这个LRU算法。实际上,InnoDB对LRU算法做了改进,采用了冷热分离的思想。
新加入的缓存页不再是立马加到LRU链表的头部,而是先放到冷数据区,如果在某个很短的时间段内再次被访问才会加载到头部(热数据区)
notion image
InnoDB实现上,按照5:3的比例把整个LRU链表分成了young区域和old区域
图中LRU_old指向的就是old区域的第一个位置,是整个链表的5/8处。也就是说,靠近链表头部的5/8young区域,靠近链表尾部的3/8old区域。
改进后的LRU算法执行流程变成了下面这样。
  1. 上图中状态1,要访问数据页P3,由于P3在young区域,因此和优化前的LRU算法一样,将其移到链表头部,变成状态2。
  1. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页Pm,但是新插入的数据页Px,是放在LRU_old处。
  1. 处于old区域的数据页,每次被访问的时候都要做下面这个判断:
      • 若这个数据页在LRU链表中存在的时间超过了1秒,就把它移动到链表头部;
      • 如果这个数据页在LRU链表中存在的时间短于1秒,位置保持不变。1秒这个时间,是由参数innodb_old_blocks_time控制的。其默认值是1000,单位毫秒。
       
这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描200G的历史数据表为例,我们看看改进后的LRU算法的操作逻辑:
  1. 扫描过程中,需要新插入的数据页,都被放到old区域;
  1. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过1秒,因此还是会被保留在old区域;
  1. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是young区域),很快就会被淘汰出去。
可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了Buffer Pool,但是对young区域完全没有影响,从而保证了Buffer Pool响应正常业务的查询命中率。
 

调优