使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以的形式存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个实际文件的抽象,数据存储在磁盘上的。磁盘的速度无法和CPU 速度匹配

InnoDB 存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。

InnoDBMySQL 服务器启动的时候就向操作系统申请了一片连续的内存,叫做 Buffer Pool(缓冲池)。默认情况下 Buffer Pool 只有 128M 大小。可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值

1
2
[server]
innodb_buffer_pool_size = 268435456

1. Buffer Pool 内部组成

Buffer Pool 中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB。为了更好的管理这些在Buffer Pool中的缓存页,InnoDB 为每一个缓存页都创建了一些 控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及 LSN 信息

每个缓存页对应的控制信息占用的内存大小是相同的,把每个页对应的控制信息占用的一块内存称为一个 控制块 ,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间如图所示

每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,空间就被称为碎片

截屏2021-05-19 下午2.51.42

每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的内存空间就被称为碎片

2. free 链表的管理

启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool中,之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool中。

Innodb 把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,被称作 free链表 (空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样的:

3. 缓存页的哈希处理

需要访问某个页中的数据时,就会把该页从磁盘加载到 Buffer Pool 中,如果该页已经在 Buffer Pool 中的话直接使用就可以了。

根据 表空间号 + 页号 来定位一个页的,也就相当于 表空间号 + 页号 是一个 key缓存页 就是对应的 value

表空间号 + 页号 作为 key缓存页 作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

4. flush 链表的管理

如果修改了Buffer Pool中某个缓存页的数据,那它和磁盘上的页数据不一致,这样的缓存页也被称为脏页。最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步

Innodb 创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫 flush链表 。链表的构造和 free链表 差不多,假设某个时间点 Buffer Pool中的脏页数量为n

截屏2021-05-19 下午4.34.12

5. LRU 链表的管理

Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,也就是free链表中已经没有多余的空闲缓存页的时候把某些旧的缓存页从Buffer Pool中移除,然后再把新的页放进来

5.1. 简单的 LRU 链表

Buffer Pool 的缓存页其 当 Buffer Pool 中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页

创建一个链表,这个链表是为了按照最近最少使用的原则去淘汰缓存页的,被称为LRU链表。当需要访问某个页时,可以这样处理LRU链表

  • 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块作为节点塞到链表的头部。

  • 如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。

5.2. 划分区域的 LRU 链表

5.2.1. 简单的 LRU 链表存在的问题

  1. 预读

    InnoDB 提供了预读。所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。根据触发方式的不同,预读 又可以细分为下边两种:

    • 线性预读

      InnoDB提供了一个系统变量 innodb_read_ahead_threshold ,如果顺序访问了某个区的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求,注意异步读取意味着从磁盘中加载这些被预读的页面并不会影响到当前工作线程的正常执行。这个innodb_read_ahead_threshold系统变量的值默认是56,可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值,不过它是一个全局变量,注意使用SET GLOBAL命令来修改哦。

    • 随机预读

      如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其的页面到Buffer Pool的请求。InnoDB 提供了 innodb_random_read_ahead 系统变量,它的默认值为OFF,也就意味着InnoDB并不会默认开启随机预读的功能

    如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果预读不到,预读的页都会放到LRU链表的头部,但是如果此时Buffer Pool的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。

  2. 扫描全表

    扫描全表意味访问到该表所在的所有页。假设这个表中记录非常多的话,那该表会占用特别多的,当需要访问这些页时,会把它们全部加载到 Buffer Pool 中,就意味着 Buffer Pool 中的所有页会被全部替换,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool中的缓存页全部替换,这严重的影响到其他查询对 Buffer Pool的使用,从而大大降低了缓存命中率。

5.2.2. 简单链表优化

Innodb 按照某个比例将 LRU 链表分成两半,不是某些节点固定是 young 区域的,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也可能发生变化。

截屏2021-05-19 下午5.42.38

对于 InnoDB 存储引擎来说,可以通过查看系统变量 innodb_old_blocks_pct 的值来确定 old 区域在 LRU链表 中所占的比例

1
mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_pct';

old 区域在 LRU链表 中所占的比例是 37% ,比例可以设置,可以在启动时修改 innodb_old_blocks_pct 参数来控制 old 区域在 LRU链表 中所占的比例

1
2
[server]
innodb_old_blocks_pct = 40
  • 针对预读的页面可能不进行后续访情况的优化

    InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。

  • 针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化

    在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移动到 young区域的头部。间隔时间是由系统变量 innodb_old_blocks_time 控制的

    1
    2
    3
    4
    5
    6
    7
    mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_time';
    +------------------------+-------+
    | Variable_name | Value |
    +------------------------+-------+
    | innodb_old_blocks_time | 1000 |
    +------------------------+-------+
    1 row in set (0.01 sec)

5.3. LRU 链表优化

LRU链表每次访问一个缓存页就要把它移动到LRU链表的头部,开销太大,在 young 区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对 LRU链表 进行节点移动操作严重影响性能。为了解决这个问题其实还可以提出一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到 LRU链表 头部,这样可以降低调整 LRU链表 的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young区域的1/4中,再次访问该缓存页时也不会将其移动到LRU链表头部)。

6. 刷新脏页到磁盘

后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:

  • LRU链表 的冷数据中刷新一部分页面到磁盘。

    后台线程会定时从 LRU链表 尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 来指定,如果发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为 BUF_FLUSH_LRU

  • flush链表 中刷新一部分页面到磁盘。

    后台线程也会定时从 flush链表 中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为 BUF_FLUSH_LIST

有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看LRU链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表尾部的一个脏页同步刷新到磁盘和磁盘交互是很慢的,这会降低处理用户请求的速度。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE

系统特别繁忙时,也可能出现用户线程批量的从 flush链表 中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为