前言
Inoodb存储引擎是以页为单位从磁盘上读取记录到内存的,一个页大小为16KB
,通常包含多条记录,可以很好的利用空间局部性原理来减少磁盘IO,提升效率。但是如果一个页被调入内存,使用完后再刷新回磁盘中,考虑到某些页访问频率非常高,那么这个页就会频繁的在内存与磁盘间换进换出,IO
是一个非常耗时的工作,考虑到时间局部性原理,Innodb内部设着了一个缓冲池Buffer pool
来缓存页面,当访问一个页面时,如果恰好该页面在Buffer pool
里,就可以直接访问内存而不需要进行磁盘IO
了。
Buffer pool
Buffer pool
实际上就是Innodb在内存上划分出的一块完整的用于存放数据页的区域(这里主要为了方便管理,实际上Innodb还设计了chunk
的概念,一个Buffer pool
由多个chunk
构成,为的是可以在运行过程中动态修改Buffer pool
的大小,chunk
是一块连续的内存区域 )。当Buffer pool
里不存在某个页面时,就需要进行磁盘IO
,将该页面调入内存。
但是Inoodb如何管理
Buffer pool
里的页呢?
如何判断一个页是否在Buffer pool
里?
如何判断哪些页被修改过(脏页
),需要在特定时刻刷新回磁盘?
当Buffer pool
满时,如何淘汰掉一些页面给新的页面腾出空间?
...
为了解决掉上述问题,Innodb
给每一个在Buffer pool
里的页都配了一个“遥控器”,称为控制块,一个控制块关联一个特定的页面,Innodb
将Buffer pool
分成了两个区域,一个区域专门存放控制块,另一个区域专门存放页数据。那么Buffer pool
大体上如下图所示:
缓存命中
想要判断一个页面是否在Buffer pool
里,其实很简单。可以参考java
里的HashMap
。我们可以把页号作为key
,页面在缓存池里的具体位置作为value
,只要判断对应的key
是否存在在哈希表里,就可以判断缓存是否命中了。
管理空闲页
如何知道当前Buffer pool
里是否有空闲的页,哪些页是空闲页可供使用呢?为了解决这个问题,Buffer pool
还管理一个空闲链表free list
。我们知道在Buffer pool
中,每个页不管是否空闲,都关联着一个控制块
。free list
实际上就是由空闲页面的控制块
组成的双向链表结构,示意图如下。
有了这个空闲链表就很好解决这个问题了,当我需要给从磁盘读入的新页面在
Buffer pool
分配一个磁盘空间时,我们我们就在空闲链表的头部取出一个控制块,在控制块里登记相应的页信息(比如页号、表空间等)就可以了。为这个free list
引入基节点主要是为了方便管理,功能类似于哨兵节点,我们可以在里面记录当前空闲页的数目,队头的控制块地址和队尾的控制块地址等等。
管理脏页
Buffer pool
中的页被修改过以后(也就是脏页),是需要刷新回磁盘里去进行持久化的。我们可以再每个页面修改后就立即进行写回磁盘(flush
)的操作,但是这样会引入了频繁的IO操作。Innodb实际上在后台维护了一个线程,在特定时刻执行批量写回操作。现在的问题是我们如何判定哪些是脏页,脏页都在Buffer pool
呢?这个问题和之前如何管理空闲页一模一样,所以Innodb采取的方法也是相同的,维护一个名为flush list
的双向链表结构,来记录脏页信息,具体结构可以参考上面的空闲链表,这里就不画出了。
优化的LRU算法
Buffer pool
并不是无限大的,所以存放在Buffer pool
的页数量也是有上限的,当我们没有命中Buffer pool
中缓存的页,就要去磁盘里将一个新的页调入到Buffer pool
中,如果恰好此时我们通过查看free list
发现没有空闲的页面了,那么就需要选择一个页面淘汰掉,使用什么样的算法来选择这个需要被淘汰掉的页面呢?先来介绍一下LRU算法。
LRU(最近最少使用)
算法的想法很简单,如果最近用到的页面通常使用的频次比较高,在不久的将来可能会再次用到,而长期未被使用的页面再被访问的几率就比较小,因此选择最近最少使用的页面淘汰回磁盘上去,释放内存空间。
LRU在具体实现上也很简单,它将页面(实际上是页面对应的控制块)组织成一个双向链表的结构,当访问一个页面时,就把这个页面插入到该双向链表的对头,表明这个页面刚使用过。当需要淘汰一个页面时就从这个双向链表的队尾删除一个页面(这个页面最近最少被使用),并将其写回磁盘(如果是脏页的话)。
这样一看LRU似乎很适合用作我们的页面置换算法,但是我们需要考虑到Innodb两个很重要的问题预读
和全表扫描
。
- 预读
就像操作系统IO
的时候,往往会连带着多读几个磁盘块到内存里,利用空间局部性原来减少IO
频率,Innodb
一些时候也会连带着多读一些页到内存里,比如当连续访问某个区里的页面数量超过一定的限制,就会把这整个区的页面读到内存里。问题是预读的页面并不是一定会被访问到,此时就会把一些使用频次较高的页面从内存里挤出去。 - 全表扫描
全表扫描会造成一种更糟糕的局面,一是全表扫描往往发生的频率很低,这些页面调入进内存后往往只会访问一次,却破坏了精心维护的LRU
链表结构。二是一旦发生全表扫描,很大概率会调入大量的页面进去页面,把大量甚至全部的缓存冲的页面刷新回主存。
为了解决以上两种问题,Innodb
对传统的LRU
链表进行了优化,将其分成了两个区,young
区存放使用频次较高的页面,old
区存放一些使用频次较低的页面。新调入进Buffer pool
的页面会插入在old
区的头部,而不是young
区的头部(也就是整个LRU
链表的头部)。只有当插入到old区头部的页面真正被访问到的时候,才会被加入young
区。这样即使预读到了大量的页面,也只会把old区的页面淘汰掉,不会影响到young
区的热点页面。
通过对LRU
链表分区的方法,可以解决预读到大量不会被访问到页面从而淘汰出大量热点页面的问题。当是这并没有解决全表扫描的困境,通过全表扫描调入Buffer pool
,是一定会被访问到,而且因为一个页里包含多条记录,还会被访问多次,从而被调入young
区,淘汰掉真正的热点页面。那么如何解决全表扫描对LRU
的影响呢?可以观察到一个现象,当全表扫描的时候,Buffer pool
中的这个新调入的页,从第一次访问(页中第一条记录)到最后一次访问(扫描完该页最后一条记录)所使用的时间非常短,我们可以设置一个访问时间间隔的阈值,当低于这个阈值的时候,说明是一次扫描操作,不把这个页面从old
区加入到young
区中去。
让我们来看一下这个阈值是多少。
SHOW VARIABLES LIKE 'innodb_old_blocks_time';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_old_blocks_time | 0 |
+------------------------+-------+
表中Value
的单位是ms,0ms也就不会判定是否是一个全部扫描操作,每当一个页面被访问到就会将其加入到young
区中去。
最后我们给出Buffer pool
中维护的LRU链表的示意图。
网友评论