为什么 要有 Buffer Pool ?
揭开 Buffer Pool 的面纱

为什么 要有 Buffer Pool ?
虽然说 MySQL 的数据是存储在磁盘里的,但是也不 能每次 都从磁盘里面读取数据,这样性能是极
差的。
要想提升查询性能,加个缓存就行了嘛。所以,当数据从磁盘中取出后,缓存内存中,下次查询
同样的数据的时候,直接从内存中读取。
为此,Innodb 存储引擎设计 了一个缓冲池(Buffer Pool ),来提高数据库的读写性能。

有了缓冲池后:
当读取数据时,如果数据存在于 Buffer Pool 中,客⼾端就会直接读取 Buffer Pool 中的数据,
否则再去磁盘中读取。
当修改数 据时,首先是修改 Buffer Pool 中数据所在的⻚,然后将其⻚设置为脏⻚,最后由后台
线程将脏⻚写入到磁盘。
Buffer Pool 有多大?
Buffer Pool 是在 MySQL 启动的时候,向操作系统申请的一片连续的内存空间,默认配置下 Buffer Pool 只有 128MB 。
可以通过调整 innodb_buffer_pool_size 参数来设置 Buffer Pool 的大小,一般建议设 置成可用物理内存的 60%~80% 。
Buffer Pool 缓存什么 ?
InnoDB 会把存储的数据划分 为若干个「⻚」,以⻚作为磁盘和内存交互 的基本单位,一个⻚的默
认大小为 16KB 。因此,Buffer Pool 同样需要按「⻚」来划分 。
在 MySQL 启动的时候,InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的
16KB 的大小划分出 一个个 的⻚, Buffer Pool 中的⻚就叫做缓存⻚。此时这些缓存⻚都是空闲
的,之后随着程序的运行,才会有磁盘上的⻚被缓存到 Buffer Pool 中。
所以,MySQL 刚启动的时候,你会 观察到使用的虚拟内存空间很大,而使用到的物理内存空间却
很小,这是因为只有这些虚拟内存被访问后,操作系统才会触发缺⻚中断,接着将虚拟地址 和物
理地址 建立映射关系。
Buffer Pool 除了缓存「索引⻚」和「数据⻚」,还包括了 undo ⻚,插入缓存、自适应哈希索引、
锁信息等等 。

为了 更好的管理这些在 Buffer Pool 中的缓存⻚,InnoDB 为每一个缓存⻚都创建了一个控制块,控
制块信息包括「缓存⻚的表空间、⻚号、缓存⻚地址 、链表节点」等等 。
控制块也是占有内存空间的,它是放在 Buffer Pool 的最前面,接着才是缓存⻚,如下图:

上图中控制块和缓存⻚之间灰色部分称为碎片空间。
为什么 会有碎片空间呢?
你想想 啊,每一个控制块都对应一个缓存⻚,那在分配足够多 的控制块和缓存⻚后,可能剩余的
那点儿空间不够一对控制块和缓存⻚的大小,自然就用不到喽,这个用不到的那点儿内存空间就
被称为碎片了。
当然,如果你把 Buffer Pool 的大小设置的刚刚 好的话,也可能不会产生碎片。
查询一条记录,就只需要缓冲一条记录吗?
不是的。
当我们查询一条记录时,InnoDB 是会把整个⻚的数据加载到 Buffer Pool 中,因为,通过索引只能
定位到磁盘中的⻚,而不能定位到⻚中的一条记录。将⻚加载到 Buffer Pool 后,再通过⻚里的⻚
目录去定位到某条 具体的记录。
关于⻚结构⻓什么 样和索引怎么查询数据的问题可以在这篇找到答案:换一个⻆度看 B+ 树
如何管理 Buffer Pool ?
如何管理空闲⻚?
Buffer Pool 是一片连续的内存空间,当 MySQL 运行一段时间后,这片连续的内存空间中的缓存⻚
既有空闲的,也有被使用的。
那当我们从磁盘读取数据的时候,总不能通过遍历这一片连续的内存空间来找到空闲的缓存⻚
吧,这样效率太低了。
所以,为了 能够快速找到空闲的缓存⻚,可以使 用链表结构,将空闲缓存⻚的「控制块」作为链
表的节点,这个链表称为 Free 链表(空闲链表)。

