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