Innodb缓存之Buffer Pool

1. 前言

我们已经知道,对于 InnoDB 存储引擎而言,页是磁盘和内存交互的基本单位。哪怕你要读取一条记录,InnoDB 也会将整个索引页加载到内存。哪怕你只改了 1 个字节的数据,该索引页就是脏页了,整个索引页都要刷新到磁盘。InnoDB 是基于磁盘的存储引擎,如果每次操作都去读写磁盘,那么性能将会受到很大的影响。而且绝大多数时候,程序读写的数据在磁盘上并不是连续的,这意味着需要执行大量的随机 IO 读写,磁盘随机 IO 读写效率是非常低的,尤其是传统的机械硬盘。

在解决这个问题之前,大家可以先想一想,为什么我们只想读取一条记录,而 InnoDB 会将整个页的数据都加载到内存?因为根据计算机的局部性原理,程序接下来大概率会访问与它相邻的记录,为了避免频繁发起磁盘 IO 读操作,InnoDB 直接将整个页都加载到内存,下次再访问页中的其它记录时,就可以命中缓存了,减少磁盘 IO 操作。

问题解决的思路其实是一样的,磁盘的速度虽然很慢,但是内存的速度快啊。这些被加载到内存里的索引页,使用完毕后不要立即释放,而是将它们先缓存下来,下次再访问这些页时,就可以命中缓存了,减少磁盘 IO,从而提升性能。理论上,只要内存无限大,那么 MySQL 几乎可以是基于内存的数据库了。

InnoDB 缓存索引页的组件,就是我们今天要聊的「Buffer Pool」。

2. Buffer Pool

MySQL 服务器启动时,InnoDB 会向操作系统申请一块连续的内存空间用来缓存索引页,这一块连续的内存空间就是 Buffer Pool。默认情况下 Buffer Pool 的大小是128MB,查看命令如下:

mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
+-------------------------+-----------+
| Variable_name           | Value     |
+-------------------------+-----------+
| innodb_buffer_pool_size | 134217728 |
+-------------------------+-----------+

理论上,Buffer Pool 越大,缓存的索引页就可以更多,缓存的命中率就可以更高,对应的性能提升就越明显。如果你的机器内存够大,完全可以调大 Buffer Pool 的大小,在配置文件里进行修改:

[server]
innodb_buffer_pool_size=2147483648
innodb_buffer_pool_instances=2

Buffer Pool 最小是 5MB,即使你配置的小于 5MB,InnoDB 也会分配 5MB 的内存。

innodb_buffer_pool_instances启动项代表 Buffer Pool 实例的个数。是的,你没看错,Buffer Pool 支持配置多个,不同实例之间是隔离的,互不影响。配置多个的主要原因是因为 Buffer Pool 由多个链表组成,在维护这些链表时需要加锁保证同步,在高并发场景下会影响性能,配置多个实例就可以解决这个问题了。每个 Buffer Pool 的大小是innodb_buffer_pool_size/innodb_buffer_pool_instances,InnoDB 有规定,单个 Buffer Pool 实例的大小如果小于1GB,即使配置多个也不会生效。

2.1 Buffer Pool 结构

Buffer Pool 是用来缓存物理磁盘上的页结构的,那它自然也是由若干个页组成。为了与磁盘上的页区分开,这里我们叫它「缓冲页」。为了更好的管理这些缓冲页,InnoDB 为每个缓冲页都创建了一个「控制块」对象与之关联。所以,Buffer Pool 其实是由若干对控制块和缓冲页,以及一些碎片空间组成的。

为什么会有碎片空间?如果最后剩余的空间不足以分配一对控制块和缓冲页,就会被浪费掉,也就是碎片空间。除非你把 Buffer Pool 的大小设置的刚好合适。另外,控制块的大小在正常模式下和 DEBUG 模式下占用的大小并不一样,DEBUG 模式下控制块的大小约为缓冲页的 5%。

缓冲页的结构和物理磁盘上的页一致,也就没什么好说的了。控制块主要记录了缓冲页所属的表空间 ID、页号、缓冲页在 Buffer Pool 中的地址、链表节点信息等等。我们重点关注链表节点,因为 Buffer Pool 出于不同的目的,将这些缓冲页串联成了多条链表,后面会提到。总之,Buffer Pool 的结构其实很简单,如下图所示:Innodb缓存之Buffer Pool

