Mysql 5.7 InnoDB 存储引擎整体逻辑架构图

InnoDB 存储引擎之 Buffer Pool-LMLPHP

一、Buffer Pool 概述

InnoDB 作为一个存储引擎,为了降低磁盘 IO,提升读写性能,必然有相应的缓冲池机制,这个缓冲池就是 Buffer Pool

为了方便理解,对于磁盘上的数据所在的页,叫做数据页,当数据页加载进 Buffer Pool 之后,叫做缓存页,这两者是一一对应的,只不过数据是在磁盘上,缓存页是在内存中

Buffer Pool 作为 InnoDB 存储引擎内存结构的四大组件之一,它主要由以下特点

1、Buffer Pool 缓存的是 最热的数据页和索引页,把磁盘上的数据页缓存到内存中,避免每次访问数据都要进行磁盘 IO 操作,提升了数据的读写性能

2、Buffer Pool 以缓存页为基本单位,每个缓存页的默认大小是 16KB,Buffer Pool 底层采用双向链表的数据结构管理缓存页

3、Buffer Pool 是一块连续的内存区域,它的作用是为了降低磁盘 IO,提升数据的读写性能,所有数据的读写操作都需要通过 Buffer Pool 才能进行

  • 读操作: 先判断 Buffer Pool 中是否存在对应的缓存页,如果存在就直接操作 Buffer Pool 中的缓存页,如果不存在,则需要将磁盘上的数据页读入到 Buffer Pool 中,然后操作 Buffer Pool 中对应的缓存页即可
  • 写操作: 先把数据和日志等信息分别写入 Buffer Pool 和 Log Buffer,再由后台线程将 Buffer Pool 中的数据刷盘

二、Buffer Pool 控制块

Buffer Pool 中存放的是缓存页,缓存页大小跟磁盘数据页大小一样,都是默认 16KB,为了更好的管理缓存页,InnoDB 为每一个缓存的数据页都创建一个单独的区域,用于记录数据页的元数据信息,这些信息主要包括 数据页所属表空间编号、数据页编号、缓存页在 Buffer Pool 中的地址、链表节点信息、索信息、LSN 信息等,这个特殊的区域被称为控制块

控制块和缓存页是一一对应的,它们都被存放在 Buffer Pool 中,控制块的大小大概占缓存页大小的 5% 约为 819 个字节(16 * 1024 * 0.05 = 819.2B)

InnoDB 存储引擎之 Buffer Pool-LMLPHP

图示中存在一个碎片空间,这个碎片空间是怎么产生的?

数据页大小为 16KB,控制块大概为 800B,当我们划分好所有的控制块与数据页后,可能会有剩余的空间不够一对控制块和缓存页的大小,这部分就是多余的碎片空间.如果把 Buffer Pool 的大小设置的刚刚好的话,也可能不会产生碎片

 

三、Buffer Pool 缓存页管理机制

Buffer Pool 底层采用双向链表这种数据结构来管理缓存页,在 InnoDB 访问表记录和索引时会将对应的数据页缓存在 Buffer Pool 中,以后如果需要再次使用时直接操作 Buffer Pool 中的缓存页即可,减少了磁盘 IO 操作,提升读写效率

当启动 Mysql 服务器的时候,需要完成对 Buffer Pool 的初始化,即分配 Buffer Pool 的内存空间,把它划分为若干对控制块 + 缓存页的组合,整个初始化过程大致如下

  • 申请空间: Mysql 服务器启动时,会根据设置的 Buffer Pool 大小(innodb_buffer_pool_size),去操作系统申请 一块连续的内存区域 作为 Buffer Pool 的内存空间,实际的内存空间大小应该要大于 innodb_buffer_pool_size,主要原因是里面还要存放每个缓存页的控制块,这些控制块占用的内存大小不计算进入 innodb_buffer_pool_size 中
  • 划分空间: 当内存区域申请完毕之后,Mysql 就会按照默认的缓存页大小(16KB) 以及对应的控制块大小(约 800B),将整个 Buffer Pool 划分为若干个 控制块 + 缓存页 的组合

Buffer Pool 中的缓存页根据状态可以分为三种类型

InnoDB 存储引擎之 Buffer Pool-LMLPHP

Free page: 空闲 page,未被使用的 page 页

