用户对数据库的最基本要求就是能高效的读取和存储数据,但是读写数据都涉及到与低速的设备交互,为了弥补两者之间的速度差异,所有数据库都有缓存池,用来管理相应的数据页,提高数据库的效率,当然也因为引入了这一中间层,数据库对内存的管理也变得相对复杂。
众所周知,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 的尾部开始,因此位于头部的页面最晚才被淘汰。这里分为两种情况:
- 页已经在缓冲池中:则只需要将页移至缓冲池的头部且没有数据页被淘汰
(a). 原始缓冲池数据如下
(b). 需要访问的页号为 55
, 发现 55
就在缓冲池中
(c). 则将页号为 55
的数据页移至缓冲池的同步,移后的结果如下
- 页不在缓冲池中:则除了需要将页放置于缓冲池的头部,还需要从缓冲池的尾部淘汰旧数据。
(a). 原始缓冲池数据如下
(b). 需要访问的页号为 56
, 发现 56
就不在缓冲池中
(c). 将页号为 56
的数据页放入缓冲池头部,同时移除尾部页号为 1
的数据页
传统的LRU缓冲池算法简单高效,但是 InnoDB 并没有直接使用,主要是为了预读的数据页失效和全表扫描导致的 buffer pool 被污染
- 什么是预读失效 由于预读(Read-Ahead),提前把页放入了缓冲池,但最终 MySQL 并没有从页中读取数据,称为预读失效。
- 如何解决
(a). 让预读失败的页,停留在缓冲池LRU里的时间尽可能短
(b). 让真正被读取的页,才挪到缓冲池LRU的头部
- InnoDB 的具体解决方法
由上图可以看出 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)。
- 如果数据页真正被读取(预读成功),才会加入到新生代的头部
- 如果数据页没有被读取,则会比新生代里的“热数据页”更早被淘汰出缓冲池
举个例子,整个缓冲池如图
假如有一个页号为 50
的数据页页被预读加入缓冲池:
(a). 页号为50
的数据页只会从老生代头部插入,老生代尾部(也是整体尾部)的页会被淘汰掉,即 8
号数据页被淘汰。
(b). 假如页号为50
的数据页不被真正读取,即预读失败,它将比新生代的数据更早淘汰出缓冲池
(c). 假如 50
这一页立刻被读取到,例如SQL访问了页内的行row数据。它会被立刻加入到新生代的头部,同时新生代的页会被挤到老生代,此时并不会有页面被真正淘汰
改进版缓冲池LRU能够很好的解决“预读失败”的问题。但仍然无法解决缓冲池被污染但问题。
- 什么是缓冲池污染?
当某一个SQL语句,要批量扫描大量数据时,可能导致把缓冲池的所有页都替换出去,导致大量热数据被换出,MySQL 性能急剧下降,这种情况叫缓冲池污染。
- 解决方法
缓冲池加入了一个“老生代停留时间窗口”的机制:
(a). 假设T=老生代停留时间窗口
(b). 插入老生代头部的页,即使立刻被访问,并不会立刻放入新生代头部
(c). 只有满足“被访问”并且“在老生代停留时间”大于T,才会被放入新生代头部
假如批量数据扫描,有91、92、93、94、95、96、97、98、99等页面将要依次被访问
如果没有“老生代停留时间窗口”的策略,这些批量被访问的页面,会置换出大量热数据。
加入“老生代停留时间窗口”策略后,短时间内被大量加载的页,并不会立刻插入新生代头部,而是优先淘汰那些,短期内仅仅访问了一次的页。
只有在老生代呆的时间足够久,停留时间大于T,才会被插入新生代头部。
老生代的停留时间由参数 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中节点的顺序。
# 总结
- 缓冲池(buffer pool)是一种常见的降低磁盘访问的机制
- 缓冲池以数据页(page)为单位缓存数据
- 缓冲池的常见管理算法是 LRU
- InnoDB 对普通 LRU 进行了优化,将缓冲池分为老生代和新生代,入缓冲池的页,优先进入老生代,页被访问,才进入新生代,以解决预读失效的问题。同时采用老生代停留时间窗口机制,当数据页被访问且在老生代停留时间超过配置阈值的,才进入新生代,以解决批量数据访问,大量热数据淘汰的问题