InnoDB - Buffer Pool

前言

InnoDB缓冲池是一个内存区域,用于保存InnoDB表、索引和其他辅助缓冲区的缓存数据 。为了提高大容量读取操作的效率,缓冲池被划分为 可能包含多行的页面。
为了缓存管理的效率,缓冲池被实现为页链表。使用LRU算法的变体(具体原因看博文最后一节),将很少使用的数据会从缓存中淘汰掉。

一、Buffer Pool Instance 缓冲池实例

其实可以理解类似为 Redis 有 16个库一样。 InnoDB 将缓冲池划分为单独的实例可以通过减少不同线程读取和写入缓存页面时的争用来提高并发性。

使用散列函数将存储在缓冲池中或从缓冲池读取的每个页面随机分配给缓冲池实例之一。 每个缓冲池管理自己的 free lists, flush lists, LRUs 和所有其他连接到缓冲池的数据结构,并由自己的缓冲池互斥锁保护。

注意:当 innodb_buffer_pool_size 小于 1GB 时候,innodb_buffer_pool_instances 被重置为1,主要是防止有太多小的 instance 从而导致性能问题。

二、Buffer Chunks

Buffer Chunks包括两部分:数据页和数据页对应的控制体。Buffer Chunks是最低层的物理块,在启动阶段从操作系统申请,直到数据库关闭才释放。

通过遍历chunks可以访问几乎所有的数据页,有两种状态的数据页除外:没有被解压的压缩页(BUF_BLOCK_ZIP_PAGE)以及被修改过且解压页已经被驱逐的压缩页(BUF_BLOCK_ZIP_DIRTY)。此外数据页里面不一定都存的是用户数据,开始是控制信息,比如行锁,自适应哈希等。

三、逻辑链表

链表节点是数据页的控制体(控制体中有指针指向真正的数据页),链表中的所有节点都有同一的属性,引入其的目的是方便管理。Innodb Buffer Pool 相关的链表有:

Free List
保存未被使用的节点,如果需要从数据库中分配新的数据页,直接从上获取即可。InnoDB 需要保证 Free List 有足够的节点,提供给用户线程用,否则需要从 FLU List 或者 LRU List 淘汰一定的节点。

InnoDB 初始化后,Buffer Chunks 中的所有数据页都被加入到 Free List,表示所有节点都可用。

LRU List
表示近期最少使用链表(Least Recently Used),所有新读取进来的数据页都被放在上面。使用 LRU 淘汰算法把最近最少使用算法排序,最近最少使用的节点被放在链表末尾。如果Free List里面没有节点了,就会从中淘汰末尾的节点。

LRU List 被分为两部分,默认前 5/8 为young list,存储经常被使用的热点page,后 3/8 为 old list。新读入的 page 默认被加在 old list 头,只有满足一定条件后,才被移到 young list 上。

FLU List
保存脏页数据,FLU List 上的页面一定在 LRU List 上,但是反之则不成立。一个数据页可能会在不同的时刻被修改多次,在数据页上记录了最老(也就是第一次)的一次修改的 lsn,即 oldest_modification。

不同数据页有不同的 oldest_modification,FLU List 中的节点按照 oldest_modification 排序,链表尾是最小的,也就是最早被修改的数据页,当需要从FLU List中淘汰页面时候,从链表尾部开始淘汰。

Unzip LRU List
存储的都是解压的数据页,来自于 Zip Clean List。

Zip Clean List
存储没有被解压的压缩页。压缩页刚刚从磁盘读取出来,还没来的及被解压,一旦被解压后,就从此链表中删除,然后加入到Unzip LRU List中。出现于 Debug 模式。

Zip Free
压缩页,有不同的大小,比如 4K、8K、16K等。

Frame
帧,16K 的虚拟地址空间。整个缓冲区是是以大小为 16k(默认页大小) 的 frame 单位来进行的,frame是innodb中页的大小。