Clean page: 已经被使用,但是数据没有被修改过,Buffer Pool 和磁盘上的数据是一致的

Dirty page: 脏页,已经被使用,并且数据被修改过,Buffer Pool 和磁盘上的数据不一致

针对上面所说的三种 page 页类型,InnoDB 通过三种链表来维护和管理这些 page 页,这三种链表分别是 Free 链表、Flush 链表、LRU 链表

3.1、Free 链表

在 Buffer Pool 刚被初始化出来的时候,所有的控制块和数据页都是空的,当执行读写操作的时候,磁盘的数据页会被加载到 Buffer Pool 的缓存页中,当 Buffer Pool 中有的数据页持久化到磁盘的时候,这些缓存页又要被空闲出来,如何知道哪些数据页是空的,哪些数据页是有数据的,只有找到空的数据页,才能把数据写进行去,一种方式是遍历所有的数据页,挨个查找,找到符合要求的空数据页,还有 另外一种方式就是通过某种数据结构来进行管理,这种数据结构就是 Free 链表

Free 链表表示 空闲缓冲区,其作用是管理 Buffer Pool 中所有的 free page,它是一个双向链表,由一个基节点和若干个子节点组成,记录空闲的数据页对应的控制块信息,

Free 链表是把所有空闲的缓冲页对应的控制块作为一个个节点放在一个链表中,

基节点: Free 链表中基节点是不记录缓存页信息的,需要单独申请,它里面就存放了 free 链表的头结点的地址、尾节点的地址、以及整个 Free 链表里面当前有多少个节点

InnoDB 存储引擎之 Buffer Pool-LMLPHP

磁盘加载数据页到 Free page 的流程

1、从 Free 链表中取出一个空闲的控制块

2、把该空闲控制块的信息填上(缓存页所在的表空间、页号等信息),通过控制块与缓存页一一对应的关系,找到 Buffer Pool 中的缓存页,然后把磁盘中的数据页读入到 Buffer Pool 的缓存页中

3、把该空闲控制块从 free 链表中移除,这样就代表该缓冲页已经被使用了

如何判断要操作的数据所在的数据页是否已经缓存在 Buffer Pool 中呢?

Mysql 中有一个哈希表数据结构,它使用 表空间编号 + 数据页编号作为 key,缓存页对应的控制块作为 value

当使用数据页时,会先在数据页缓存 Hash 表中进行查找,找到了之后取出 value 值对应的控制块,由于控制块和 Bufer Pool 中的缓存页是一一对应的,通过控制块就能定位到缓存页如果在数据页缓存的 Hash 表中查找失败,那么就要从磁盘读入了

需要注意的是 value 是控制块编号,而不是缓存页号

3.2、Flush 链表

InnoDB 为了提高处理效率,在每次修改缓冲页之后,并不是立刻把修改刷新到磁盘上,而是在未来的某个时间点进行刷盘操作,所以需要使用 Flush 链表来存储脏页,凡是被修改过的缓冲页对应的控制块都会作为节点被加入到 Flush 链表中

Flush 链表表示需要刷新到磁盘的缓冲区,其作用是为了管理 Dirty page

Flush 链表的结构和 Free 链表结构相似,这里就不再画图赘述了

需要注意的是脏页既存在 Flush 链表中,也存在 LRU 链表中,两种链表互不影响,LRU 链表负责管理缓存页的可用性和释放,而 Flush 链表负责管理脏页的刷盘操作

当我们写入数据的时候,磁盘 IO 的效率是很低下的,所以 Mysql 不会直接进行磁盘刷新操作,而是要经过以下两个步骤

  • 更新 Buffer Pool 中的数据页(一次内存更新操作)
  • 将更新操作顺序写 Redo log file(一次磁盘顺序写)

这样的效率是很高的,顺序写 Redo log 大概每秒几万次
当脏页对应的控制块被加入到 Flush 链表后,后台线程就可以遍历 Flush 链表,将脏页写入磁盘

3.3、LRU 链表

表示正在使用的缓冲区,其作用是为了管理 Clean page 和 Dirty page

InnoDB 的 Buffer Pool 的大小是有限的,并不能无限缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,要实现这个目的,我们很容易想到 LRU(Least Recently Used)算法
LRU 算法一般是使用链表作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没有被使用的,那么当空间不够的时候,就会淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间

