用户对数据库的最基本要求就是能高效的读取和存储数据,但是读写数据都涉及到与低速的设备交互,为了弥补两者之间的速度差异,所有数据库都有缓存池,用来管理相应的数据页,提高数据库的效率,当然也因为引入了这一中间层,数据库对内存的管理也变得相对复杂。

众所周知,MySQL 操作任何一个数据页面都需要读到 Buffer pool 进行才会进行操作。所以任何一个读写请求都需要从 Buffer pool 来获取所需页面。如果需要的页面已经存在于 Buffer pool,那么直接利用当前页面进行操作就行。但是如果所需页面不在 Buffer pool,比如 UPDATE 操作,那么就需要从 Buffer pool 中新申请空闲页面,将需要读取的数据放到Buffer pool中进行操作。

# 什么是数据页

InnoDB中,数据管理的最小单位为页,默认是16KB,页中除了存储用户数据,还可以存储控制信息的数据。InnoDB IO子系统的读写最小单位也是页。

-- 查看数据页大小
SHOW GLOBAL STATUS LIKE 'Innodb_page_size';

在 ibd 中,0-16KB偏移量即为0号数据页,16KB-32KB的为1号数据页,依次类推。数据页的头尾除了一些元信息外,还有Checksum校验值,这些校验值在写入磁盘前计算得到,当从磁盘中读取时,重新计算校验值并与数据页中存储的对比,如果发现不同,则会导致MySQL crash。InnoDB 的数据页有很多种,比如,索引页,Undo页,Inode页,系统页,BloB页等,因并非本文讨论的重点因此这里就不多加赘述了。

# 逻辑链表

链表节点是数据页的控制体(控制体中有指针指向真正的数据页),链表中的所有节点都有同一的属性,引入其的目的是方便管理。下面其中链表都是逻辑链表。

# Free List

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

# LRU List

这个是InnoDB中最重要的链表。所有新读取进来的数据页都被放在上面。链表按照最近最少使用算法排序,最近最少使用的节点被放在链表末尾,如果Free List里面没有节点了,就会从中淘汰末尾的节点。LRU List还包含没有被解压的压缩页,这些压缩页刚从磁盘读取出来,还没来的及被解压。

# LRU 页面置换算法

LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。

传统的LRU是如何进行缓冲页管理?

最常见的做法是将最近被访问的页放到 LRU 的头部,而淘汰是从 LRU 的尾部开始,因此位于头部的页面最晚才被淘汰。这里分为两种情况:

  1. 页已经在缓冲池中:则只需要将页移至缓冲池的头部且没有数据页被淘汰

(a). 原始缓冲池数据如下

20200326145342

(b). 需要访问的页号为 55 , 发现 55 就在缓冲池中

20200326145742

(c). 则将页号为 55 的数据页移至缓冲池的同步,移后的结果如下

20200326150029

  1. 页不在缓冲池中:则除了需要将页放置于缓冲池的头部,还需要从缓冲池的尾部淘汰旧数据。

(a). 原始缓冲池数据如下

20200326145342

(b). 需要访问的页号为 56 , 发现 56 就不在缓冲池中

20200326150709

(c). 将页号为 56 的数据页放入缓冲池头部,同时移除尾部页号为 1 的数据页

20200326151327

传统的LRU缓冲池算法简单高效,但是 InnoDB 并没有直接使用,主要是为了预读的数据页失效和全表扫描导致的 buffer pool 被污染

  1. 什么是预读失效 由于预读(Read-Ahead),提前把页放入了缓冲池,但最终 MySQL 并没有从页中读取数据,称为预读失效。
  2. 如何解决

(a). 让预读失败的页,停留在缓冲池LRU里的时间尽可能短

(b). 让真正被读取的页,才挪到缓冲池LRU的头部

  1. InnoDB 的具体解决方法

20200325182702

