1. 结构设计

image.png

1.1 数据页

  • MySQL 中抽象出来的数据单位,每一个数据页放了很多行数据;
  • 数据在磁盘和内存中都是以数据页为单位来调用的;
  • 内存中的数据页,又称为缓存页;
  • 数据页默认大小 16KB
  • 如果要更新一行数据,就会找到这行数据所在的数据页,然后从磁盘里将这个数据页加载到 Buffer Pool 里去;

    1.2 缓存页的描述数据

  • 在 Buffer Pool 中,每个缓存页都有一个块描述数据,包含:

    • 这个数据页所属表空间
    • 数据页编号
    • 在 Buffer Pool 中缓存页所在内存地址;
    • 等等;
  • 描述数据的大小,相当于缓存页的5%,大概 800Byte 左右;

    1.3 free 链表

  • 作用:管理空闲的缓存页;

  • 结构设计:
    • 它是一个双向链表结构;
    • free 链表里的每一个节点就是一个空闲的缓存页的描述数据块
    • free 链表有一个基础节点,它会引用链表的头节点和尾节点,里面还存储了链表中有多少个节点;
    • free 链表依靠描述数据块中包含的两个指针:free_pre 和 free_next,将所有的描述数据块串成一个 free 链表;
  • free 链表是一个逻辑概念,并不是真的有一个描述数据的地址集合;
  • 基础节点不属于 Buffer Pool,它是一个40 byte 大小的节点,里面放了 free 链表的头节点地址、尾节点地址、节点总数;

    1.4 数据页缓存的哈希表

  • 作用:查看数据页是否已被缓存;

  • 结构设计:

    • 哈希表数据结构;
    • 表空间号+数据页号,作为一个 key,缓存页的地址作为 value;

      1.5 flush 链表

  • 作用:管理被修改过的缓存页,即需要刷新回磁盘的有脏数据的缓存页;

  • 结构设计:

    • 它和 free 链表类似,也是一个双向链表;
    • flush 链表里的每个节点就是修改过的缓存页的描述数据块
    • flush 链表有一个基础节点,它会引用链表的头节点和尾节点,里面还存储了链表中有多少个节点;
    • flush 链表本质也是依靠缓存页的描述数据块中的两个指针:flush_pre 和 flush_next,让被修改过的缓存页的描述数据块,组成一个双向链表;

      1.6 LRU 链表

  • 作用:Least Recently Used,使用 LRU 链表来判断哪些缓存页是最不常使用的,根据 LRU 链表去淘汰缓存页;

  • 结构设计:
    • 它也是一个双向链表,结构和 flush、free 链表类似;
  • 工作原理:
    • 只要是将数据从磁盘中加载到缓存里,变成非空闲的缓存页,它对应的描述数据块都会放入 LRU 链表;
    • 最近被加载的数据页,都会放到 LRU 链表的头部去;
    • 本来在 LRU 链表尾部的缓存页,只要你查询、或修改了这个缓存页的数据,就要把这个缓存页挪动到 LRU 链表的头部去:最近被访问的缓存页,一定在 LRU 链表的头部;
    • 当你没有空闲的缓存页的时候,就把 LRU 链表最尾部的那个缓存页刷新回磁盘中,然后把你需要的数据从磁盘加载到空闲出来的缓存页中去;
  • 简单的 LRU 链表存在的隐患:
    • MySQL 的预读机制:
      • 触发条件:
        • innodb_read_ahead_threshold,默认56,如果顺序的访问了一个区里的多个数据页,访问的数据页的数量超过了这个阈值,就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓存里去;(主要触发规则)
        • innodb_random_read_ahead,默认 OFF,如果 Buffer Pool 里缓存了一个区里的 13 个连续的数据页,而且这些数据页都是会被比较频繁的访问,就会触发预读机制,把这个区里的其他的数据页都加载到缓存里去;
      • 隐患:
        • 默认情况下,主要第一规则触发预读机制,一下子把下一个相邻区里的所有数据页都加载到缓存区,这些其实没什么人会访问的缓存页会都放在 LRU 链表的前面,而且导致之前就在缓存里被频繁访问的缓存页挪动到了尾部,如果此时要淘汰一些缓存页,就会把这些本来被频繁访问的缓存页刷新回磁盘,而那些不怎么被访问的却留在了缓存里,这是不合理的!
    • 全表扫描:
      • 查询时,没有加 where 条件,将这个表的所有数据页都加载到 Buffer Pool;
      • 隐患:
        • 全表扫描加载进来的数据页,会占据 LRU 链表的头部,如果淘汰缓存页时,将一直被频繁访问的缓存页给淘汰掉了,而后续全表扫描的数据又几乎都没有用到,这就是不合理的!
  • 优化后的基于冷热数据分离的 LRU 链表(MySQL 真正采用的):

    • 结构设计:
      • LRU 链表会被拆为两个部分:热数据、冷数据,默认占比:63%、37%,可配置 innodb_old_blocks_pct 参数;
    • 工作原理:
      • 数据页第一次加载到缓存,会被放到冷数据区域的链表头部;
      • (冷区转热区)如果一个数据页被加载到缓存页之后,在1s之后,你还访问了这个缓存页,说明你后续很可能会经常要访问它,它才会被挪动到热数据区域的链表头部去;
      • (热区内部优化)热数据区域都是可能频繁被访问的数据页,并不是一被访问就要移到热区域的表头,只有热数据区域的后 3/4 部分的缓存页被访问了,才会给你移动到热区域的头部;如果你是热数据区域的前 1/4 部分的缓存页被访问,是不会移动到热区域的头部;尽可能的减少链表中的节点移动,提高性能;

        1.7 配置参数

  • innodb_buffer_pool_size:设置 Buffer Pool 大小;

  • innodb_buffer_pool_instances:设置多个 buffer pool 提高并发能力;
    • 如果设置 pool_size 128M,Buffer Pool 真正大小其实会超出一些,128+128*0.05=134.4,里面还要包含每个缓存页的描述数据;
  • innodb_old_blocks_pct:默认37,设置 LRU 链表的冷热数据比例,即冷数据占37%;
  • innodb_old_blocks_time:默认1000,即1000毫秒,一个数据页被加载到缓存页之后,在1s之后,你访问这个缓存页,他才会被挪动到热数据区域的链表头部;
  • innodb_buffer_pool_chunk_size:默认128MB,chunk 结构块的大小;

    2. 工作原理

    2.1 Buffer Pool 的初始化

  • 数据库启动,会按照你设置的 innodb_buffer_pool_size 大小,稍微再加大一点,去找操作系统申请一块内存区域作为 Buffer Pool 的内存区域;

  • 内存区域申请完毕后,MySQL 会按照默认的 16KB 缓存页大小,以及对应的 800byte 左右的描述数据的大小,将 Buffer Pool 划分成一个一个空白的缓存页,以及对应的描述数据块;
  • 可能所有的缓存页都是空闲的,一条数据都没有,free 链表保存了所有空白缓存页的描述数据块;

    2.2 查看数据页缓存哈希表

  • 执行 CURD 的时候,先看看这个数据页有没有被缓存;

  • 通过“表空间号+数据页号”作为 key 去哈希表里查一下,如果已经缓存了就直接使用,如果没有缓存就往下执行;

    2.3 将磁盘上的数据页加载到 Buffer Pool 的缓存页中去

  • 从 free 链表获取一个描述数据块,然后依赖它获取到对应的空闲缓存页;

  • 将磁盘上的数据页读取到对应的空闲缓存页中去,同时把相关的一些描述数据写入到对应的描述数据块中去,比如这个数据页所属的表空间之类的信息;
  • 把这个描述数据块从 free 链表里移除掉;
  • 在数据页缓存哈希表中,写入一个 key-value 对,key 是表空间号+数据页号,value 是缓存页地址,下次再次访问这个数据页时,直接使用;
  • LRU 链表的冷数据区域的头部,放入这个缓存页;

    2.4 查询或修改加载进来的缓存页

  • 查询:如果在加载进缓存的 1s 之后,查询了这个缓存页

    • 如果本来就在冷数据区,那么就会把这个缓存页从 LRU 链表的冷数据区域中移动到热数据区头部;
    • 如果本来就在热数据区,且在后面 3/4 部分,那么就移动到热数据的头部;
    • 如果本来就在热数据区,且在前面 1/4 部分,那么就不会移动;
  • 修改:

    • 将这个修改过的脏页加入到 flush 链表里;
    • 按照和查询一样的规则,将数据页在 LRU 链表里移动;

      2.4 当不停的加载数据页后,没有空闲缓存页时,淘汰哪些缓存页?时机是怎样的?

  • 定时把 LRU 链表尾部的部分缓存页刷入磁盘;

    • 后台线程运行一个定时任务,每隔一段时间就回把 LRU 链表的冷数据区尾部的一些缓存页,刷新回磁盘,清空这几个缓存页,把它们加入回 free 链表,从 flush 链表中移除,从 LRU 链表中移除;
    • 实际上在缓存页没用完的时候,可能就回清空一些缓存页出来;
  • 仅仅把 LRU 链表冷数据区的刷入磁盘是不够的,不能因为 LRU 热数据区的缓存页被频繁的修改,就让它们永远的停留在缓存里,不刷入磁盘中,这是不合理的;
  • 定时把 flush 链表中的一些缓存刷入磁盘;
    • 后台线程会在 MySQL 不怎么忙的时候,把 flush 链表中的缓存页都刷入磁盘中,这样被你修改过的数据,迟早都会刷入磁盘,同时这些缓存页会从 flush 链表和 LRU 链表中移除,然后加入 free 链表中去;
  • 极端情况,free 链表没有空间缓存页了,如果要从磁盘加载数据页到一个空闲缓存页,此时就会从 LRU 链表冷数据区的尾部找到一个缓存页,把它刷入磁盘和清空,然后把数据页加载到这个腾出来的空闲缓存页里去;

