1.MySQL是如何读取记录的——缓存的重要性
对于使用InnoDB
作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚集索引和非聚集索引),还是各种系统数据,都是以页的形式存放在磁盘上的。而CPU
与内存的交互远远快于与磁盘的交互,所以InnoDB
存储引擎在处理客户端的请求时,如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中。也就是说,即使我们只需要访问一个页的一条记录,也需要先把整个页的数据加载到内存中。
将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样下次有请求再次访问该页面时,就可以省去磁盘I/O
的开销了。
2. InnoDB的Buffer Pool
2.1 什么是Buffer Pool
为了缓存磁盘中的页,在MySQL
服务器启动的时候就向操作系统申请了一片连续的内存,并给这片内存起了个名——Buffer Pool
(缓冲池)。默认情况下Buffer Pool
只有128M
大小。
innodb_buffer_pool_size
的单位是字节,134217728 Byte=128MB
如果你想修改大小,可以在启动服务器的时候配置innodb_buffer_pool_size
参数的值,它表示Buffer Pool
的大小,就像这样:
[server]
innodb_buffer_pool_size = 268435456
上面指定Buffer Pool
大小是256M
,注意,Buffer Pool
不能太小,最小值是5MB
,即使innodb_buffer_pool_size
的值小于5MB
,也会被自动设置成5MB
2.2 Buffer Pool的内部结构
Buffer Pool
对应的一片连续的内存被划分为若干个页,页大小默认是16KB
。我们前文又说过,页是磁盘与内存之间交互的基本单位,为了将磁盘中的页和Buffer Pool
中的页区分开,我们这里把Buffer Pool
中的页称为缓存页。
为了更好的管理这些在Buffer Pool
中的缓存页,InnoDB
在每一个缓存页都创建了一些控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool
中的地址、链表节点信息等等。
每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个控制块,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool
中,其中控制块被存放到 Buffer Pool
的前边,缓存页被存放到 Buffer Pool
后边,所以整个Buffer Pool
对应的内存空间看起来就是这样的:
细心的小伙伴会发现,上图的控制块和缓存页之间还有个碎片。因为每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,剩余的那点儿空间可能不够一对控制块和缓存页的大小,自然就用不到了,这个用不到的内存空间就被称为碎片。当然,如果你把Buffer Pool
的大小设置的刚刚好的话,也可能不会产生碎片。
注意:在
DEBUG
模式下,每个控制块大约占用缓存页大小的5%
(非DEBUG
模式下会更小一点),在MySQL 5.7.22
这个版本的DEBUG
模式下,每个控制块占用的大小是808
字节。而我们设置的innodb_buffer_pool_size
并不包含这部分控制块占用的内存空间大小,也就是说InnoDB
在为Buffer Pool
向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size
的值大5%左右。
2.3 free链表
当我们最初启动MySQL
服务器的时候,需要完成对Buffer Pool
的初始化过程,就是先向操作系统申请Buffer Pool
的内存空间,然后把它划分成若干对控制块和缓存页的位置(用来缓存后续从磁盘读取的页)。但是此时并没有真实的磁盘页被缓存到Buffer Pool
中(因为还没有用到),下次查询某条记录时,如果Buffer Pool
中没有这个磁盘页,那么包含这条记录的磁盘页就会被缓存到Buffer Pool
中。
问题来了,从磁盘上读取一个页到Buffer Pool
中的时,该放到哪个缓存页的位置呢?怎么知道Buffer Pool
中哪些缓存页是空闲的,哪些已经被使用了呢?所以需要记录一下Buffer Pool
中哪些缓存页是可用的,这个时候控制块就派上大用场了——我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free
链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool
中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free
链表中,假设该Buffer Pool
中可容纳的缓存页数量为n
,那增加了free
链表的效果图就是这样的:
为了管理好这个free
链表,特意为这个链表定义了一个基节点,里边儿包含着链表的头节点地址、尾节点地址以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool
申请的一大片连续内存空间之内,而是单独申请的一块内存空间。
链表基节点占用的内存空间并不大,在
MySQL 5.7.22
中,每个基节点只占用40
字节,后面会介绍的flush
链表、LRU
链表的基节点也是一样,它们的基节点在内存分配方式上与free
链表的基节点一样,都是一块单独申请的40
字节的内存空间,并不包含在为Buffer Pool
申请的一大片连续内存空间之内。
每当需要从磁盘中加载一个页到Buffer Pool
中时,就从free
链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free
链表节点从链表中移除,表示该缓存页已经被使用了。
综上所述,free
链表是干什么的?
free
链表是用来记录Buffer Pool
中哪些缓存页是空闲的,空闲的缓存页对应的控制块就是free
链表中的节点。有了空闲的缓存页,才能将读取的磁盘页缓存起来。
2.4 如何知道磁盘页在Buffer Pool是否已存在——缓存页的hash
当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool
中,如果该页已经在Buffer Pool
中的话就不用从磁盘读取了,这样可以加快速度。
怎么知道该页在不在Buffer Pool
中呢?总不能依次遍历Buffer Pool
中各个缓存页吧?其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key
,缓存页就是对应的value
,要通过一个key
来找一个value
,哈希表就能解决 。
你查找这个表,表空间号一定知道,关于页号查找过程,下面马上会讲。
补充知识:
- 表空间中的每一个页都对应着一个页号,也就是
FIL_PAGE_OFFSET
,我们可以通过这个页号在表空间中快速定位到指定的页面,这个页号由4
个字节组成,也就是32
个比特位,所以一个表空间最多可以拥有2³²
个页,如果按照页的默认大小16KB
来算,一个表空间最多支持64TB
的数据。表空间的第一个页的页号为0
,之后的页号分别是1,2,3...
依此类推
写到这里,我自己也在思考一个问题,当sql
需要查询某条记录时,首先会去判断这条记录所属的页是在磁盘上还是已经被加载到内存中的Buffer Pool
,具体过程是怎么样的?
- 1.
sql
当从磁盘中读取一页到内存的时候,页号和表空间号就已经知道了(页结构的File Header
部分存储页的各种状态信息,其中有2
个状态变量这里要关注。FIL_PAGE_OFFSET
就是记录的页号,FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID
就是表示页属于哪个表空间) - 2.再利用表空间号+页号就可以
hash
定位是否该页在Buffer Pool
中,如果在Buffer Pool
中,直接从Buffer Pool
中返回记录,如果不在,那么从磁盘读取该页,然后缓存到Buffer Pool
,再返回记录。
如果你不知道怎么从B+
树查找记录对应的页,建议阅读我的前面的基础博文图文并茂说MySQL索引——入门进阶必备 简单描述一下怎么从B+
树查找记录对应的页,直接从磁盘B+
树的根节点往下找,利用二分缩小查找范围,最后到对应范围的页去进行槽点二分查找,确定这个页有没有这条记录,如果有,就直接返回这个页。
总结一下:
我们可以用表空间号 + 页号作为key
,缓存页作为value
创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free
链表中选一个控制块,找到对应的空闲缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
2.5 flush链表
如果我们修改了Buffer Pool
中某个缓存页的数据,它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(dirty page
)。当然,我们可以每当修改完某个缓存页时,就立即将其刷新到磁盘中对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,并不是立即把修改刷新到磁盘上,而是在未来的某个时间点进行刷新。
但是,如果不立即将修改刷新到磁盘,那之后再刷新的时候我们怎么知道Buffer Pool
中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都刷新到磁盘上吧,假如Buffer Pool
被设置的很大,那一次性刷新这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是被修改过的缓存页对应的控制块都会作为一个节点加入到这个链表中。因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush
链表。链表的构造和free
链表差不多,那么对应的flush
链表就长这样:
综上所述,flush
链表是用来干啥的?
flush
链表是一个存储脏页对应控制块的链表,被修改过的缓存页对应的控制块都会作为一个节点加入到这个链表中。如果一个缓存页是空闲的,那它肯定不可能是脏页。如果一个缓存页是脏页,那它肯定就不是空闲的。所以,某个缓存页对应的控制块不可能既是free
链表的节点,又是flush
链表的节点,只能二者居其一。
2.6 LRU链表
2.6.1 Buffer Pool不够怎么办
Buffer Pool
对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool
大小,这样该怎么办?当然是把某些旧的缓存页从Buffer Pool
中移除,然后再把新的页放进来。应该移除哪些旧的缓存页吗?
设计Buffer Pool
的初衷就是想减少和磁盘的I/O
,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool
中了。假设我们一共访问了n
次页,页已在缓存中的次数除以n
就是所谓的缓存命中率,缓存命中率当然是越高越好。 比如我们的微信最近聊天列表,排在前边的都是最近很频繁使用的,每聊天一次就将这个聊天会话项排在了最前面,假如最近聊天列表能容纳下的会话数有限,你是会把最近聊天很频繁的留下还是最近很少聊天的留下呢? 当然那些聊天少的就给移除了,Buffer Pool
管理缓存页也是如此。
2.6.2 简单的LRU链表
当Buffer Pool
中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的部分缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?
我们可以再创建一个链表,和上面free
链表和flush
链表结构图类似,这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU
链表(Least Recently Used
)。当需要访问某个页时,可以按照下面的方式处理LRU
链表:
- 如果该页不在
Buffer Pool
中,在把该页从磁盘加载到Buffer Pool
中的缓存页时,就把该缓存页对应的控制块作为节点塞到LRU
链表的头部。 - 如果该页已经被加载到
Buffer Pool
中,则直接把该页对应的控制块移动到LRU
链表的头部。
也就是说,只要我们使用到某个缓存页,就把该缓存页对应的控制块调整到LRU
链表的头部,这样LRU
链表尾部就是最近最少使用的缓存页了。 所以当Buffer Pool
中的空闲缓存页使用完时,到LRU
链表的尾部找些缓存页对应的控制块,将这些控制块和对应缓存页淘汰就OK
了。
2.6.3 划分区域的LRU链表(内容难度提升 ↑)
上面只是简单的LRU
链表,用了没多长时间就会发现问题,因为存在这两种比较尴尬的情况:
- 情况一:我们上面说过,只有当我们用到某个页时,才会将其从磁盘加载到
Buffer Pool
中,用不到则不加载。而InnoDB
提供了预读(read ahead
)的功能,所谓预读,就是InnoDB
认为执行当前的请求时,可能会在后面读取某些页面,于是就预先把这些页面加载到Buffer Pool
中。根据触发方式的不同,预读又可以细分为下边两种:
- 线性预读:
InnoDB
提供了一个系统变量innodb_read_ahead_threshold
,如果顺序访问了某个区(extent
)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool
的请求,注意异步读取意味着从磁盘中加载这些被预读的页面时,并不会影响到当前工作线程的正常执行。这个innodb_read_ahead_threshold
系统变量的值默认是56
,我们可以在服务器启动时通过启动选项来调整该值,或者在服务器运行过程中直接调整该系统变量的值,由于它是一个全局变量,因此要使用SET GLOBAL
命令来修改。 - 随机预读:如果
Buffer Pool
中已经缓存了某个区的13
个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其的页面到Buffer Pool
的请求。InnoDB
同时提供了innodb_random_read_ahead
系统变量,它的默认值为OFF
,也就意味着InnoDB
并不会默认开启随机预读的功能,如果想开启该功能,可以通过修改启动选项或者直接使用SET GLOBAL
命令把该变量的值设置为ON
。
扩展了解:
InnoDB
是怎么实现异步读取的呢?在Windows
或者Linux
平台上,可能是直接调用操作系统内核提供的AIO
接口,在其它类Unix
操作系统中,使用了一种模拟AIO
接口的方式来实现异步读取,其实就是让别的线程去读取需要预读的页面。
InnoDB
提供预读本来是想提高效率的,如果预读到Buffer Pool
中的页用不到呢?原本这些预读的页的控制块先会放到LRU
链表的头部,但是如果此时Buffer Pool
的容量不大,而且很多预读的页面都没有用到的话,随着数据的不断读取,这就会导致LRU
链表头部预读的缓存页控制块很快会到尾部,这意味着这些控制块和对应的缓存页很快会被淘汰掉,从而大大降低Buffer Pool
命中率。
- 情况二:如果写了一些需要全表扫描的查询语句(比如没有建立合适的索引或者没有
WHERE
子句的查询)。
全表扫描意味着将访问该表的聚集索引的所有叶子结点对应的页(由于需要找到第一个叶子结点,首选从B+
树的根一步步定位到第一个叶子结点的第一条记录,这个过程不得不访问少量的非叶子节点,这个细节问题大家可以注意一下。)!如果访问的页面特别多,而Buffer Pool
又不能全部容纳它们的话,这就意味着需要将其他语句在执行过程中用到的页面移出Buffer Pool
,之后在其他语句重新执行时,又需要将需要用到的页重新从磁盘加载到Buffer Pool
中(这就像我在饭店吃饭吃了一半,忽然来了一群人把我的饭菜收走,然后把我从饭店赶了出去,等他们吃完后我又得进去重新点菜吃)
我们在业务中一般对很大的表不会进行全表扫描,因为对很大的表进行全表扫描可能要把Buffer Pool
中的缓存页换一次,这严重的影响到其他查询对 Buffer Pool
的使用,从而大大降低了Buffer Pool
命中率。
综上所述,可能降低Buffer Pool命中率的两种情况如下:
- 加载到
Buffer Pool
中的页不一定被用到; - 如果有非常多的使用频率偏低的页被同时加载到
Buffer Pool
时,则可能会把那些使用频率非常高的页从Buffer Pool
中淘汰掉。
因为有这两种情况的存在,InnoDB
把这个LRU
链表按照一定比例分成两截:
- 一部分存储使用频率非常高的缓存页,所以这一部分链表也称为热数据,或者称为
young
区域。 - 另一部分存储使用频率不是很高的缓存页,这一部分链表也称为冷数据,或者称为
old
区域。
把示意图做了简化如下:
需要特别注意的是:我们是按照某个比例将LRU
链表分成两半的,而不是某些节点固定位于young
区域的,某些节点固定位于old
区域的,随着程序的运行,某个节点所属的区域也可能发生变化。这个划分成两截的比例怎么确定呢?对于InnoDB
存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct
的值来确定old
区域在LRU
链表中所占的比例,比方说这样:
show variables like 'innodb_old_blocks_pct'
从结果可以看出来,默认情况下old
区域在LRU
链表中所占的比例是37%
,也就是说old
区域大约占LRU
链表的3/8
。这个比例我们是可以设置的,我们可以在启动服务器时修改innodb_old_blocks_pct
启动选项来控制old
区域在LRU
链表中所占的比例,比方在配置文件中输入如下:
[server]
innodb_old_blocks_pct = 40
这样我们在启动服务器后,old
区域占LRU
链表的比例就是40%
。当然,在服务器运行期间也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量,所以我们要使用SET GLOBAL
命令来修改:
SET GLOBAL innodb_old_blocks_pct = 40;
有了这个被划分成young
和old
区域的LRU
链表之后,InnoDB
就可以针对我们上边提到的两种可能降低Buffer Pool
命中率的情况进行优化了。
(下面的2条优化项有点难以理解,需要大家多读2遍)
- 针对预读的页面的处理
InnoDB
规定,当磁盘上的某个页面在初次加载到Buffer Pool
中的某个缓存页时,该缓存页对应的控制块会被放到old
区域的头部。这样针对预读到Buffer Pool
但不进行后续访问的页面就会被逐渐从old
区域逐出,而不会影响young
区域中被使用比较频繁的缓存页。
- 全表扫描时,对短时间内访问大量使用频率非常低的页面进行优化
InnoDB
规定,每次去页面中读取一条记录时,都算是访问一次页面。
在进行全表扫描时,首次被加载到Buffer Pool
的页被放到了old
区域的头部,而一个页面中可能会包含很多条记录,读取完某个页面的记录就相当于访问了这个页面好多次,并且这些记录顺序访问时间间隔非常短,也就是InnoDB
会认为old
区域短时间内某一页被多次访问了。
这种情况是短时间内频繁访问同一页面,而后续不再访问。如果此时把这种页放到young
区域的头部,会把那些真正使用频率比较高的页面给顶下去,这样会降低Buffer Pool
命中率。(就像一个新玩具,前几天每天都玩,后续这个玩具放在一旁不再使用,那么不能认定你喜欢玩这个玩具)
所以我们规定,计算上次访问old
区域的时间戳和本次访问的时间戳如果小于某一时间间隔,该页面就不会被从old
区域移动到young
区域的头部,否则将它移动到young
区域的头部。每次访问old
区域后就刷新时间戳,方便下次计算时间间隔。
上述的这个间隔时间是由系统变量innodb_old_blocks_time
控制的,默认是1000 ms
。
show variables like 'innodb_old_blocks_time'
对于从磁盘上被加载到LRU
链表的old
区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s
,那么该页是不会被加入到young
区域的。很明显在一次全表扫描的过程中,多次访问一个页面(也就是读取同一页面中的多条记录)的时间不会超过1s
,
如果我们把innodb_old_blocks_time
的值设置为0
,那么每次我们访问一个页面时就会把该页面放到young
区域的头部。
综上所述
正是因为将LRU
链表划分为young
和old
区域这两个部分,又添加了innodb_old_blocks_time
这个系统变量,预读机制和全表扫描造成的Buffer Pool
命中率降低的问题才得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old
区域,而不影响young
区域中的缓存页。
2.6.4 进一步优化LRU链表
对于young
区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU
链表的头部,开销还是有点大,毕竟在young
区域的缓存页都是高热度的页,也就是可能被经常访问的,这样频繁的对LRU
链表进行节点移动操作不太好。
为了解决这个问题其实我们还可以提出一些优化策略,比如某个缓存页对应的节点在young
区域的前1/4
时,再次访问该缓存页时也不会将其移动到LRU
链表头部,这样就可以降低调整LRU
链表的频率,从而提升性能。(你可以认为young
区域的前1/4
是超高热度的页,后3/4
是普通热度的页,超高热度的页之间不必来回切换。)
注意: 上面
2.6.3
介绍随机预读的时候提到,如果Buffer Pool
中有某个区的13
个连续页面就会触发随机预读,这其实是不严谨的,其实还要求这13
个页面是超高热度的页面,也就是指的是这些页面在整个young
区域的头1/4
处。
只要从磁盘中加载一个页面到Buffer Pool
的一个缓存页中,该缓存页对应的控制块就会作为一个节点加入到LRU
链表中,这样一来,该缓存页对应的控制块就不在free
链表中了,flush链表中的节点(控制块)肯定也是LRU
链表中的节点。
文章参考自书籍《MySQL是怎样运行的》,后续本文将持续更新。