MySQL之深入InnoDB存储引擎——Buffer Pool
InnoDB存储引擎是基于磁盘存储的,并将其中的记录按照页的方式进行管理。在数据库系统中,由于CPU速度与磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池技术来提高数据库的整体性能。在数据库中进行读取页的操作,首先将从磁盘读到的页存放在缓冲池中,这个过程称为将页“FIX”在缓冲池中,在下一次读取相同的页时,首先判断该页是否存在缓冲池中,如果存在则被命中,直接读取,否则读取磁盘上的页。
Buffer Pool对应的一片连续的内存被划分为若干个页面,页面大小和InnoDB表空间使用的页面大小一致,默认都是16KB。为了更好地管理Buffer Pool中的这些缓冲页,InnoDB为每个缓冲页都创建了一个控制块,其中包含该页所属的表空间编号、页号、缓冲页在Buffer Pool中的地址、链表节点等信息。控制块位于Buffer Pool的内存的前部分,缓冲页位于Buffer Pool的后部分,两部分之间可能会存在碎片(如果剩余的空间不足够再分配一堆控制块和缓冲页则会产生)。
一、空闲链表的管理
在我们最开始启动MySQL服务器的时候,需要向操作系统申请Buffer Pool的内存空间,然后把它换分成若干对控制块和缓冲页。当然此时没有真实的磁盘页被缓存到Buffer Pool中,会随着程序的运行不断缓存。
当从磁盘上读取到一个页时,我们需要将其放置在空闲的缓冲页中。那我们就需要区分哪些页是空闲的,哪些页已经被使用了,因此引入了Free链表(空闲链表)。在Buffer Pool刚刚完成初始化之后,所有缓冲页对应的控制块都会加入到空闲链表中。而为了管理这个空闲链表,我们为其定义了一个基节点,其中包含了链表的头节点地址、尾节点地址以及当前链表中节点的数量等信息。需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool申请的一大片连续内存孔内,而是一块单独申请的内存空间。
有了空闲链表之后,每当需要从磁盘中加载一个页到Buffer Pool中时,就从空闲链表中取一个缓冲页对应的控制块,把页加载到控制块指向的缓冲页中,并把该控制块的信息给填上(即表空间、页号等)。
二、缓冲页的哈希处理
使用了Buffer Pool之后,我们访问某个页的数据时会先去缓冲池中查找该页是否已经加载到内存中,如果不存在才会去磁盘中加载。而为了快速判断目标页是否存在于缓冲池中,InnoDB采用的是哈希表的方式,将表空间号和页号作为键,缓冲页控制块作为值来进行构建。
三、Flush链表的管理
如果我们修改了Buffer Pool中某个缓冲页的数据,那其中的数据就和磁盘中的不同了,此时这个缓冲页变为了脏页。虽然我们可以在每次修改完都将其刷新到磁盘中对应的页上,但是这显然会严重影响程序的性能。因此InnoDB不会在每次修改完页都立刻刷库,而是在未来某个时间点进行刷新。
既然不是及时刷新,那我们在未来刷新时便需要知道哪些页是脏页。因此引入了Flush链表进行脏页的管理,当缓冲页被修改则将其控制块加入到链表中。
和空闲链表相同,它也有一个相同结构的基节点来保存这个链表的信息。显然当一个缓冲页的控制块在空闲链表中时则不可能在Flush链表中,反之同理。
四、LRU链表的管理
Buffer Pool对应的内存大小毕竟是有限的,随着程序的运行Free链表总归会用完。如果Free链表已经没有多余的空闲缓冲页了,那么只能将某些旧的缓冲页从Buffer Pool中淘汰,再将新的页放进来。Buffer Pool引入的初衷是为了想减少磁盘IO,因此自然希望淘汰掉的页面是最少使用的,而最近经常使用的页面不被淘汰,因此InnoDB采用的淘汰策略为LRU,即最近最少使用算法。以这个算法为核心,InnoDB维护了一个LRU链表,如果页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool的时候,就把该缓冲页对应的控制块作为节点添加到LRU链表的头部,如果使用到链表中的某个页时也将其移动到链表头部。之后当空闲缓冲页用完时,就从链表尾部进行淘汰。
但这种实现并不完善,它存在两个问题:
-
因为InnoDB提供了预读的功能,即InnoDB认为执行当前的请求时,可能会在后面读取某些页面,于是就预先把这些页面加载到Buffer Pool中。根据触发方式的不同,预读可分为以下两种:
- 线性预读:InnoDB中有一个系统变量
innodb_read_ahead_threshold
,如果顺序访问某个区的页面超过这个系统变量的值,则会触发一次异步读取下一个区中所有页面的请求 - 随机预读:如果某个区的13个连续的页面都被加载到了Buffer Pool中,无论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他页面到Buffer Pool中的请求。这个功能默认不会开启
- 预读到Buffer Pool中的页如果能够被成功的使用到,那自然可以极大地提高语句执行的效率。但是如果用不到,会导致LRU链表尾部的一些缓冲页很快就被淘汰掉,从而大大降低Buffer Pool的命中率
- 线性预读:InnoDB中有一个系统变量
-
有的时候可能需要用到全表扫描,这个过程中需要访问的页面一般会特别多,那么就会导致LRU链表中很多页面都会被淘汰掉。但是这次全表扫描引进来的这些页面可能并不是热点数据,但是反而把热点数据从链表中赶跑了
为了避免以上两种情况把Buffer Pool中真正的热点数据淘汰,InnoDB将LRU分成两个部分:
- 一部分存储使用频率非常高的缓冲页,称为热数据或young数据
- 一部分存储使用频率不是很高的缓冲页,称为冷数据或old数据
LRU的划分是根据比例的,默认情况下old区域占LRU链表的37%。之后当磁盘的页面被初次加载到Buffer Pool中时,该缓冲页对应的控制块会放置到old区域的头部,而不再是整个链表的头部,这样就不会使得预读加载进来的页影响young区域中使用比较频繁的缓冲页。并且在对某个处于old区域的缓冲页进行第一次访问时,会在它对应的控制块中记录下整个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会从old区域移动到young区域头部。这样一来也可以防止全表扫描时除了将数据页加载到Buffer Pool,还会频繁读取页面中的记录导致仍然会淘汰掉热点数据的情况。
除了以上的改进,LRU还有进一步的优化策略。对于young区域的缓冲页来说,如果我们每次访问一个缓冲页都需要把它移动到LRU链表的头部,其实开销是比较大的,因为young区域都是热点数据。因此InnoDB规定只有被访问的缓冲页位于young区域1/4的后面时,才会被移动到LRU链表的头部。
五、脏页刷新
后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求,刷新的方式主要有两种:
- 从LRU链表的冷数据中刷新一部分页面到磁盘:后台线程会定时从LRU链表尾部开始扫描一些页面。如果在LRU链表中发现脏页(缓冲页的控制块就会记录该缓冲页是否被修改),则把它们刷新到磁盘
- 从Flsuh链表中刷新一部分到磁盘:后台线程也会定时从Flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是否繁忙
当有线程在准备加载一个磁盘页到Buffer Pool中时没有可用的缓冲页时会尝试查看LRU链表尾部,看是否存在可以直接释放掉的未修改缓冲页。有时后台线程刷新脏页的进度比较慢,导致LRU链表尾部的脏页还没有被刷新,此时则不得不将LRU链表尾部的一个脏页同步刷新到磁盘中,这会降低用户请求的速度。
六、多Buffer Pool实例
在多线程环境下,访问Buffer Pool中的各个链表都需要加锁处理。在Buffer Pool特别大并且多线程并发量特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大时,可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例。它们之间是相互独立的,多线程并发时不会互相影响。