这里写目录标题
- 一、引入缓存的重要性
- 二、InnoDB 的 Buffer Pool
- 1. Buffer Pool 内部组成
- 2. free 链表管理空闲页
- 3. flush 链表管理脏页
- 4. LRU 链表提高缓存命中
- 那咱需要咋地解决预读问题呢?
- 那咱需要咋地解决 Buffer Pool 污染问题呢?
- 5. 脏页什么时候被刷入磁盘?
- 三、总结
有近俩个来月没有写博客了,也不知道自己在瞎忙活什么。之前我有写过一篇关于 MySQL 的内存池一篇博客 InnoDB体系架构之内存池(buffer pool),那是看楠哥视频后总结出来的。然后现是看了《MySQL 是怎样运行的》一书,也可能是基础相比之前要好了些,理解的要好点了吧,就再总结一篇,现进入正文。
一、引入缓存的重要性
无论是系统数据还是用于存储用户数据的索引(包括聚簇索引和二级索引),都是以页为基本单位存放在表空间中。所谓的表空间,只不过是 InnoDB 对一个或几个实际文件的抽象(ibd)。说到底数据是存放在磁盘上的,那磁盘的速度是非常慢的,而且在磁盘缓冲区和内存之间进行交互的时候也是需要霸占着CPU的,不管是在性能上还是在资源上都是一种不好的选择。所以 InnoDB 存储引擎在处理客户端的请求时,如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中。也就是说,即使你是需要访问某索引页的一条记录,也需要先把整个页的数据加载到内存中(这一方面是和MySQL存储数据是在索引中,另一方面就是为了更好的缓存数据)。将整个页加载到内存中后就可以进行读写访问了,而且在读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省下磁盘 I/O 的开销了。
二、InnoDB 的 Buffer Pool
为缓存上面提到的磁盘中的页,在 MySQL 服务器启动时就向操作系统申请了一片连续的内存,这块内存的名字就叫 Buffer Pool(缓冲池)。
查看 innodb_buffer_pool_size
可以查看到其申请的内存大小,我这分配的是 128mb;
1. Buffer Pool 内部组成
整个Buffer Pool 由控制块、缓冲页和碎片组成。
控制块
:它记录着缓冲页的描述信息,比如该页所属的表空间编号、页号、缓冲页在 Buffer Pool 中的地址等等。设置的innodb_buffer_pool_size
的大小不包含这个。缓冲页
:buffer pool 中存放的【数据页】称之为【缓冲页】,和磁盘上的数据页是一一对应的,都是16KB,缓冲页的数据,是从磁盘上加载到 buffer pool 当中的一个完整页。碎片
:若设置的Buffer Pool 放满了缓冲页,剩余的内存则称之为碎片。
2. free 链表管理空闲页
在最初启动 MySQL 服务器的时候,需要完成 Buffer Pool 的初始化工作。先向系统中申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓冲页。此时是没有磁盘页被存到 Buffer Pool 中的。
而有个 free 链表就是用来管理这些空闲页的,以控制块作为节点,进行连接组成的一个链表。
当有磁盘页需要被缓存到 Buffer Pool 中时,就可以进行下面操作:
- 可以取出一个空闲的缓冲页;
- 然后将其描述信息填入控制块;
- 最后将 free 链表中的对应节点删除,表示已经不再空闲,而哪个磁盘页就放到对应的缓冲页的位置上。
- 然后将
表空间号 + 页号
作为 key,控制块地址
作为 value,存入到一个哈希表中,方便后续读取或写对应的缓冲页。
若读某页的逻辑就大概如下:
3. flush 链表管理脏页
设计 Buffer Pool 除了可以提高读性能,也可提高写性能。当需要更新数据的时,若可以根据 表空间号 + 页号 可以找到对应的控制块地址,说明 Buffer Pool 存在对应的缓冲页,那么进行写操作可以选择直接对 缓冲页 进行写操作,那么它就和对应磁盘上的数据不一致了,这样的缓冲页也被称为脏页。
这些脏页的控制块最后也会被拼接成一个链表——flush 链表
。
和 Free 链表是一样的,只是区别是 Free 链表的结点是空闲缓冲页的控制块,而 Flush 链表的结点是脏页的控制块。
有了 Flush 链表之后,后台线程就可以通过遍历这个链表,然后将脏页写入到磁盘。
从上图的InnoDB存储引擎的体系架构中看出其后台线程所担任的角色:
负责刷新内存池中的数据,保证缓存池中的内存缓存是最近的数据。此外将已修改的数据文件刷新到磁盘文件中,同时保证在数据库中发生异常的情况下 InnoDB 能恢复到正常运行状态。(如:Master Thread:负责将缓存池中的数据异步刷新到磁盘;IO Thread:异步处理IO请求,提高数据库的性能)
4. LRU 链表提高缓存命中
看见 LRU(Least Recently Used) 第一反应就是最近最少使用淘汰策略。
这里使用它的目的就是因为内存的针对,Buffer Pool 的内存资源也是有限的,当无法缓存新的数据的时候,希望把一些不用的缓冲页给淘汰掉,空闲出缓冲页供新的数据页使用。
针对上面的需求,Buffer Pool 使用了 LRU 淘汰策略去实现。
简单的 LRU 算法的实现思路是这样:
- 当访问的页在 Buffer Pool 里,就直接把该页对应的 LRU 链表节点移动到链表的头部;
- 当访问的页不在 Buffer Pool 里,除了要把页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。
阐述了这三种链表,咱就知道 Buffer Pool 是怎样管理数据的了:
解释图:
- Free Page(空闲页),表示此页未被使用,位于 Free 链表;
- Clean Page(干净页),表示此页已经被使用,但是页面未发生修改,位于 LRU 链表;
- Dirty Page(脏页),表示此页【已被使用】且【已经被修改】,其数据和磁盘上的数据已经不一致了。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。脏页同时存在于 LRU 链表和 Flush 链表。
简单的 LRU 算法并没有被 MySQL 使用,因为简单的 LRU 算法无法避免下面这俩个问题:
- 预读失效;
- Buffer Pool 污染;
何为预读?
Innodb 提供了一个看起来比较贴心的服务——预读。所谓预读,就是 InnoDB 认为执行当前的请求时,可能会在后面读取某些页面,于是就预先把这些页面加载到 Buffer Pool 中。默认是线性预读,而不是随机预读。咱就是说预读本来是个好事,但是如果预读的页用不到呢?插入到 LRU 链表的头部,然后满了的话把尾部的页都淘汰掉,从而大大降低 Buffer Pool 的命中率。
大部分情况下局部性原理还是可靠的。
那咱需要咋地解决预读问题呢?
想要避免这种预读效果带来的影响,最好就是让预读页停留在 Buffer Pool 里的时间尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在 Buffer Pool 里的时间尽可能长。
MySQL 改进了 LRU 算法,将 LRU 划分了俩个区域:Old 区域 和 Young 区域。
Young区域和Old区域在 LRU 链表的占比是由 innodb_old_blocks_pct 参数设置的。
划分这俩个区域后,预读的页就只需要加入到 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 污染。
只要咱说无用查询,这种LRU策略就会引起 Buffer Pool 污染。
比如数据量很大的表 t_user,执行了下面这个查询:
select * from t_user where name like "%xiaolin%";
即时这查询的结果就几条,但是这也会引起索引失效,这样就得进行全表扫描(就是说从聚簇索引的叶子节点记录、节点遍历),接下来会发生下面这些过程:
- 从磁盘读到的页加入到 LRU 链表的 Old 区域头部;
- 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 Young 区域头部;
- 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;
- 如此往复,直到扫描完表中的所有记录。
经过这一番折腾,原本 Young 区域的热点数据都会被替换掉。
举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。
在批量访问这些数据的时候,会被逐一插入到 young 区域头部。
可以看到,原本在 young 区域的热点数据 6 和 7 号页都被淘汰了,这就是 Buffer Pool 污染的问题。
那咱需要咋地解决 Buffer Pool 污染问题呢?
很多缓存页只是被访问了一次,但是却只因为被访问了一次而进入到 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被访问了才会。
5. 脏页什么时候被刷入磁盘?
若修改数据时 Buffer Pool 有这个数据,那么自然会直接修改 BufferPool 控制块映射的缓存页,这样一来这个缓存页就被称为脏页,其控制块也会被放入到 Flush 链表中进行管理。
但是脏页终究还是要被刷入磁盘的,保证缓存和磁盘数据的一致,一般情况下为了不影响性能,都会在一定时机进行批量刷盘。
可能大伙担心,如果脏页还没有来得及刷入到磁盘内,MySQL 宕机了,不就丢失数据了吗?
这个不用担心,InnoDB 考虑到这一点,更新操作采用的是 Write Ahead Log 策略,就是说先写入日志,再写入磁盘,通过 redo log 日志让 MySQL 拥有了崩溃恢复的能力。
下面几种情况会触发脏页的刷新:
- 当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
- Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
- MySQL 认为空闲时,后台线程会定期将适量的脏页刷入到磁盘;
- MySQL 正常关闭之前,会把所有的脏页刷入到磁盘;
在开启慢 SQL 监控后,如果你发现 【偶尔】会出现用时稍长的 SQL ,这可能是因为脏页在刷新到磁盘时可能会给数据库带来性能开销,导致数据库操作抖动。
如果间断地出现这种现象,就需要调大 Buffer Pool 空间或 Redo Log 日志的大小。
三、总结
为尽可能地降低磁盘 IO,来提高数据库的读写性能,InnoDB 存储引擎设计了一个 缓冲池(Buffer Pool)。
Buffer Pool 以页为单位缓冲数据,可以通过 innodb_buffer_pool_size
参数调整缓冲池的大小,默认是 128 M。
Buffer Pool 的内部组成有三种角色,控制块、缓冲页、碎片
。
InnoDB 通过三种链表来管理缓冲页:
- Free List(空闲页链表),管理空闲页;
- Flush List(脏页链表),管理脏页;
- LRU List(淘汰链表),管理脏页+干净页,将最近且经常查询的数据缓存在其中,而不常查询的数据就淘汰出去。
InnoDB 对 LRU 做了一些优化,我们熟悉的 LRU 算法通常是将最近查询的数据放到 LRU 链表的头部,而 InnoDB 做了以下俩点优化(当然还有很多优化点,这里阐述的是重要的俩个):
- 将 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 日志的大小。
参考文献:
- 《MySQL是怎样运行的》第十七章 InnoDB 的 Buffer Pool。
- 小林的解开Buffer Pool 的面纱