Mysql高级篇(InnoDB的Buffer Pool)

InnoDB的Buffer Pool

    • 前言
    • 缓存的重要性
    • InnoDB的Buffer Pool
      • Buffer Pool
    • Buffer Pool内部组成
      • free链表的管理
      • 缓存页的哈希处理
      • flush链表的管理
      • LRU链表的管理
        • 缓存不够的窘境
        • 简单的LRU链表
        • 划分区域的LRU链表
      • 其他的一些链表
      • 刷新脏页到磁盘
      • 多个Buffer Pool实例
      • Buffer Pool中存储的其它信息
      • 查看Buffer Pool的状态信息
    • 总结

前言

本文摘自:MySQL是怎样运行的:从根儿上理解MySQL

缓存的重要性

即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行 读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其 缓存 起来,这样将来有 请求再次访问该页面时,就可以省去磁盘 IO 的开销了。

InnoDB的Buffer Pool

Buffer Pool

设计 InnoDB 的大叔为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申请了一片连续的内存,他 们给这片内存起了个名,叫做 Buffer Pool (中文名是 缓冲池 )。那它有多大呢?这个其实看我们机器的配 置,如果你是土豪,你有 512G 内存,你分配个几百G作为 Buffer Pool 也可以啊,当然你要是没那么有钱,设 置小点也行呀~ 默认情况下 Buffer Pool 只有 128M 大小。当然如果你嫌弃这个 128M 太大或者太小,可以在启 动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示 Buffer Pool 的大小,就像这样:

[server]
innodb_buffer_pool_size = 268435456

其中, 268435456 的单位是字节,也就是我指定 Buffer Pool 的大小为 256M 。需要注意的是,Buffer Pool也 不能太小,最小值为 5M (当小于该值时会自动设置成 5M )。

Buffer Pool内部组成

Buffer Pool 中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB 。为了更好的管理这些在 Buffer Pool 中的缓存页,设计 InnoDB 的大叔为每一个缓存页都创建了一些所谓的 控制信息 ,这些控制信息 包括该页所属的表空间编号、页号、缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息

每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个 控制块 吧,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:

Mysql高级篇(InnoDB的Buffer Pool)_第1张图片

咦?控制块和缓存页之间的那个 碎片 是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配 足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用 不到的那点儿内存空间就被称为 碎片 了。当然,如果你把 Buffer Pool 的大小设置的刚刚好的话,也可能不会 产生 碎片 ~

小贴士: 每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。 而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB 在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_poo l_size的值大5%左右。

free链表的管理

我们最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。那么问 题来了,从磁盘上读取一个页到 Buffer Pool 中的时候该放到哪个缓存页的位置呢?或者说怎么区分 Buffer Pool 中哪些缓存页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可 用的,这个时候缓存页对应的 控制块 就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节 点放到一个链表中这个链表也可以被称作 free链表 (或者说空闲链表)。刚刚完成初始化的 Buffer Pool 中 所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free链表 中,假设该 Buffer Pool 中 可容纳的缓存页数量为 n ,那增加了 free链表 的效果图就是这样的:

Mysql高级篇(InnoDB的Buffer Pool)_第2张图片

从图中可以看出,我们为了管理好这个 free链表 ,特意为这个链表定义了一个 基节点 ,里边儿包含着链表的头 节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间 并不包含在为 Buffer Pool 申请的一大片连续内存空间之内,而是单独申请的一块内存空间。

有了这个 free链表 之后事儿就好办了,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 free链表 中 取一个空闲的缓存页,并且把该缓存页对应的 控制块 的信息填上(就是该页所在的表空间、页号之类的信 息),然后把该缓存页对应的 free链表 节点从链表中移除,表示该缓存页已经被使用了~

缓存页的哈希处理

我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到 Buffer Pool 中,如果该页已经 在 Buffer Pool 中的话直接使用就可以了。那么问题也就来了,我们怎么知道该页在不在 Buffer Pool 中呢?难 不成需要依次遍历 Buffer Pool 中各个缓存页么?一个 Buffer Pool 中的缓存页这么多都遍历完岂不是要累死?

再回头想想,我们其实是根据 表空间号 + 页号 来定位一个页的,也就相当于 表空间号 + 页号 是一个 key , 缓存页 就是对应的 value ,怎么通过一个 key 来快速找着一个 value 呢?哈哈,那肯定是哈希表喽~

回头想想,我们其实是根据 表空间号 + 页号 来定位一个页的,也就相当于 表空间号 + 页号 是一个 key , 缓存页 就是对应的 value ,怎么通过一个 key 来快速找着一个 value 呢?哈哈,那肯定是哈希表喽~

所以我们可以用 表空间号 + 页号 作为 key , 缓存页 作为 value 创建一个哈希表,在需要访问某个页的数据 时,先从哈希表中根据 表空间号 + 页号 看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没 有,那就从 free链表 中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