由上图可以看出 InnoDB 将 LRU List 分为两部分,默认前 5/8 为 New Sublist(新生代)用于存储经常被使用的热点数据页,后 3/8 为 Old Sublist(老生代),新读入的数据页默认被放到 Old Sublist 中,只有满足一定条件后,才会被移入 New Sublist。

新生代和老生代代比例在 MySQL 中通过参数 innodb_old_blocks_pct 控制,值的范围是5到95.默认值是37(即池的3/8)。

  • 如果数据页真正被读取(预读成功),才会加入到新生代的头部
  • 如果数据页没有被读取,则会比新生代里的“热数据页”更早被淘汰出缓冲池

举个例子,整个缓冲池如图

20200326171515

假如有一个页号为 50 的数据页页被预读加入缓冲池:

(a). 页号为50 的数据页只会从老生代头部插入,老生代尾部(也是整体尾部)的页会被淘汰掉,即 8 号数据页被淘汰。

20200326172850

(b). 假如页号为50 的数据页不被真正读取,即预读失败,它将比新生代的数据更早淘汰出缓冲池

(c). 假如 50 这一页立刻被读取到,例如SQL访问了页内的行row数据。它会被立刻加入到新生代的头部,同时新生代的页会被挤到老生代,此时并不会有页面被真正淘汰

20200326173852

改进版缓冲池LRU能够很好的解决“预读失败”的问题。但仍然无法解决缓冲池被污染但问题。

  1. 什么是缓冲池污染?

当某一个SQL语句,要批量扫描大量数据时,可能导致把缓冲池的所有页都替换出去,导致大量热数据被换出,MySQL 性能急剧下降,这种情况叫缓冲池污染。

  1. 解决方法

缓冲池加入了一个“老生代停留时间窗口”的机制:

(a). 假设T=老生代停留时间窗口

(b). 插入老生代头部的页,即使立刻被访问,并不会立刻放入新生代头部

(c). 只有满足“被访问”并且“在老生代停留时间”大于T,才会被放入新生代头部

假如批量数据扫描,有91、92、93、94、95、96、97、98、99等页面将要依次被访问

20200326184026

如果没有“老生代停留时间窗口”的策略,这些批量被访问的页面,会置换出大量热数据。

20200326190058

加入“老生代停留时间窗口”策略后,短时间内被大量加载的页,并不会立刻插入新生代头部,而是优先淘汰那些,短期内仅仅访问了一次的页。

20200326190531

只有在老生代呆的时间足够久,停留时间大于T,才会被插入新生代头部。

20200326222304

老生代的停留时间由参数 innodb_old_blocks_time 控制,单位为毫秒,默认是1000

# FLU List

这个链表中的所有节点都是脏页,也就是说这些数据页都被修改过,但是还没来得及被刷新到磁盘上。在FLU List上的页面一定在LRU List上,但是反之则不成立。一个数据页可能会在不同的时刻被修改多次,在数据页上记录了最老(也就是第一次)的一次修改的lsn,即oldest_modification。不同数据页有不同的oldest_modification,FLU List中的节点按照oldest_modification排序,链表尾是最小的,也就是最早被修改的数据页,当需要从FLU List中淘汰页面时候,从链表尾部开始淘汰。加入FLU List,需要使用flush_list_mutex保护,所以能保证FLU List中节点的顺序。

# 总结

  1. 缓冲池(buffer pool)是一种常见的降低磁盘访问的机制
  2. 缓冲池以数据页(page)为单位缓存数据
  3. 缓冲池的常见管理算法是 LRU
  4. InnoDB 对普通 LRU 进行了优化,将缓冲池分为老生代和新生代,入缓冲池的页,优先进入老生代,页被访问,才进入新生代,以解决预读失效的问题。同时采用老生代停留时间窗口机制,当数据页被访问且在老生代停留时间超过配置阈值的,才进入新生代,以解决批量数据访问,大量热数据淘汰的问题

# 参考

  1. CMySQL · 引擎特性 · InnoDB Buffer Pool
  2. 缓冲池(buffer pool),这次彻底懂了!!! (opens new window)