传统的 LRU 算法实现思路
当访问的数据页在内存中,就直接把该页对应的链表节点移动到 LRU 链表的头部
当访问的页不在内存中,除了要把该页对应的节点放入到 LRU 链表的头部,还要淘汰链表最末尾的页

假设有一个 LRU 链表,初始状态如下图所示

InnoDB 存储引擎之 Buffer Pool-LMLPHP

如果访问了 3 号页,由于 3 号页在内存中,需要将 3 号页移动到链表的头部,表示最近被访问了,同时 1,2 号页需要向后移动

InnoDB 存储引擎之 Buffer Pool-LMLPHP

假设此时再次访问了一个内存中不存在的 9 号页,需要将 9 号页移动到 LRU 链表的头部,其它节点向后移动,由于整个链表长度是 8,所以要将原来末尾的 8 号页淘汰掉

InnoDB 存储引擎之 Buffer Pool-LMLPHP

传统的 LRU 算法并没有被 Mysql 使用,因为传统的 LRU 算法无法避免下面两个问题

  • 预读失效导致缓存命中率下降;
  • Buffer Pool 污染导致缓存命中率下降

什么是预读机制?
预读是 InnoDB 存储引擎的一种优化机制,当 Mysql 从磁盘加载页时,会提前把它相邻的页一并加载进来

InnoDB 为什么要预读呢?
一般来说,数据的读取会遵循集中读写的原则,也就是说当我们需要使用某一些数据的时候,很大概率也会用到附近的数据(即局部性原理),如果要使用的数据页是连续的,一次读取多个数据页相较于多次读取但是每次只读一个页来说,速度是更快的,因为一次读取连续的多个数据页是顺序 IO,由于磁盘快速旋转,磁头只要在对应的磁道上就能快速获取数据,而多次读取每次只读一个页是随机 IO,磁头要不断的移动,寻找磁道然后才能读取数据,磁头的移动是机械性的,速度很慢

在两种情况下会触发 InnoDB 的预读机制
1、顺序访问了磁盘上一个区的多个数据页,当这个数量超过一个阈值时,InnoDB 就会认为你对下一个区的数据也感兴趣,因此触发预读机制,将下个区的数据页也全部加载进 Buffer Pool,这个阈值由参数 innodb_read_ahead_threshold,默认值为 56,可以通过如下命令查看

show variables like '%innodb_read_ahead_threshold%';

InnoDB 存储引擎之 Buffer Pool-LMLPHP

2、Buffer Pool 中已经缓存了同一个区数据页的个数超过 13 时,InnoDB 就会将这个区的其它数据页也读取到 Buffer Pool 中,这个开关由参数 innodb_random_read_ahead 控制,默认是关闭的,可以通过如下命令查看

show variables like 'innodb_random_read_ahead';

InnoDB 存储引擎之 Buffer Pool-LMLPHP

什么是预读失效,预读失效会带来什么影响?
如果这些提前加载进来的页,并没有被访问,相当于这个预读的工作是白做的,这就是所谓的预读失效
如果使用传统的 LRU 算法,就会把预读页放到 LRU 链表的头部,当内存空间不足的时候,还需要把链表末尾的页淘汰掉
如果这些预读页一直不被访问,就会出现一个很奇怪的问题,不会被访问的预读页反而占据了整个 LRU 链表的前排位置,而链表末尾的页,可能是真正的热点数据,这样就大大降低了缓存的命中率

如何避免预读失效造成的影响?
我们不能因为害怕预读失效,而将预读机制去掉,在大部分的情况下,空间局部性原理还是成立的
要避免预读失效带来的影响,最好的做法就是让预读页在内存中停留的时间尽可能的短,这样真正被访问的页才能移动到 LRU 链表的头部,从而保证真正被读取的热数据停留在内存中的时间尽可能长

那要怎么做才能达成上面的预期呢?
Mysql InnoDB 存储引擎通过改进传统的 LRU 链表来避免预读失效带来的负面影响,具体的改进方式如下
Mysql 的 InnoDB 存储引擎在一个 LRU 链表上划分出两个区域,young 区域和 old 区域,young 区域在 LRU 链表的前半部分,old 区域在后半部分,这两个区域都有各自的头节点和尾节点
young 区域和 old 区域在 LRU 链表中的占比关系并不是 1:1,比例关系由 innodb_old_blocks_pct 参数进行控制,默认热数据区域占 63%,冷数据区域占 37%