Free 链表上除了有控制块,还有一个头节点,该头节点包含链表的头节点地址 ,尾节点地址 ,以
及当前链表中节点的数量等信息。
Free 链表节点是一个一个的控制块,而每个控制块包含着对应缓存⻚的地址 ,所以相当于 Free 链
表节点都对应一个空闲的缓存⻚。
有了 Free 链表后,每当需要从磁盘中加载一个⻚到 Buffer Pool 中时,就从 Free 链表中取一个空
闲的缓存⻚,并且把该缓存⻚对应的控制块的信息填上,然后把该缓存⻚对应的控制块从 Free 链
表中移除。
如何管理脏⻚?
设计 Buffer Pool 除了能提高读性能,还能提高写性能,也就是更新数 据的时候,不需要每次 都要
写入磁盘,而是将 Buffer Pool 对应的缓存⻚标记为脏⻚,然后再由后台 线程将脏⻚写入到磁盘。
那为了 能快速知道哪些缓存⻚是脏的,于是就设计 出 Flush 链表,它跟 Free 链表类似的,链表的
节点也是控制块,区别在于 Flush 链表的元素都是脏⻚。

有了 Flush 链表后,后台 线程就可以遍历 Flush 链表,将脏⻚写入到磁盘。
如何提高缓存命中率?
Buffer Pool 的大小是有限的,对于一些频繁访问的数据我们希望可以一直留在 Buffer Pool 中,而
一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证 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 算法并没有被 MySQL 使用,因为简单的 LRU 算法无法避免下面这两个 问题:
预读失效;
Buffer Pool 污染;
什么 是预读失效?
先来说说 MySQL 的预读机制。程序是有空间局部性的,靠近当前被访问数据的数据,在未来 很大
概率会被访问到。
所以,MySQL 在加载数据⻚时,会提前把它相邻的数据⻚一并加载进 来,目的是为了 减少磁盘
IO 。
但是可能这些被提前加 载进 来的数据⻚,并没有被访问,相当于这个预读是白做了,这个就是预
读失效。
如果使用简单的 LRU 算法,就会把预读⻚放到 LRU 链表头部,而当 Buffer Pool 空间不够的时候,
还需要把末尾的⻚淘汰掉。
如果这些预读⻚如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读⻚却占
用了 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 区域的头部,这个过程并不会有⻚被淘汰。

虽然通过划分 old 区域 和 young 区域避免了预读失效带来的影响,但是还有个问题无法解决,那
就是 Buffer Pool 污染的问题。
什么 是 Buffer Pool 污染?
当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有⻚都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由
于缓存未命中,就会产生大量的磁盘 IO ,MySQL 性能就会急剧下降,这个过程被称为 Buffer Pool 污染。
注意, Buffer Pool 污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结
果集很小,也会造成 Buffer Pool 污染。
比如,在一个数据量非常大的表,执行了这条语句:
可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是
全表扫描的,接着会发生如下的过程:
从磁盘读到的⻚加入到 LRU 链表的 old 区域头部;
当从⻚里读取行记录时,也就是⻚被访问的时候,就要将该⻚放到 young 区域头部;
接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集
里;
如此往复,直到扫描完表中的所有记录。
经过这 一番折腾,原本 young 区域的热点 数据都会被替换掉 。
举个 例子,假设需要批量扫描:21 ,22 ,23 ,24 ,25 这五个 ⻚,这些⻚都会被逐一访问(读取⻚里的记录)。

在批量访问这些数据的时候,会被逐一插入到 young 区域头部。