散列表Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表

flush链表的管理

如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为 脏 页 (英文名: dirty page )。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是 频繁的往磁盘中写数据会严重的影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并 不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步

但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是 脏页 ,哪些页从 来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如 Buffer Pool 被设置的很大,比方说 300G , 那一次性同步这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是修改过的缓存页对 应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的, 所以也叫 flush链表 。链表的构造和 free链表 差不多,假设某个时间点 Buffer Pool 中的脏页数量为 n ,那么 对应的 flush链表 就长这样:

Mysql高级篇(InnoDB的Buffer Pool)_第3张图片

LRU链表的管理

缓存不够的窘境

Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 大小,也就 是 free链表 中已经没有多余的空闲缓存页的时候岂不是很尴尬,发生了这样的事儿该咋办?当然是把某些旧的 缓存页从 Buffer Pool 中移除,然后再把新的页放进来喽~ 那么问题来了,移除哪些缓存页呢?

为了回答这个问题,我们还需要回到我们设立 Buffer Pool 的初衷,我们就是想减少和磁盘的 IO 交互,最好每 次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。假设我们一共访问了 n 次页,那么被访问的页已经 在缓存中的次数除以 n 就是所谓的 缓存命中率 ,我们的期望就是让 缓存命中率 越高越好~那也就是说留下缓存命中率高的缓冲页!

简单的LRU链表

管理 Buffer Pool 的缓存页其实也是这个道理,当 Buffer Pool 中不再有空闲的缓存页时,就需要淘汰掉部分最 近很少使用的缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?呵呵,神奇的链表 再一次派上了用场,我们可以再创建一个链表,由于这个链表是为了 按照最近最少使用 的原则去淘汰缓存页 的,所以这个链表可以被称为 LRU链表 (LRU的英文全称:Least Recently Used,最近最少使用的)。当我们需要访问某个页时, 可以这样处理 LRU链表 :

  • 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的 控制块 作为节点塞到链表的头部。
  • 如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的 控制块 移动到 LRU链表 的头部

也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU链表 的头部,这样 LRU链表 尾部就是最近最少 使用的缓存页喽~ 所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU链表 的尾部找些缓存页淘汰就OK啦

划分区域的LRU链表

上边的这个简单的 LRU链表 用了没多长时间就发现问题了,因为存在这两种比较尴尬的情况:

情况一: InnoDB 提供了一个看起来比较贴心的服务—— 预读 (英文名: read ahead )。所谓 预读 ,就 是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。根据触发 方式的不同, 预读 又可以细分为下边两种:

  • 线性预读

设计 InnoDB 的大叔提供了一个系统变量 innodb_read_ahead_threshold ,如果顺序访问了某个区 ( extent )的页面超过这个系统变量的值,就会触发一次 异步 读取下一个区中全部的页面到 Buffer Pool 的请求

  • 随机预读

如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发 一次 异步 读取本区中所有其的页面到 Buffer Pool 的请求

预读 本来是个好事儿,如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执 行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的 容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU链表 尾部的一些缓存页会很快的 被淘汰掉,也就是所谓的 劣币驱逐良币 ,会大大降低缓存命中率。

情况二:有的小伙伴可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有 WHERE子句的查询)。

扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特 别多的 ,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,这也就意味着吧唧一下, Buffer Pool 中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这 严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率

总结一下上边说的可能降低 Buffer Pool 的两种情况:

  • 加载到 Buffer Pool 中的页不一定被用到。

  • 如果非常多的使用频率偏低的页被同时加载到 Buffer Pool 时,可能会把那些使用频率非常高的页从 Buffer Pool 中淘汰掉。

因为有这两种情况的存在,所以设计 InnoDB 的大叔把这个 LRU链表 按照一定比例分成两截,分别是:

  • 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做 热数据 ,或者称 young区域
  • 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做 冷数据 ,或者称 old区域

为了方便大家理解,我们把示意图做了简化,各位领会精神就好:

Mysql高级篇(InnoDB的Buffer Pool)_第4张图片

我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某 些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化

有了这个被划分成 young 和 old 区域的 LRU 链表之后,设计 InnoDB 的大叔就可以针对我们上边提到的两种可能 降低缓存命中率的情况进行优化了:

  • 针对预读的页面可能不进行后续访情况的优化

设计 InnoDB 的大叔规定,**当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应 的控制块会被放到old区域的头部。**这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页

  • 针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化

在进行全表扫描时,虽然首次被加载到 Buffer Pool 的页被放到了 old 区域的头部,但是后续会被马上访问 到,每次进行访问的时候又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶 下去。

以我们只需要规定,在对某个处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中 记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被 从old区域移动到young区域的头部,否则将它移动到young区域的头部

上述的这个间隔时间是由系统变量 innodb_old_blocks_time 控制

mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_time';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_old_blocks_time | 1000 |
+------------------------+-------+
1 row in set (0.01 sec)