Control Block
控制块。每个frame, 对应一个block, block上的信息是专门用于进行frame控制的管理信息。这些信息不需要记录到磁盘,而是根据读入数据块在内存中的状态动态生成的, 主要包括:

  • 页面管理的普通信息,互斥锁, 页面的状态等
  • 脏回写(flush)管理信息
  • lru控制信息
  • 快速查找的管理信息, 为了便于快速的超找某一个block或frame, 缓冲区里面的block被组织到一些hash表中; 缓冲区中的block数量是一定的, innodb缓冲区对所管理的block用lru(last recently used)策略进行替换。

Buffer Pool分配方式
使用 mmap 分配 Buffer Pool,把内存划分为两个部分,前一部分是数据页控制体(buf_block_t),后一部分是真正的数据页。

按照UNIV_PAGE_SIZE分隔。假设page大小为16KB,则数据页控制体占的内存:数据页约等于1:38.6。划分完空间后,遍历数据页控制体,设置buf_block_t::frame 指针,指向真正的数据页,然后把这些数据页加入到 Free List 中即可。

初始化完Buffer Chunks的内存,还需要初始化BUF_BLOCK_POOL_WATCH类型的数据页控制块,page hash的结构体,zip hash的结构体(所有被压缩页的伙伴系统分配走的数据页面会加入到这个哈希表中)。注意这些内存是额外分配的,不包含在Buffer Chunks中。

互斥访问
buf_pool 进行管理和控制的实现。使用 mutex 来保护 buf_pool 这个控制结构中的数据域。但 mutex 并不保护缓冲区中的数据frame以及用于管理的block, 缓冲区里block或者frame中的访问是由专门的读写锁来保护的, 每个block/frame一个。

四、Buffer Pool 存储内容

索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等。

五、Buffer Pool 加载数据

新实例或取刚启动实例的 Buffer Pool 是空的,此时读数据都是直接读取磁盘中的。慢慢的使用多了经常读的数据就被加载到 Buffer Pool 中,这个过程叫做 预热。当 Buffer Pool 越大,预热过程越长。这个过程中数据库的性能是相对不要好的。

当然为了避免每次重启都需要预热,MySQL在 关闭前会把Buffer Pool中的页面信息保存到磁盘,等重新启动时,再根据之前保存的信息把磁盘中的数据加载到 Buffer Pool 中。这个过程分为 Buffer Pool Dump 和 Buffer Pool Load。

  1. Buffer Pool Dump 遍历所有 Buffer Pool Instance 的 LRU List,对于其中的每个数据页,按照 space_id 和 page_no 组成一个64位的数字,写到外部文件中。
  2. Buffer Pool Load 读取指定的外部文件,把所有的数据读入内存后,使用归并排序对数据排序,以64个数据页为单位进行IO合并,然后发起一次真正的读取操作。排序的作用就是便于IO合并。

除了保存 Buffer Pool 到磁盘中之外还有一个种方式,那就是 预读。 可以看博文 <InnoDB - Buffer Pool - 预读>。

六、数据页访问机制 - 一个数据页的访问流程

  1. 当访问的页面在缓存池中命中,则直接从缓冲池中访问该页面。另外为了避免查询数据页时扫描LRU,还为每个buffer pool instance维护了一个page hash,通过space id和page no可以直接找到对应的page。一般情况下,当我们需要读入一个Page时,首先根据space id和page no找到对应的buffer pool instance。然后查询page hash,如果page hash中没有,则表示需要从磁盘读取。
  2. 如果没有命中,则需要将这个页面从磁盘上加载到缓存池中,因此需要在缓存池中的空闲列表中找一个空闲的内存块来缓存这个从磁盘读入的页面。
  3. 但存在空闲内存块被使用完的情况,不保证一定有空闲的内存块。假如空闲列表为空,没有空闲的内存块,则需要想办法去产生空闲的内存块。
  4. 首先去LRU列表中找可以替换的内存页面,查找方向是从列表的尾部开始找,如果找到可以替换的页面,将其从LRU列表中摘除,加入空闲列表,然后再去空闲列表中找空闲的内存块。第一次查找最多只扫描100个页面,循环进行到第二次时,会查找深度就是整个LRU列表。这就是LRU列表中的页面淘汰机制。
  5. 如果在LRU列表中没有找到可以替换的页,则进行单页刷新,将脏页刷新到磁盘之后,然后将释放的内存块加入到空闲列表。然后再去空闲列表中取。为什么只做单页刷新呢?因为目的是获取空闲内存页,进行脏页刷新是不得已而为之,所以只会进行一个页面的刷新,目的是为了尽快的获取空闲内存块。

