深度解析MySQL Buffer Pool工作原理
深度解析MySQL Buffer Pool工作原理
MySQL的Buffer Pool是其存储引擎InnoDB的核心组件之一,主要负责缓存磁盘上的数据页,以减少磁盘I/O操作,从而提升数据库的读写性能。本文将深入探讨Buffer Pool的工作原理,包括其大小配置、缓存内容管理、碎片空间处理、空闲页和脏页的管理方式,以及如何通过LRU算法优化缓存命中率等。
前言
在系统分层架构中,为了加速数据访问,通常会将最常访问的数据存储在缓存(cache)中,避免每次都直接访问数据库。操作系统通过缓冲池(buffer pool)机制,可以避免频繁的磁盘访问,从而加速数据访问速度。作为存储系统的MySQL,同样采用了缓冲池(Buffer Pool)机制,以避免每次查询数据时都进行磁盘I/O操作。
为什么会有Buffer Pool
虽然MySQL的数据主要存储在磁盘上,但每次都从磁盘读取数据的性能较差。为了提升查询性能,可以在内存中添加缓存。将磁盘上的数据加载到内存中,可以避免每次访问都进行磁盘I/O操作,从而加速数据访问。因此,InnoDB设计了一个缓冲池(Buffer Pool),以提高数据库的读写性能。
有了缓冲池后,我们可以看到:
- 当读取数据时,如果数据存在于Buffer Pool中,客户端就会直接读取Buffer Pool中的数据,否则再去磁盘中读取。
- 当修改数据时,首先是修改Buffer Pool中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。
Buffer Pool介绍
Buffer Pool有多大
Buffer Pool是在MySQL启动时向操作系统申请的一片连续的内存空间,默认配置下Buffer Pool只有128MB。不过,你可以根据innodb_buffer_pool_size
参数来设置Buffer Pool的大小。
Buffer Pool缓存什么呢
InnoDB会将存储的数据划分为若干个“页”,以页作为磁盘和内存交互的基本单位,一个页的默认大小为16KB。因此,Buffer Pool同样需要按“页”来划分。
InnoDB会为Buffer Pool申请一片连续的内存空间,然后按照默认的16KB的大小划分出一个个的页,Buffer Pool中的页就叫做缓存页,此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到Buffer Pool中。
Buffer Pool除了缓存“索引页”和“数据页”,还包括了undo页、插入缓存、自适应哈希索引、锁信息等等。数据类型一多就不好管理了,所以为每个缓存页都设置了一个控制块,控制块的信息包括:缓存页的表空间、页号、缓存页地址、链表节点等。
控制块也是占有内存空间的,它是放在Buffer Pool的最前面,接着才是缓存页。上图中控制块和缓存页之间灰色部分称为碎片空间。
Buffer Pool碎片空间
每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,用不到的那点儿内存空间就被称为碎片了。
查询一条记录,就只需要缓冲一条记录吗
不是的。当我们查询一条记录时,InnoDB会把整个页的数据加载到Buffer Pool中,因为,通过索引只能定位到磁盘中的页,而不能定位到页中的一条记录。将页加载到Buffer Pool后,再通过页里的页目录去定位到某条具体的记录。
如何管理Buffer Pool
如何管理空闲页
为了能够快速找到空闲的缓存页,可以使用链表结构,将空闲缓存页的“控制块”作为链表的节点,这个链表称为Free链表(空闲链表)。
Free链表上除了有控制块,还有一个头节点,该头节点包含链表的头节点地址、尾节点地址,以及当前链表中节点的数量等信息。
Free链表节点是一个一个的控制块,而每个控制块包含着对应缓存页的地址,所以相当于Free链表节点都对应一个空闲的缓存页。
有了Free链表后,每当需要从磁盘中加载一个页到Buffer Pool中时,就从Free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从Free链表中移除。
如何管理脏页
设计Buffer Pool除了能提高读性能,还能提高写性能,也就是更新数据的时候,不需要每次都要写入磁盘,而是将Buffer Pool对应的缓存页标记为脏页,然后再由后台线程将脏页写入到磁盘。
那为了能快速知道哪些缓存页是脏的,于是就设计出Flush链表,它跟Free链表类似的,链表的节点也是控制块,区别在于Flush链表的元素都是脏页。
如何提高缓存命中率
Buffer Pool的大小是有限的,对于一些频繁访问的数据我们希望可以一直留在Buffer Pool中,而一些很少访问的数据希望可以在某些时机可以淘汰掉。要实现这个,最容易想到的就是LRU(Least recently used)算法。
该算法的思路是,链表头部的节点是最近使用的,而链表末尾的节点是最久没被使用的。
那么,当空间不够了,就淘汰最久没被使用的节点,从而腾出空间。
简单的LRU算法的实现思路是这样的:
- 当访问的页在Buffer Pool里,就直接把该页对应的LRU链表节点移动到链表的头部。
- 当访问的页不在Buffer Pool里,除了要把页放入到LRU链表的头部,还要淘汰LRU链表末尾的节点。
比如下图,假设LRU链表长度为5,LRU链表从左到右有1,2,3,4,5的页。
如果访问了3号的页,因为3号页在Buffer Pool里,所以把3号页移动到头部即可。
而如果接下来,访问了8号页,因为8号页不在Buffer Pool里,所以需要先淘汰末尾的5号页,然后再将8号页加入到头部。
到这里我们可以知道,Buffer Pool里有三种页和链表来管理数据。
- Free Page(空闲页),表示此页未被使用,位于Free链表;
- Clean Page(干净页),表示此页已被使用,但是页面未发生修改,位于LRU链表。
- Dirty Page(脏页),表示此页“已被使用”且“已经被修改”,其数据和磁盘上的数据已经不一致。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。脏页同时存在于LRU链表和Flush链表。
LRU带来的问题
简单的LRU算法并没有被MySQL使用,简单的LRU算法无法避免下面这两个问题:
- 预读失效;
- Buffer Pool污染;
预读失效
预读机制:程序是有空间局部性的,靠近当前被访问数据的数据,在未来很大概率会被访问到。
MySQL在加载数据页时,会提前把它相邻的数据页一并加载进来,目的是为了减少磁盘IO。
预读失效:但是可能这些被提前加载进来的数据页,并没有被访问,相当于这个预读是白做了,
如果这些预读页如果一直不会被访问到,就会出现一个很奇怪的问题:
- 不会被访问的预读页却占用了LRU链表前排的位置
- 末尾淘汰的页,可能是频繁访问的页
这样大大降低了缓存命中率。
如何解决呢?
要避免预读失效带来影响,最好就是让预读的页停留在Buffer Pool里的时间要尽可能的短,让真正被访问的页才移动到LRU链表的头部,从而保证真正被读取的热数据留在Buffer Pool里的时间尽可能长。
那到底怎么去做呢?
MySQL是这样做的,它改进了LRU算法,将LRU划分了2个区域:old区域和young区域。
young区域在LRU链表的前半部分,old区域则是在后半部分,如下图:
old区域占整个LRU链表长度的比例可以通过innodb_old_blocks_pct
参数来设置,默认是37,代表整个LRU链表中young区域与old区域比例是63:37。
划分这两个区域后,预读的页就只需要加入到old区域的头部,
当页被真正访问的时候,才将页插入young区域的头部。
如果预读的页一直没有被访问,就会从old区域移除,这样就不会影响young区域中的热点数据。
举例子来看:
假设有一个长度为10的LRU链表,其中young区域占比70%,old区域占比30%。
现在有个编号为20的页被预读了,这个页只会被插入到old区域头部,而old区域末尾的页(10号)会被淘汰掉。
如果20号页一直不会被访问,它也没有占用到young区域的位置,而且还会比young区域的数据更早被淘汰出去。
如果20号页被预读后,立刻被访问了,那么就会将它插入到young区域的头部,young区域末尾的页(7号),会被挤到old区域,作为old区域的头部,这个过程并不会有页被淘汰。
但是但是,还有个问题无法解决,那就是Buffer Pool污染的问题。
Buffer Pool污染
当某一个SQL语句扫描了大量的数据时,在Buffer Pool空间比较有限的情况下,可能会将Buffer Pool里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘IO,MySQL性能就会急剧下降,这个过程被称为Buffer Pool污染。
如何解决呢?
发生BufferPool污染本质上是:像前面这种全表扫描的查询,很多缓冲页其实只会被访问一次,但是它却只因为被访问了一次而进入到young区域,从而导致热点数据被替换了。
LRU链表中young区域就是热点数据,只要我们提高进入到young区域的门槛,就能有效地保证young区域里的热点数据不会被替换掉。
MySQL是这样做的,进入到young区域条件增加了一个停留在old区域的时间判断。
具体规则是这样:
在对某个处在old区域的缓存页进行第一次访问时,就在它对应的控制块中记录下来这个访问时间。
- 如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该缓存页就不会被从old区域移动到young区域的头部;
- 如果后续的访问时间与第一次访问的时间不在某个时间间隔内,那么该缓存页移动到young区域的头部;
这个间隔时间是由innodb_old_blocks_time
控制的,默认是1000ms。
只有同时满足“被访问”与“在old区域停留时间超过1秒”两个条件,才会被插入到young区域头部。
还有一点:MySQL针对young区域其实做了一个优化,为了防止young区域节点频繁移动到头部。young区域前面1/4被访问不会移动到链表头部,只有后面的3/4被访问了才会。
脏页什么时候会被刷入磁盘
若每次修改数据都刷入磁盘,则性能会很差,因此一般都会在一定时机进行批量刷盘。
InnoDB的更新操作采用的是Write Ahead Log策略,即先写日志,再写入磁盘,通过redo log日志让MySQL拥有了崩溃恢复能力。
下面几种情况会触发脏页的刷新:
- 当redo log日志满了的情况下,会主动触发脏页刷新到磁盘;
- Buffer Pool空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
- MySQL认为空闲时,后台线程会定期将适量的脏页刷入到磁盘;
- MySQL正常关闭之前,会把所有的脏页刷入到磁盘;
总结
Innodb存储引擎设计了一个缓冲池(Buffer Pool),来提高数据库的读写性能。
Innodb通过三种链表来管理缓页:
- Free List(空闲页链表),管理空闲页;
- Flush List(脏页链表),管理脏页;
- LRU List,管理脏页+干净页,将最近且经常查询的数据缓存在其中,而不常查询的数据就淘汰出去。
InnoDB对LRU做了一些优化,我们熟悉的LRU算法通常是将最近查询的数据放到LRU链表的头部,而InnoDB做2点优化:
- 将LRU链表分为young和old两个区域,加入缓冲池的页,优先插入old区域;页被访问时,才进入young区域,目的是为了解决预读失效的问题。
- 当页被访问且old区域停留时间超过innodb_old_blocks_time阈值(默认为1秒)时,才会将页插入到young区域,否则还是插入到old区域,目的是为了解决批量数据访问,大量热数据淘汰的问题。
- 为了防止young区域节点频繁移动到头部。young区域前面1/4被访问不会移动到链表头部,只有后面的3/4被访问了才会。