综上所述,正是因为将 LRU 链表划分为 young 和 old 区域这两个部分,又添加了 innodb_old_blocks_time 这个 系统变量,才使得预读机制全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全 表扫描的页面都只会被放到 old 区域,而不影响 young 区域中的缓存页。

其他的一些链表

为了更好的管理 Buffer Pool 中的缓存页,除了我们上边提到的一些措施,设计 InnoDB 的大叔们还引进了其他 的一些 链表 ,比如 unzip LRU链表 用于管理解压页zip clean链表用于管理没有被解压的压缩页zip free数组 中每一个元素都代表一个链表,它们组成所谓的 伙伴系统 来为压缩页提供内存空间等等,反正是为了 更好的管理这个 Buffer Pool 引入了各种链表或其他数据结构

刷新脏页到磁盘

后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种 刷新路径:

  • LRU链表的冷数据中刷新一部分页面到磁盘。

后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称 之为 BUF_FLUSH_LRU 。

  • flush链表 中刷新一部分页面到磁盘。

后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种 刷新页面的方式被称之为 BUF_FLUSH_LIST

有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存 页,这时就会尝试看看 LRU链表 尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU链表 尾 部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁 盘中的刷新方式被称之为 BUF_FLUSH_SINGLE_PAGE 。

多个Buffer Pool实例

我们上边说过, Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要加锁处理啥的,在 Buffer Pool 特别大而且多线程并发访问特别高的情况下, 单一的 Buffer Pool 可能会影响请求的处理速度。所以在 Buffer Pool 特别大的时候,我们可以把它们拆分成若 干个小的 Buffer Pool ,每个 Buffer Pool 都称为一个 实例 ,它们都是独立的,独立的去申请内存空间,独立 的管理各种链表

所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。我们可 以在服务器启动的时候通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例的个数,比方说 这样:

[server]
innodb_buffer_pool_instances = 2

这样就表明我们要创建2个 Buffer Pool 实例,示意图就是这样:

Mysql高级篇(InnoDB的Buffer Pool)_第5张图片

Buffer Pool中存储的其它信息

Buffer Pool 的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息,后面再说~

查看Buffer Pool的状态信息

SHOW ENGINE INNODB STATUS\G

Mysql高级篇(InnoDB的Buffer Pool)_第6张图片

  • Pending writes LRU :即将从 LRU 链表中刷新到磁盘中的页面数量。

  • Pending writes flush list :即将从 flush 链表中刷新到磁盘中的页面数量。

  • Pending writes single page :即将以单个页面的形式刷新到磁盘中的页面数量。

  • Pages made young :代表 LRU 链表中曾经从 old 区域移动到 young 区域头部的节点数量。

这里需要注意,一个节点每次只有从 old 区域移动到 young 区域头部时才会将 Pages made young 的值加 1,也就是说如果该节点本来就在 young 区域,由于它符合在 young 区域1/4后边的要求,下一次访问这个页 面时也会将它移动到 young 区域头部,但这个过程并不会导致 Pages made young 的值加1。

  • not young :在将 innodb_old_blocks_time 设置的值大于0时,首次访问或者后续访问某个处 在 old 区域的节点时由于不符合时间间隔的限制而不能将其移动到 young 区域头部时, Page made not young 的值会加1。

总结

  1. 磁盘太慢,用内存作为缓存很有必要!
  2. BufferPool本质上是InnoDB向操作系统申请的一段连续的内存空间,可以通过Innodb_buffer_pool_size来调整它的大小
  3. BufferPool向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓冲页都是一一对应的,在填充足够多的控制块和缓存页的组合后,BufferPool剩余的空间可能不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为碎片
  4. InnoDB使用了许多链表来管理BufferPool
  5. free链表记录空闲的缓存页,将磁盘中的页加载到BufferPool时,会从free链表中寻找空闲的缓存页
  6. 为了快速定位某个页是否被加载到BufferPool,使用表空间+页号作为key,缓存页作为value建立哈希表
  7. flush链表记录脏页,脏页并不是立即刷新,而是被加到flush链表中,待之后的某个时刻同步到磁盘上
  8. LRU链表它是为了解决当缓存页用完的时候需要剔一些不常用的缓存数据页,留下缓存命中率高的缓存页!只要我们使用到某个缓存页,就把该缓存页调整到 LRU链表 的头部也就是LRU链表的young部分,这样 LRU链表 尾部也就是old部分就是最近最少使用的缓存页所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU链表 的尾部找些缓存页淘汰就OK啦
  9. 我们可以通过指定 innodb_buffer_pool_instances 来控制 Buffer Pool 实例的个数,每个 Buffer Pool 实 例中都有各自独立的链表,互不干扰。
  10. 可以用下边的命令查看 Buffer Pool 的状态信息:
SHOW ENGINE INNODB STATUS\G

你可能感兴趣的:(#,Mysql,mysql,数据库,java)