:::info Buffer Pool 简化版的动态运行效果:

  • 一边不停的加载数据到缓存页里,不停的查询和修改缓存数据,然后 free 链表中的缓存页不停的减少,flush 链表中的缓存页不停的在增加,LRU 链表中的缓存页不停的在增加和移动;
  • 后台线程不停的在 LRU 链表的冷数据区域的缓存页以及 flush 链表的缓存页,刷入磁盘中来清空缓存页,然后 flush 链表和 LRU 链表中的缓存页在减少,free 链表中的缓存页在增加; :::

3. Buffer Pool 的生产优化经验

3.1 多个 Buffer Pool 优化并发能力

  • 默认情况下,如果你给 Buffer Pool 分配的内存小于 1GB,那么最多就只会给你一个 Buffer Pool;
  • 如果机器的内存很大,分配给 Buffer Pool 的内存页很大,比如 8GB,那么此时就可以同时设置多个 Buffer Pool,例如下面的配置:

    • 设置了 4个 buffer pool,每个2GB;
    • 每个 buffer pool 负责一部分的缓存页和描述数据,但是它们有自己独立的 free、flush、LRU 链表;
      1. [server]
      2. innodb_buffer_pool_size = 8589934592
      3. innodb_buffer_pool_instances = 4
  • 多个线程可以在不同的 buffer pool 中加锁和执行自己的操作,可以并发执行;

    3.2 基于 chunk 机制支持运行时动态调整 Buffer Pool 大小

  • chunk 默认大小 128MB,可设置 innodb_buffer_pool_chunk_size 参数;

    • Buffer Pool 总大小 8G,设置4个 buffer pool,每个 buffer pool 2GB、包含 16 个 chunk;
  • 每个 buffer pool 里的 chunk就是一系列的描述数据和数据页,buffer pool 里的所有 chunk 共享一套 free、flush、LRU 链表;
  • 假设现在 Buffer Pool 总大小8G,要动态加到16G,那么只要盛情一系列的128MB大小的 chunk 就可以了,只要每个 chunk 试连续的128M内存就可以,然后把这些申请到的 chunk 内存分配给 Buffer pool 就可以,而并不需要额外申请16G连续内存空间,将已有数据拷贝过去;

    3.3 应该给 Buffer Pool 设置多少内存?

  • 关键公式:Buffer Pool 总大小 = (chunk大小 * buffer pool 数量) 的倍数;

  • 例如,buffer pool 设置16个,chunk大小buffer pool 数量 = 16128MB = 2048MB,然后如果 Buffer Pool 总大小设置 20G,是符合的规则的,此时就是 2048M的10倍;

image.png