2.2 Free 链表

Buffer Pool 是用来缓存磁盘上的页结构的,那么第一个问题就来了。当我们要从磁盘上加载一个页的时候,这个页该放到 Buffer Pool 的哪个缓冲页里呢?总不能遍历整个 Buffer Pool 吧,哪个缓冲页是空闲的就直接使用它,这未免也太笨拙了。InnoDB 会通过控制块里的链表节点属性,将所有空闲的缓冲页都串联成一条双向链表,叫作「Free 链表」。MySQL 服务器刚启动时,所有的缓冲页都会加入到该链表中,因为所有的缓冲页都没有被使用。当我们要把磁盘上的页加载到内存时,就从 Free 链表申请一个缓冲页,并把它对应的控制块从 Free 链表中移除,这比遍历整个 Buffer Pool 可高效多了。

怎么找到 Free 链表呢?为了更好的管理这些链表,InnoDB 为每条链表都创建了一个叫作「链表基节点」的结构,它的属性就三个,分别记录链表的头尾节点指针、以及链表内的节点数量。Innodb缓存之Buffer Pool

2.3 缓冲页哈希表

第二个问题又来了,当我们要使用某个页的时候,怎么知道它有没有被加载到 Buffer Pool 呢?难道又要再遍历一次所有已使用的缓冲页吗?未免也太笨拙了。在同一个表空间里,每个页都有唯一的一个页号,所以要定位一个页,只需要知道表空间 ID+页号就可以了。也就是说,我们完全可以建立一个哈希表,哈希表的 Key 就是表空间 ID+页号的组合,Value 就是缓冲页。这样就可以快速判断某个页是否已经加载到 Buffer Pool 了。

2.4 Flush 链表

在执行增删改操作时,如果 InnoDB 每次都把受影响的页同步到磁盘,那么必然会导致大量的磁盘随机 IO 写操作,这个效率是很低的。为了提升性能,InnoDB 会先在内存里修改这些受影响的页面,这些被修改过的页面称作「脏页」(Dirty Page),然后由一个额外的线程负责将这些脏页刷新到磁盘。

内存断电数据就丢失了,那些没来得及刷盘的脏页岂不是数据就丢失了?不用担心,后面聊的 redo log 会帮我们保证数据一致性的,这里先跳过。

第三个问题又来了,InnoDB 怎么知道哪些页是脏页呢?再遍历一次 Buffer Pool 吗?太笨拙了,为了解决这个问题,InnoDB 又引入了第二条链表:flush 链表。flush 链表和 free 链表极其相似,也有一个链表基节点,当我们修改了缓冲页里的数据,InnoDB 就会把该缓冲页对应的控制块加入到 flush 链表,等待后续的刷盘。Innodb缓存之Buffer Pool

2.5 LRU 链表

那些已经被使用的缓冲页,会从 Free 链表中移除,然后加入到一个叫作“LRU”的链表中。LRU 是 Least Recently Used 的缩写,译为“最近最少使用”。为啥会需要 LRU 链表呢?说白了,相较于磁盘上海量的数据,Buffer Pool 那点内存实在是杯水车薪,当 Buffer Pool 中的内存不够时,就不得不释放掉一些页面,来缓存新的页面。Buffer Pool 的本质是为了减少磁盘 IO 的访问,提高缓存命中率,正是因为它小才显得极其珍贵,InnoDB 更应该要用好它。如果是你,你会在 Buffer Pool 里放访问频率高的页面,还是访问频率低的页面呢?

最简单的 LRU 链表,每当我们要访问一个页面时,就把它移动到 LRU 链表的表头,那么链尾的页面自然就是最近最少使用的了,当 Free 链表没有空闲的缓冲页时,直接把 LRU 链表的链尾页面释放掉即可。看似没什么问题,但是某些场景下,LRU 链表会被破坏:

  • 1.全表扫描:全表扫描需要加载聚簇索引 B+树的所有叶子节点,当表中数据量较大时,可能一次全表扫描就会把之前访问频率很高的缓冲页全部从 LRU 链表中挤出,下次再访问这些页面时,又得从磁盘上重新加载一遍了。
  • 2.预读:InnoDB 内置了一个贴心的预读功能,它会在执行当前读请求时,判断是否还会访问其它页面,然后异步的把这些页面提前加载到 Buffer Pool,从而加速读操作。预读细分为两种:
    • 2.1 线性预读:系统变量innodb_read_ahead_threshold代表触发线性预读的阈值,如果顺序的访问某个区的页面数量超过该值,InnoDB 就会异步的将下一个区的所有页面加载到 Buffer Pool,默认值56
    • 2.2 随机预读:系统变量innodb_random_read_ahead代表触发随机预读的阈值,如果某个区的 13 个连续的页面被加载到 Buffer Pool,InnoDB 就会异步的将本区其它页面全部加载到 Buffer Pool,该功能默认关闭。

