缓存的重要性
对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个实际文件的抽象,也就是说数据说到底还是存储在磁盘上的
但是磁盘的速度慢,所以 InnoDB 存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后不着 急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘 IO 的开销了
Buffer Pool
Buffer Pool 大小设置
InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做 Buffer Pool(中文名是缓冲池)。默认情况下 Buffer Pool 只有 128M 大小,这个值其实是偏小的
show variables like 'innodb_buffer_pool_size';
可以在启动服务器的时候配置 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 对应的内存空间看起来就是这样的:
每个控制块大约占用缓存页大小的 5%,而设置的 innodb_buffer_pool_size 并不包含这部分控制块占用的内存空间大小,也就是说 InnoDB 在为 Buffer Pool 向操作系统申请连续的内存空间时,这片连续的内存空间一般会比 innodb_buffer_pool_size 的值大 5%左右
free 链表的管理
最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中
所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作 free 链表(或者说空闲链表)。刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free 链表中,假设该 Buffer Pool 中可容纳的缓存页数量为 n,那增加了 free 链表的效果图如下所示:
有了这个 free 链表之后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的 free 链表节点从链表中移除,表示该缓存页已经被使用了
缓存页的哈希处理
根据表空间号 + 页号来定位一个页,也就相当于表空间号 + 页号是一个 key,缓存页就是对应的 value,所以可以用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置
flush 链表的管理
如果修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步
所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫 flush 链表。链表的构造和 free 链表差不多
LRU 链表的管理
缓存不够的情况
Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时候,会把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进来,对于淘汰怎样的缓存页需要考虑到各种情况,并且们设立 Buffer Pool 的初衷,就是想减少和磁盘的 IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中,也就是淘汰机制最后达到的效果是查询命中率越高越好
从这个角度出发,回想一下微信聊天列表,排在前边的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,当然是留下最近很频繁使用的了
简单的 LRU 链表
当 Buffer Pool 中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页,此时会再引入一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。当需要访问某个页时,可以这样处理 LRU 链表:
- 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部
- 如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU 链表的头部
也就是说:只要使用到某个缓存页,就把该缓存页调整到 LRU 链表的头部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了
划分区域的 LRU 链表
简单的 LRU 链表存在两种比较尴尬的情况:
- 如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到,这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU 链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率
- 应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有 WHERE 子句的查询),这将意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,这也就意味着 Buffer Pool 中的所有页都被换了一次血, 其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率
InnoDB 提供了预读(英文名:read ahead)。所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。根据触发方式的不同,预读又可以细分为下边两种:
线性预读
InnoDB 提供了一个系统变量 innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 的请求
这个 innodb_read_ahead_threshold 系统变量的值默认是 56,可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值
随机预读
如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。InnoDB 同时提供了 innodb_random_read_ahead 系统变量,它的默认值为 OFF
总结一下上边说的可能降低 Buffer Pool 的两种情况:
- 加载到 Buffer Pool 中的页不一定被用到
- 如果非常多的使用频率偏低的页被同时加载到 Buffer Pool 时,可能会把那些使用频率非常高的页从 Buffer Pool 中淘汰掉
因为有这两种情况的存在,所以 InnoDB 把这个 LRU 链表按照一定比例分成两截,分别是:
- 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称 young 区域
- 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称 old 区域
对于 InnoDB 存储引擎来说,可以通过查看系统变量innodb_old_blocks_pct的值来确定old区域在LRU 链表中所占的比例,比方说这样:
SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
从结果可以看出来,默认情况下,old 区域在 LRU 链表中所占的比例是 37%,也就是说 old 区域大约占 LRU 链表的 3/8。这个比例是可以设置的,可以在启动时修改 innodb_old_blocks_pct 参数来控制 old 区域在 LRU 链表中所占的比例。在服务器运行期间,也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量
有了这个被划分成 young 和 old 区域的 LRU 链表之后,InnoDB 就可以针对上边提到的两种可能降低缓存命中率的情况进行优化了:
- 针对预读的页面可能不进行后续访问情况的优化:
InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页
- 针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化:
在进行全表扫描时,虽然首次被加载到 Buffer Pool 的页被放到了 old 区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。也就是说,简单的 LRU 链表分区无法解决这个问题(因为 InnoDB 规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次)
全表扫描有一个特点,那就是它的执行频率非常低,出现了全表扫描的语句也是应该尽快优化的对象。而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的
所以在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移动到 young 区域的头部。上述的这个间隔时间是由系统变量 innodb_old_blocks_time 控制的:
SHOW VARIABLES LIKE 'innodb_old_blocks_time';
这个 innodb_old_blocks_time 的默认值是 1000,它的单位是毫秒,也就意味着对于从磁盘上被加载到 LRU 链表的 old 区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于 1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过 1s),那么该页是不会被加入到 young 区域的, 当然,像 innodb_old_blocks_pct 一样,也可以在服务器启动或运行时设置 innodb_old_blocks_time 的值,这里需要注意的是,如果把 innodb_old_blocks_time 的值设置为 0,那么每次我们访问一个页面时就会把该页面放到 young 区域的头部
综上所述,正是因为将 LRU 链表划分为 young 和 old 区域这两个部分,又添加了 innodb_old_blocks_time 这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到 old 区域,而不影响 young 区域中的缓存页
更进一步优化 LRU 链表
频繁操作 LRU 链表影响MySQL的整体性能的优化方案:
只有被访问的缓存页位于 young 区域的 1/4 的后边,才会被移动到 LRU 链表头部,这样就可以降低调整 LRU 链表的频率,从而提升性能
当然还有其他的优化手段,但不管如何优化都脱离不开:尽量高效的提高 Buffer Pool 的缓存命中率
其他的一些链表
为了更好的管理 Buffer Pool 中的缓存页,除了上边提到的一些措施,InnoDB 们还引进了其他的一些链表,比如 unzip LRU 链表用于管理解压页,zip clean 链表用于管理没有被解压的压缩页,zip free 数组中每一个元素都代表一个链表,它们组成所谓的伙伴系统来为压缩页提供内存空间等等
刷新脏页到磁盘
后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:
- 从 LRU 链表的冷数据中刷新一部分页面到磁盘
后台线程会定时从 LRU 链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为 BUF_FLUSH_LRU
- 从 flush 链表中刷新一部分页面到磁盘
后台线程也会定时从 flush 链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为 BUF_FLUSH_LIST
有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU 链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU 链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为 BUF_FLUSH_SINGLE_PAGE
当然,有时候系统特别繁忙时,也可能出现用户线程批量的从 flush 链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为,这属于一种兜底策略
多个 Buffer Pool 实例
Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要加锁处理,在 Buffer Pool 特别大而且多线程并发访问特别高的情况下,单一的 Buffer Pool 可能会影响请求的处理速度。所以在 Buffer Pool 特别大的时候,可以把它们拆分成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力
可以在服务器启动的时候通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例的个数,而每个实例的大小为总共的大小除以实例的个数,结果就是每个 Buffer Pool 实例占用的大小,如下所示:
innodb_buffer_pool_size/innodb_buffer_pool_instances
不过也不是说 Buffer Pool 实例创建的越多越好,分别管理各个 Buffer Pool 也是需要性能开销的,InnoDB 规定:当 innodb_buffer_pool_size(默认 128M)的值小于 1G 的时候设置多个实例是无效的,InnoDB 会默认把 innodb_buffer_pool_instances 的值修改为 1。所以 Buffer Pool 大于或等于 1G 的时候设置应该多个 Buffer Pool 实例
innodb_buffer_pool_chunk_size
在 MySQL 5.7.5 之前,Buffer Pool 的大小只能在服务器启动时通过配置 innodb_buffer_pool_size 启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过 MySQL 在 5.7.5 以及之后的版本中支持了在服务器运行过程中调整 Buffer Pool 大小的功能, 但是有一个问题,就是每次当要重新调整 Buffer Pool 大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的 Buffer Pool 中的内容复制到这一块新空间,这是极其耗时的。所以 MySQL 决定不再一次性为某个 Buffer Pool 实例向操作系统申请一大片连续的内存空间,而是以一个所谓的 chunk 为单位向操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的, 一个 chunk 就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块:
正是因为发明了这个 chunk 的概念,在服务器运行期间调整 Buffer Pool 的大小时就是以 chunk 为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的 chunk 的大小是在启动操作 MySQL 服务器时通过 innodb_buffer_pool_chunk_size 启动参数指定的,它的默认值是 134217728,也就是 128M。不过需要注意的是, innodb_buffer_pool_chunk_size 的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的
Buffer Pool 的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息
查看 Buffer Pool 的状态信息
MySQL 给我们提供了 SHOW ENGINE INNODB STATUS 语句来查看关于 InnoDB 存储引擎运行过程中的一些状态信息,其中就包括 Buffer Pool 的一些信息(为了突出重点,只把输出中关于 Buffer Pool 的部分提取了出来):
SHOW ENGINE INNODB STATUS\G
返回参数含义:文章来源:https://www.toymoban.com/news/detail-404745.html
- Total memory allocated:代表 Buffer Pool 向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小
- Dictionary memory allocated:为数据字典信息分配的内存空间大小,注意这个内存空间和 Buffer Pool 没啥关系,不包括在 Total memory allocated 中
- Buffer pool size:代表该 Buffer Pool 可以容纳多少缓存页,注意,单位是页
- Free buffers:代表当前 Buffer Pool 还有多少空闲缓存页,也就是 free 链表中还有多少个节点
- Database pages:代表 LRU 链表中的页的数量,包含 young 和 old 两个区域的节点数量
- Old database pages:代表 LRU 链表 old 区域的节点数量
- Modified db pages:代表脏页数量,也就是 flush 链表中节点的数量
- Pending reads:正在等待从磁盘上加载到 Buffer Pool 中的页面数量。当准备从磁盘中加载某个页面时,会先为这个页面在 Buffer Pool 中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到 LRU 的 old 区域的头部,但是这个时候真正的磁盘页并没有被加载进来,Pending reads 的值会跟着加 1
- Pending writes LRU:即将从 LRU 链表中刷新到磁盘中的页面数量
- Pending writes flush list:即将从 flush 链表中刷新到磁盘中的页面数量
- Pending writes single page:即将以单个页面的形式刷新到磁盘中的页面数量
- Pages made young:代表 LRU 链表中曾经从 old 区域移动到 young 区域头部的节点数量
- Page made not young:在将 innodb_old_blocks_time 设置的值大于 0 时,首次访问或者后续访问某个处在old区域的节点时由于不符合时间间隔的限制而不能将其移动到 young 区域头部时,Page made not young 的值会加 1
- youngs/s:代表每秒从 old 区域被移动到 young 区域头部的节点数量
- non-youngs/s:代表每秒由于不满足时间限制而不能从 old 区域移动到 young 区域头部的节点数量
- Pages read、created、written:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率
- Buffer pool hit rate:表示在过去某段时间,平均访问 1000 次页面,有多少次该页面已经被缓存到 Buffer Pool 了
- young-making rate:表示在过去某段时间,平均访问 1000 次页面,有多少次访问使页面移动到 young 区域的头部了
- not (young-making rate):表示在过去某段时间,平均访问 1000 次页面,有多少次访问没有使页面移动到 young 区域的头部
- LRU len:代表 LRU 链表中节点的数量
- unzip_LRU:代表 unzip_LRU 链表中节点的数量
- I/O sum:最近 50s 读取磁盘页的总数
- I/O cur:现在正在读取的磁盘页数量
- I/O unzip sum:最近 50s 解压的页面数量
- I/O unzip cur:正在解压的页面数量
InnoDB 的内存结构总结
InnoDB 的内存结构和磁盘存储结构图总结如下:
其中的 Insert/Change Buffer 主要是用于对二级索引的写入优化,Undo 空间则是 undo 日志一般放在系统表空间,但是通过参数配置后,也可以用独立表空间存放,所以用虚线表示文章来源地址https://www.toymoban.com/news/detail-404745.html
到了这里,关于2-3-5-3、InnoDB 的 Buffer Pool的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!