空闲列表是一个公共的列表,所有的用户线程都可以使用,存在争用的情况。因此,自己产生的空闲内存块有可能会刚好被其他线程所使用,所以用户线程可能会重复执行上面的查找流程,直到找到空闲的内存块为止。

七、缓冲池刷新策略

参数 innodb_max_dirty_pages_pct_lwm 控制是否启用脏面预刷新行为,默认为 0 禁用“预刷新”行为。触发时机由参数 innodb_max_dirty_pages_pct(脏页占有率, 大于触发)或 innodb_lru_scan_depth(LRU列表中可用页的数量,小于触发)

步骤如下:

  1. 调用page_cleaner_flush_pages_recommendation建议函数,对每个缓冲池实例生成脏页刷新数量的建议。在执行刷新之前,会用建议函数生成每个buffer pool需要刷新多少个脏页的建议。
  2. 生成刷新建议之后,通过设置事件的方式,向刷新线程(Page Cleaner线程)发出刷新请求。后台刷新线程在收到请求刷新的事件后,会执行pc_flush_slot函数对某个缓存池进行刷新,刷新的过程首先是对lru列表进行刷新,执行的函数为buf_flush_LRU_list,完成LRU列表的刷新之后,就会根据建议函数生成的建议对脏页列表进行刷新,执行的函数为buf_flush_do_batch。
  3. 后台刷新的协调线程会作为刷新调度总负责人的角色,它会确保每个buffer pool都已经开始执行刷新。如果哪个buffer pool的刷新请求还没有被处理,则由刷新协调线程亲自刷新,且直到所有的buffer pool instance都已开始/进行了刷新,才退出这个while循环。
  4. 当所有的buffer pool instance的刷新请求都已经开始处理之后,协调函数(或协调线程)就等待所有buffer pool instance的刷新的完成,等待函数为pc_wait_finished。如果这次刷新的总耗时超过4000ms,下次循环之前,会在数据库的错误日志记录相关的超时信息。它期望每秒钟对buffer pool进行一次刷新调度。如果相邻两次刷新调度的间隔超过4000ms ,也就是4秒钟,MySQL的错误日志中会记录相关信息,意思就是“本来预计1000ms的循环花费了超过4000ms的时间。

八、Buffer Pool 配置

  1. 参数 innodb_buffer_pool_size 控制缓冲池的大小,Buffer Pool 是保存InnoDB表、索引和其他辅助缓冲区的缓存数据的内存区域。缓冲池的大小对系统性能很重要,通常建议将 innodb_buffer_pool_size其配置为系统内存的 50% 到 75%。默认缓冲池大小为 128MB。
  2. 参数 innodb_buffer_pool_instances 控制缓冲池实例的数量,在具有大量内存的系统上,您可以通过将缓冲池划分为多个缓冲池实例来提高并发性。
  3. 参数 innodb_log_buffer_size 控制 InnoDB 用于写入磁盘上的日志文件的缓冲区的大小。默认大小为 16MB。大型日志缓冲区使大型事务能够在事务提交之前运行而无需将日志写入磁盘。如果有更新、插入或删除许多行的事务,增加日志缓冲区的大小以节省磁盘 I/O。
  4. 缓冲池大小必须始终等于或为 innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances 的倍数。

若不满足上面条件将自动调整为等于或不小于指定缓冲池大小的 innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances 的倍数。

如:innodb_buffer_pool_size 设置为 8G,innodb_buffer_pool_instances 设置为 16,innodb_buffer_pool_chunk_size为128M。

支持在线和离线方式修改 innodb_buffer_pool_size。如:

在线:
mysql> SET GLOBAL innodb_buffer_pool_size=8589934592;