综上所述,全表扫描和预读可能会破坏 LRU 链表,本质上就是将大量可能短期不会被访问到的页面加入到 LRU 链表,反而导致那些访问频率很高的页面被挤掉了,导致 Buffer Pool 的命中率降低。

为了解决这个问题,InnoDB 对 LRU 链表进行了优化,将 LRU 链表按照一定的比例分成两部分:存储访问频率很高的 Young 区、存储访问频率较低的 Old 区。系统变量innodb_old_blocks_pct控制了 Old 区所占的比例,默认值是37。也就是说,整个 LRU 链表的前约5/8部分用来存储访问频率很高的缓冲页,后约3/8部分用来存储访问频率较低的缓冲页。Innodb缓存之Buffer Pool将 LRU 链表划分为两截后,InnoDB 是这样来维护 LRU 链表的:首次加载的页面不会直接放到 LRU 链表的表头,而是 Old 区的头部,如果该页面后续没有继续访问,会慢慢被释放掉,而不影响 Young 区的页面。如果后续再次访问了该页面,判断距离上次访问的时间,只有两次访问的时间间隔超过了阈值,才会把它移动到 Young 区头部。时间间隔的阈值通过系统变量innodb_old_blocks_time配置,默认是1000ms

LRU 链表经过这么一番优化后,我们看看是如何解决上面两个场景的:

  • 全表扫描:全表扫描的页面首次加载只会放在 Old 区头部,虽然马上又会访问同一个页面,但是时间间隔很短,因此不会移动到 Young 区。(每一条记录都要访问一次页面)
  • 预读:预读首次加载的页面只会放在 Old 区头部,只要后续不再继续访问,就会慢慢被释放掉。

对于 Young 区的缓冲页,如果每访问一次都要把它移动到 LRU 链表的表头,这个操作未免也太频繁了,因为 Young 区本来就是访问频率很高的页面,大家互相换来换去意义不大。所以 InnoDB 再进一步优化,如果访问的缓冲页在 Young 区的前1/4处,是不需要移动到表头的,只有访问的缓冲页在 Young 区的后3/4处才会把它移动到表头,这大大降低了链表节点移动的频率。

2.6 多个实例

现在我们知道,Buffer Pool 在物理上虽然是一块连续的内存空间,但是逻辑上它由多条链表组成。在维护这些链表时,都需要加锁来保证同步,在高并发场景下,这会带来一些性能上的影响。为了解决这个问题,InnoDB 支持多个 Buffer Pool 实例,每个实例都是独立的,会维护自己的各种链表,多线程并发访问时不会有影响,从而提高并发处理能力。查看 Buffer Pool 实例个数的命令,默认是 1 个。

mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';
+------------------------------+-------+
| Variable_name                | Value |
+------------------------------+-------+
| innodb_buffer_pool_instances | 1     |
+------------------------------+-------+

支持在配置文件中进行配置:

[server]
innodb_buffer_pool_size=2147483648
innodb_buffer_pool_instances=2

在 MySQL5.7.5 之前,InnoDB 是不支持运行时动态调整 Buffer Pool 大小的,主要是因为每次调整大小,都需要向操作系统重新申请一个 Buffer Pool,然后将数据拷贝一次,这个开销太大了。在之后的版本中,InnoDB 引入了chunk的概念来支持运行时修改 Buffer Pool 大小。一个 Buffer Pool 实例由若干个 chunk 组成,里面包含了若干个控制块和缓冲页。在调整 Buffer Pool 大小时,InnoDB 以 chunk 为单位来申请内存空间和数据的拷贝。chunk 的大小由系统变量innodb_buffer_pool_chunk_size控制,默认是128MB,chunk 本身的大小不支持运行时修改。

mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_chunk_size';
+-------------------------------+-----------+
| Variable_name                 | Value     |
+-------------------------------+-----------+
| innodb_buffer_pool_chunk_size | 134217728 |
+-------------------------------+-----------+

innodb_buffer_pool_size 必须是 innodb_buffer_pool_chunk_size*innodb_buffer_pool_instances 的整数倍大小,目的是保证没个 Buffer Pool 实例的 chunk 数量一致。

2.7 Buffer Pool 状态信息

说了这么多,耳听为虚,眼见为实。如何查看 MySQL 运行时的 Buffer Pool 相关的状态信息呢?命令是SHOW ENGINE INNODB STATUS,输出的是 InnoDB 引擎的状态信息,其中就包含 Buffer Pool 的状态信息,如下:

----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137428992
Dictionary memory allocated 268616
Buffer pool size 8191
Free buffers 7238
Database pages 953
Old database pages 371
Modified db pages 0
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 919, created 34, written 36
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 740 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 959, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
  • Total large memory allocated:Buffer Pool 向操作系统申请的总内存大小,包括控制块大小。
  • Dictionary memory allocated:给数据字典分配的内存大小,不包含在 Buffer Pool 总内存大小中。
  • Buffer pool size:Buffer Pool 可以容纳多少缓冲页。
  • Free buffers:Free 链表的页面数。
  • Database pages:LRU 链表的页面数。
  • Old database pages:LRU 链表 Old 区域的页面数。
  • Modified db pages:脏页数量,即 Flush 链表的页面数。
  • Pending reads:等待从磁盘加载到 Buffer Pool 的页面数。
  • Pending writes.LRU:等待从 LRU 链表中刷新到磁盘的页面数。
  • Pending writes.flush list:等待从 Flush 链表中刷新到磁盘的页面数。
  • Pending writes.single page:等待以单个页面的形式刷新到磁盘的页面数。
  • Pages made young:LRU 链表曾经从 Old 区移动到 Young 区的节点数。
  • Pages made not young:再次访问 Old 区的节点因为时间问题不能移动到 Young 区的节点数。
  • youngs/s:每秒从 Old 移动到 Young 区的节点数。
  • non-youngs/s:每秒由于时间限制不能从 Old 移动到 Young 区的节点数。
  • Pages read/created/written:读取/创建/写入了多少页面,下一行是对应的速率。
  • Buffer pool hit rate:过去平均每访问一千次页面,有多少次页面已经被缓存到 Buffer Pool。
  • young-making rate:过去平均每访问一千次页面,有多少次使页面移动到 Young 区头部。
  • not young-making rate:过去平均每访问一千次页面,有多少次没有使页面移动到 Young 区头部。
  • LRU len:LRU 链表的节点数。
  • I/O sum:最近 50 秒,读取磁盘的总页数。
  • I/O cur:现在正在读取磁盘页的数量。
  • I/O unzip sum:最近 50 秒解压的页面数。
  • I/O unzip cur:正在解压的页面数。

3. 总结

磁盘速度太慢了,如果每次读取页面都从磁盘加载,会导致大量的磁盘 IO 随机读,MySQL 的性能势必会受到严重影响。为了解决这个问题,InnoDB 引入了 Buffer Pool,它会在 MySQL 服务器启动时申请一块连续的内存空间,用来缓存对应的磁盘里的页结构。每个缓冲页都有一个与之关联的控制块,InnoDB 为了不同的目的,将这些控制块串联成多条双向链表,例如:Free 链表、LRU 链表、Flush 链表等等。为了提高 Buffer Pool 的命中率,防止一些特殊的操作破坏 LRU 链表,InnoDB 将 LRU 链表按照一定的比例划分成两截,分别是存放访问频率很高的页的 Young 区,和访问频率较低的页的 Old 区。Buffer Pool 逻辑上由这些链表组成,维护这些链表都需要加锁保证同步,高并发下会影响性能,所以 InnoDB 支持配置多个 Buffer Pool 实例。为了在运行时支持调整 Buffer Pool 的大小,InnoDB 又引入了 chunk 的概念,最后通过命令我们可以查看 Buffer Pool 的状态信息。


原文始发于微信公众号(程序员小潘):Innodb缓存之Buffer Pool

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/28566.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!