InnoDB 存储引擎之 Buffer Pool-LMLPHP

划分好这两个区域之后,预读的页就只需要加入到 old 区域的头部,当数据页真正被访问的时候,才将页插入到 young 区域的头部,如果预读的页一直没有被访问,就会从 old 区域移除,整个过程并不会影响 young 区域中的热点数据

假设有一个 LRU 链表初始长度为 8

InnoDB 存储引擎之 Buffer Pool-LMLPHP

 现在有个编号为 9 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页 (8号) 会被淘汰掉

InnoDB 存储引擎之 Buffer Pool-LMLPHP

如果 9 号页一直不会被访问,它也没有占用到 young 区域的位置,也就不会影响到热数据区域,而且还会比 young 区域的数据更早被淘汰出去

如果 9号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域末尾的页 (5号) 会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰

InnoDB 存储引擎之 Buffer Pool-LMLPHP

从上可知,通过 young 和 old 区域的划分,可以很好的解决预读失效的问题

什么是 Buffer Pool 污染?
虽然 Mysql 通过改进传统的 LRU 链表(划分两个区域),避免了预读失效带来的负面影响,但是如果还是使用只要数据被访问一次就加入到 LRU 链表的头部这种方式的话,那么还存在缓存污染的问题
当我们批量读取数据的时候,由于数据被访问了一次,这些大量的数据就会被加入到 young 区域头部,之前缓存在 young 区域的热点数据就被淘汰了,下次访问热点数据的时候又要重新去磁盘读取,大量的 IO 操作导致数据库的性能下降,这个过程就是 Buffer Pool 污染

Buffer Pool 污染会带来什么问题?
Buffer Pool 污染带来的影响是致命的,当某一个 SQL 语句扫描了大量数据的时候,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 中的所有页都替换出去,导致大量的热数据被淘汰了,等这些热数据又再次被访问的时候,由于缓存未命中,又要重新去磁盘加载,这样就会产生大量的磁盘 IO,Mysql 性能就会急剧下降
注意: 缓存污染并不只是查询语句查询除了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染

比如,在一个数据量非常大的表,执行了下面这条 SQL 语句

select * from user where name like "%xiaomaomao";

从磁盘读取数据页加入到 LRU 链表的 old 区域头部
从数据页中读取行记录时,也就是页被访问的时候,就要将该页放入到 young 区域的头部
接着拿行记录中的 name 字段和字符串 xiaomaomao 进行模糊匹配,如果符合条件,加入到结果集中
如此往复,直到扫描完表中的所有记录,经过这一番折腾,由于这条 SQL 语句访问的的页非常多,每访问一个页就会将其加入到 young 区域的头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降,那些在批量扫描时,而被加入到 young 区域的页,如果在很长的一段时间都不会再被访问的话,那么就污染了 young 区域

如何解决 Buffer Pool 污染?
造成 Buffer Pool 污染的原因是,全表扫描导致加载大量数据页到 old 区域,紧接着这些数据页只被访问一次就从 old 区域移动到了 young 区域,导致原本缓存在 young 区域的热点数据失效
全表扫描有一个特点,就是相同的数据页在短时间内被频繁访问

select * from t_user where id >= 1 

例如上面这条 SQL,假设 id = 1 这行数据所在的页号是 page 10,该页有 10000 条记录,那么执行这条 SQL 时会在短时间内对 page 10 扫描 10000 次

老年代时间停留窗口机制
全表扫描之所以会替换淘汰原有的 LRU 链表 young 区域数据,主要是因为我们将原本只会访问一次的数据页加载到 young 区,这些数据实际上刚刚从磁盘被加载到 Buffer Pool,然后就被访问,之后就不会用,基于此,我们是不是可以将数据移动到 young 区的门槛提高有点,从而把这种访问一次就不会用的数据过滤掉,把它停留在 old 区域,这样就不会污染 young 区的热点数据了
我们只需要提前设定一个时间阈值,然后记录下两次访问同一个数据页的时间间隔,如果两次的时间间隔大于这个阈值,就证明不是全表扫描(全表扫描的特点是相同的数据页短时间内被频繁访问)