select * from t_user where name like "%xiaolin%" ;可以看到,原本在 young 区域的热点 数据 6 和 7 号⻚都被淘汰了,这就是 Buffer Pool 污染的问
题。
怎么解决出现 Buffer Pool 污染而导致缓存命中率下降的问题?
像前面这种全表扫描的查询,很多缓冲⻚其实只会被访问一次,但是它却只因为被访问了一次而
进入到 young 区域,从而导致热点 数据被替换了。
LRU 链表中 young 区域就是热点 数据,只要我们提高进入到 young 区域的⻔槛,就能有效地保证
young 区域里的热点 数据不会被替换掉 。
MySQL 是这样做的,进入到 young 区域条件增加了一个停留在 old 区域的时间判断。
具体是这样做的,在对某个处在 old 区域的缓存⻚进行第一次访问时,就在它对应的控制块中记
录下来这个访问时间:
如果后续的访问时间与第一次访问的时间在某个时间间隔 内,那么该缓存⻚就不会被从 old 区
域移动到 young 区域的头部;
如果后续的访问时间与第一次访问的时间不在某个时间间隔 内,那么该缓存⻚移动到 young 区
域的头部;
这个间隔 时间是由 innodb_old_blocks_time 控制的,默认是 1000 ms 。也就说,只有同时满足「被访问」与「在 old 区域停留时间超过 1 秒」两个 条件,才会被插入到
young 区域头部,这样就解决了 Buffer Pool 污染的问题 。
另外,MySQL 针对 young 区域其实做了一个优化,为了 防止 young 区域节点频繁移动到 头部。
young 区域前面 1/4 被访问不会移动到 链表头部,只有后面的 3/4 被访问了才会。
脏⻚什么 时候会被刷入磁盘?
引入了 Buffer Pool 后,当修改数 据时,首先是修改 Buffer Pool 中数据所在的⻚,然后将其⻚设置
为脏⻚,但是磁盘中还是原数据。
因此,脏⻚需要被刷入磁盘,保证缓存和磁盘数据一致,但是若每次 修改数 据都刷入磁盘,则性
能会很差,因此一般都会在一定时机进行批量刷盘。
可能大家担心,如果在脏⻚还没有来 得及刷入到磁盘时,MySQL 宕机了,不就丢失数据了吗?
这个不 用担心,InnoDB 的更新操作采用的是 Write Ahead Log 策略,即先写 日志,再写 入磁盘,
通过 redo log 日志让 MySQL 拥有了崩溃恢复能力。
下面几种情况会触发脏⻚的刷新:
当 redo log 日志满了的情况下,会主动触发脏⻚刷新到磁盘;
Buffer Pool 空间不足时,需要将一部分数据⻚淘汰掉,如果淘汰的是脏⻚,需要先将脏⻚同步
到磁盘;
MySQL 认为空闲时,后台 线程会定期将适量的脏⻚刷入到磁盘;
MySQL 正常关闭之前,会把所 有的脏⻚刷入到磁盘;
在我们开启了慢 SQL 监控后,如果你发现** 「偶尔」会出现一些用时稍⻓的 SQL** ,这可能是因
为脏⻚在刷新到磁盘时可能会给数据库带 来性能开销,导致数据库操作抖动。
如果间断出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。
总结
Innodb 存储引擎设计 了一个缓冲池(Buffer Pool ),来提高数据库的读写性能。
Buffer Pool 以⻚为单位缓冲数据,可以通过 innodb_buffer_pool_size 参数调整缓冲池的大小,默认是 128 M 。
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 区域,目的是为了 解决批量数据访问,
大量热数据淘汰的问题。
可以通过调整 innodb_old_blocks_pct 参数,设置 young 区域和 old 区域比例。在开启了慢 SQL 监控后,如果你发现「偶尔」会出现一些用时稍⻓的 SQL ,这可因为脏⻚在刷新
到磁盘时导致数据库性能抖动。如果在很短的时间出现这种现象,就需要调大 Buffer Pool 空间或
redo log 日志的大小。