离线:需要重启生效
配置文件中指定 innodb_buffer_pool_chunk_size=size。

九、为什么不直接用 LRU 淘汰算法

当 Buffer Pool 不够时候,便要进行内存中数据的释放回收以腾出空间。最有效的方法就是淘汰最近一段时间最少被访问过的缓存,也就是 LRU 算法了。redis 也支持该种算法进行数据淘汰。

LRU 是通过维护一个链表,每当有新数据访问时候将该数据放到链表的头部。所以链表的尾部就是最近没有被访问的数据,则就可以淘汰这部分数据。

看起来理论上是挺好的,但是不适合MySQL。因为以下几种情况下直接使用 LRU 是很不明智的。

1. 全表扫描
不管是有意还是无意间的查询对整张表进行扫描,InnoDB 会将该表的数据页全部从磁盘文件加载进缓存页中,这些缓存页会被加入到 LRU 链表中。

而这些数据是非常多而不常用,假如只是偶尔使用一次。这部分数据将占据 Buffer Pool 的大部分空间。若此时缓存满了需要进行数据淘汰,这时候这些数据是放到链表的前面,所以往往会把拍后面热点数据给淘汰了。

这时候热数据需要查询时候需要重新从磁盘加载,这种情况通常伴随着 Buffer Pool 缓存的命中率明显下降,SQL 的性能也明显下降情况发生。

2. 预读
预读是 InnoDB 引擎的一个优化机制,当你从磁盘上读取某个数据页,InnoDB 可能会将与这个数据页相邻的其他数据页也读取到 Buffer Pool 中。

所以仅直接使用 LRU 算法,那么预读机制和全表扫描带来的问题类似,预读机制会将其他的数据页也加载进内存,当 LRU 链表满时,可能将我们频繁访问的缓存页给淘汰,从而导致性能下降。

所以避免上述情况发生,MySQL 基于 LRU 进行了优化。采用 冷热分离 方式,思路就是:对数据进行冷热分离,将 LRU 链表分成两部分,一部分用来存放冷数据,也就是刚从磁盘读进来的数据,另一部分用来存放热点数据,也就是经常被访问到数据。如下图:
在这里插入图片描述

使用参数: innodb_old_blocks_pct 控制存放冷数据链表的占用大小,默认是 37%(约八分之三)。

当从磁盘读取数据页后,会先将数据页存放到 LRU 链表冷数据区的头部,如果这些缓存页在 1 秒之后被访问,那么就将缓存页移动到热数据区的头部;如果是 1 秒之内被访问,则不会移动,缓存页仍然处于冷数据区中。

该时间由参数:innodb_old_blocks_time 控制,默认是 1秒。

那怎么解决上面全表扫描或者预读的问题?当遇到全表扫描或者预读时,如果没有空闲缓存页来存放它们,那么将会淘汰一个数据页,而此时淘汰地是冷数据区尾部的数据页。冷数据区的数据就是不经常访问的,因此这解决了误将热点数据淘汰的问题。

1秒后这些数据还是没有被访问,则这部分数据将在下次被淘汰掉。

看到这里不知道大家会不会想到这样的一个问题,如果访问的数据就是热数据链表区域是否还需要将该数据放到链表的头部?

将链表中的数据移动到头部,实际上就是修改元素的指针指向,虽然这个操作是非常快的。但是为了安全起见,在修改链表的时候,MySQL 需要对链表加上锁,否则容易出现并发问题。

当并发量大的时候,因为要加锁,会存在锁竞争,每次移动显然效率就会下降。因此 MySQL 针对这一点又做了优化,如果一个缓存页处于热数据区域,且在热数据区域的前 1/4 区域(注意是热数据区域的 1/4,不是整个链表的 1/4),那么当访问这个缓存页的时候,就不用把它移动到热数据区域的头部;

如果缓存页处于热数据的后 3/4 区域,那么当访问这个缓存页的时候,会把它移动到热数据区域的头部。

写在最后

本文整理总结于:

  1. 知乎用户: 爱折腾的邦邦
  2. MySQL文档: 缓冲和缓存
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mooddance

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值