Mysql 先设定一个间隔时间 innodb_old_blocks_time,然后将 old 区域数据页的第一次访问时间在其对应的控制块中记录下来
如果后续的访问时间与第一次访问的时间小于 innodb_old_blocks_time 则不将该缓存页从 old 区域移动到 young 区域
如果后续的访问时间与第一次访问的时间大于 innodb_old_blocks_time 才会将该缓存页移动到 young 区域的头部
这样看,其实这个间隔时间 innodb_old_blocks_time 就是数据页必须在 old 区域停留的时间,有了这个 old 区域停留机制,那些短时间内被多次访问的页,并不会立刻插入新生代头部(完美的避开了全表扫描),而是优先淘汰老年代中短期内仅仅访问了一次的页

这个老年代时间停留参数,可以通过如下命令查看,默认值是 1秒

show variables like '%innodb_old_blocks_time%';

InnoDB 存储引擎之 Buffer Pool-LMLPHP

所以在 InnoDB 中,只有同时满足 数据页被访问数据页在 old 区域停留时间超过 1 秒 两个条件,才会被插入到 young 区域头部

实际上,Mysql 在冷热分离的基础上还做了一层更深入的优化
当一个缓存页处于热数据区域的时候,我们去访问这个缓存页,这个时候我们真的有必要把它移动到 young 区域的头部吗?
将链表中的数据移动到头部,实际上就是修改节点的指针指向,这个操作是非常快的,但是为了安全期间,在修改链表指针期间,我们需要对链表加上锁,否则会出现并发问题,在并发量大的时候,因为要加锁,会存在锁竞争,每次移动显然效率就会下降,因此 Mysql 针对这一点又做了一层优化

  • 如果一个缓存页处于热数据区域,并且在热数据区域的前 1/4 区域(注意是热数据区域的 1/4,而不是整个 LRU 链表的 1/4),那么访问这个缓存页的时候,就不用把它移动到热数据区域的头部,
  • 如果缓存页处于热数据的 3/4 区域,那么访问这个缓存页的时候,会把它移动到热数据区域的头部

 

四、多实例 Buffer Pool
Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内存空间,既然是内存空间,那么在多线程环境下,为了保证数据安全,访问 Buffer Pool 中的数据都需要 加锁 处理,当多线程并发访问量特别高时,单一个 Buffer Pool 可能会影响请求的处理速度,因此当 Buffer Pool 的内存空间很大的时候,可以将单一的 Buffer Pool 拆分成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个独立的实例,各自去申请内存空间、分配内存空间、管理各种链表,以此保证在多线程并发访问时不会相互影响,从而提高并发能力
通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例的个数,默认为 1,最大可以设置为 64

[server]
innodb_buffer_pool_instances = 2

上面配置标识创建两个 Buffer Pool 实例(每个 Buffer Pool 的大小为 innodb_buffer_pool_size / 2)

单个 Buffer Pool 实际占用内存空间 = innodb_buffer_pool_size / innodb_buffer_pool_instances

由于管理 Buffer Pool 需要额外的性能开销,因此 innodb_buffer_pool_instances 的个数并不是越多越好

特别需要注意,在 InnoDB 中,当 innodb_buffer_pool_size 小于 1GB 时,innodb_buffer_pool_instances 无效,即使设置的 innodb_buffer_pool_instances 值不为 1,InnoDB 默认也会把它改为 1,这是需要同时考虑单个 Buffer Pool 大小和多实例管理的性能开销而作出的选择

关于 Buffer Pool 的详细参数可以通过如下命令查看

show variables like '%innodb_buffer_pool%'

InnoDB 存储引擎之 Buffer Pool-LMLPHP

innodb_buffer_pool_chunk_size:指定了 InnoDB 缓冲池内存的分配单位大小,默认值为 128MB,表示以 128MB 为单位进行内存分配
innodb_buffer_pool_instances:这个参数指定了 InnoDB 缓冲池被划分为多少个实例,每个实例独立管理一部分缓冲池内存,可以提高并发读取的性能,建议设置为 CPU 核心数
innodb_buffer_pool_size:指定了 InnoDB 缓冲池的大小,即用于缓存数据和索引的内存池大小,默认单位是字节,适当调整此参数可以提高读取性能,过大或过小都可能导致性能下降

 

10-25 19:05