内存篇,揭开Buffer Pool的神秘面纱
为什么要有Buffer Pool?
MySQL的内存结构中,Buffer Pool是MySQL存储引擎的内存结构,用于缓存磁盘上的数据,减少磁盘IO,提高查询性能。
所以,Buffer Pool是InnoDB引擎的。
Buffer Pool有多大?
Buffer Pool是在MySQL启动的时候,向操作系统申请的一片连续的内存空间,默认只有128MB.
可以通过调整innodb_buffer_pool_size
参数来设置Buffer Pool的大小,一般建议设置成可用物理内存的60%-80%.
Buffer Pool缓存什么?
InnoDB会把存储的数据划分为若干个页,以页作为磁盘和内存交互的基本单位。一个页的默认大小是16KB.
因为MySQL刚启动的时候缓存页都是空闲的,因此此时使用的虚拟内存空间很大,而使用到的物理内存空间却很小。这是因为只有这些虚拟内存被访问后,操作系统才会触发缺页中断,接着将虚拟地址和物理地址建立映射关系。
Buffer Pool除了缓存索引页和数据页,还包括了undo页、插入缓存、自适应哈希索引、锁信息等等。
为了更好地管理这些在Buffer Pool里面的缓存页、InnoDB为每个缓存页都创建了一个控制块,控制块信息包括“缓存页的表空间、页号、缓存页地址、链表节点”
等。
控制块单独放在BufferPool的头部,而缓存页紧随其后。
但是控制块和缓存页之间有灰色的部分,被称为碎片空间。
为什么会有碎片空间?
每个控制块对应一个缓存页、那么分配足够多的控制块和缓存页后,可能剩余的空间不够一对控制块和缓存页大小,自然用不到。这些用不到的内存空间就被称为碎片。
复习一下,之前也写过,就算只查询一条记录,也不止是需要缓冲一条记录。因为InnoDB实际上是以页的方式查询,会把整页的数据加载到buffer pool中,因为通过索引只能定位到磁盘中的页。把页加载到buffer pool后,再通过页里面的页目录去定位到某条具体的记录。
如何管理Buffer Pool?
如何管理空闲页?
Buffer Pool用了一个链表结构,叫Free 链表,来管理空闲缓存页的控制块。
有了空闲链表后,每当需要从磁盘中加载一个页到Buffer Pool中时,就从Free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从Free链表中移除。
如何管理脏页?
设计Buffer Pool除了提高读性能,还能提高写性能。也就是更新数据的时候,不需要每次都写入磁盘,而是把Buffer Pool对应的缓存页标记为脏页。然后后台线程再写入磁盘。
为了快速知道哪些缓存页是脏的,就设计出了Flush链表。跟Free链表都是类似,区别在于Flush链表的元素都是脏页。有了Flush链表之后,后台线程就遍历Flush链表,把脏页写入到磁盘。
如何提高缓存命中率?
Buffer Pool的大小是有限的,为了提高缓存命中率,最容易想到的就是LRU算法。
思路是链表头部节点就是最近使用的,末尾节点是最久未被使用的。当空间不够时,淘汰最久未被使用节点从而腾出空间。
简单实现:
- 当访问的页面在Buffer Pool中,节点移动到链表的头部。
- 不在时,除了要把页面放到LRU链表头部,如果空间满了还要淘汰链表末尾的节点。
所以到这里,可以知道 Buffer Pool有三种页和链表管理数据。
- Free 链表:管理空闲缓存页的控制块。
- LRU链表:管理被使用的缓存页的控制块。
- Flush链表:管理脏缓存页的控制块。
但是实际上简单的LRU算法没有被MySQL使用,因为简单的LRU算法无法避免两个问题:
- 预读失效
- Buffer Pool污染
什么是预读失效
MySQL有预读机制。程序都有空间局部性,靠近当前被访问的数据,在未来很大概率会被访问到。所以MySQL在加载数据页时,会提前把它相邻的数据页一起加载进来,目的是为了减少磁盘IO。
但是可能这些被提前加载进来的数据页,并没有被访问,相当于这个预读是白做了。这个就叫做预读失效。
如果用简单的LRU算法,就会把预读页放到LRU链表头部。这样会有问题,如果这些预读页一直没被访问到,就会导致不会被访问到的预读页却占用了LRU链表前排的位置,而末尾被淘汰掉的页面可能是频繁访问的,这样大大降低了缓存命中率。
怎么解决预读失效导致的缓存命中率降低的问题?
我们不能因为害怕预读失效,而去掉预读机制。大部分情况下,局部性原理还是成立的。
为了解决这个问题,最好让预读的页保留在Buffer Pool内的时间要尽可能短,让真正被访问到的页才移动到LRU头部,使得真正被访问到的热数据留在Buffer Pool里的时间尽可能长。
MySQL改进了LRU算法,把LRU链表划分为了old区域和young区域。
old 区域占整个 LRU 链表长度的比例可以通过 innodb_old_blocks_pct 参数来设置,默认是 37,代表整 个 LRU 链表中 young 区域与 old 区域比例是 63:37。
划分区域后,预读的页只加入old区域的头部,真正被访问到的时候才插入young区域的头部。
什么是Buffer Pool污染?
当某一个 SQL语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去! 导致大量热数据被淘汰了, 等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘IO,MySQL 性能就会急剧下降,这个过程被称为 Buffer Pool 污染。
怎么解决Buffer Pool 污染而导致的缓存命中率下降的问题?
关键是提高进入到young区域的门槛。
MySQL在进入到young区域的条件增加了一个停留在old区域的时间判断。
具体实践:在对某个处在old区域的缓存页第一次访问时,会在它对应的控制块中记录下来这个访问时间:
- 如果后续的访问时间与第一次访问的时间处于某个时间间隔内,那么这个缓存页不会移动到young的头部。
- 如果超过时间间隔,就移动到young头部
间隔时间由参数innodb_old_blocks_time控制,默认是1000ms,也就是1秒。
也就是,同时满足“被访问”与“在old区域与第一次访问的时间间隔超过1秒”两个条件才会被插入到young头部。这样来解决Buffer Pool的问题。
另外,MySQL 针对 young 区域其实做了一个优化,为了防止 young 区域节点频繁移动到头部。young 区域前面 1/4 被访问不会移动到链表头部,只有后面的 3/4被访问了才会。
脏页什么时候会被刷入磁盘?
下面几种情况会触发脏页的刷新:
- redo log日志满了的情况下,会主动触发脏页刷新到磁盘
- Buffer Pool空间不足,会淘汰数据,淘汰的是脏页就刷盘
- MySQL认为空闲时,后台线程自动刷盘
- MySQL正常关闭时,脏页刷盘
开启了慢SQL监控后,如果偶尔有耗时较长的SQL,可能是因为脏页在刷新到磁盘时给数据库带来性能开销,导致数据库抖动。
如果间断出现这种现象,需要调大Buffer Pool空间或者redo log日志的大小。