本篇文章给大家带来了mysql中关于Buffer pool的相关知识,其中包括了数据页、缓存页free链表、 flush链表、 LRU链表Chunk等等,希望对大家有帮助。 通过前边的唠叨我们知道,对于使用 设计 其中, 每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个 咦?控制块和缓存页之间的那个 小贴士: 每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。 当我们最初启动 从图中可以看出,我们为了管理好这个 小贴士: 链表基节点占用的内存空间并不大,在MySQL5.7.21这个版本里,每个基节点只占用40字节大小。后边我们即将介绍许多不同的链表,它们的基节点和free链表的基节点的内存分配方式是一样一样的,都是单独申请的一块40字节大小的内存空间,并不包含在为Buffer Pool申请的一大片连续内存空间之内。 有了这个 我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到 再回头想想,我们其实是根据 小贴士: 啥?你别告诉我你不知道哈希表是个啥?我们这个文章不是讲哈希表的,如果你不会那就去找本数据结构的书看看吧~ 啥?外头的书看不懂?别急,等我~ 所以我们可以用 如果我们修改了 但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 缓存不够的窘境 为了回答这个问题,我们还需要回到我们设立 简单的LRU链表 管理 如果该页不在 如果该页已经缓存在 也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 划分区域的LRU链表 高兴的太早了,上边的这个简单的 情况一: 线性预读 设计 小贴士: InnoDB是怎么实现异步读取的呢?在Windows或者Linux平台上,可能是直接调用操作系统内核提供的AIO接口,在其它类Unix操作系统中,使用了一种模拟AIO接口的方式来实现异步读取,其实就是让别的线程去读取需要预读的页面。如果你读不懂上边这段话,那也就没必要懂了,和我们主题其实没太多关系,你只需要知道异步读取并不会影响到当前工作线程的正常执行就好了。其实这个过程涉及到操作系统如何处理IO以及多线程的问题,找本操作系统的书看看吧,什么?操作系统的书写的都很难懂?没关系,等我~ 随机预读 如果 情况二:有的小伙伴可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有WHERE子句的查询)。 扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的 总结一下上边说的可能降低 加载到 如果非常多的使用频率偏低的页被同时加载到 因为有这两种情况的存在,所以设计 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做 为了方便大家理解,我们把示意图做了简化,各位领会精神就好: 大家要特别注意一个事儿:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于 从结果可以看出来,默认情况下, 这样我们在启动服务器后, 有了这个被划分成 针对预读的页面可能不进行后续访问情况的优化 设计 针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化 在进行全表扫描时,虽然首次被加载到 咋办?全表扫描有一个特点,那就是它的执行频率非常低,谁也不会没事儿老在那写全表扫描的语句玩,而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。所以我们只需要规定,在对某个处在 这个 综上所述,正是因为将 更进一步优化LRU链表 小贴士: 我们之前介绍随机预读的时候曾说,如果Buffer Pool中有某个区的13个连续页面就会触发随机预读,这其实是不严谨的(不幸的是MySQL文档就是这么说的[摊手]),其实还要求这13个页面是非常热的页面,所谓的非常热,指的是这些页面在整个young区域的头1/4处。 还有没有什么别的针对缓存的重要性
InnoDB
作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页
的形式存放在表空间
中的,而所谓的表空间
只不过是InnoDB
对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。但是各位也都知道,磁盘的速度慢的跟乌龟一样,怎么能配得上“快如风,疾如电”的CPU
呢?所以InnoDB
存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存
起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO
的开销了。InnoDB的Buffer Pool
啥是个Buffer Pool
InnoDB
的大叔为了缓存磁盘中的页,在MySQL
服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool
(中文名是缓冲池
)。那它有多大呢?这个其实看我们机器的配置,如果你是土豪,你有512G
内存,你分配个几百G作为Buffer Pool
也可以啊,当然你要是没那么有钱,设置小点也行呀~ 默认情况下Buffer Pool
只有128M
大小。当然如果你嫌弃这个128M
太大或者太小,可以在启动服务器的时候配置innodb_buffer_pool_size
参数的值,它表示Buffer Pool
的大小,就像这样:[server] innodb_buffer_pool_size = 268435456
268435456
的单位是字节,也就是我指定Buffer Pool
的大小为256M
。需要注意的是,Buffer Pool
也不能太小,最小值为5M
(当小于该值时会自动设置成5M
)。Buffer Pool内部组成
Buffer Pool
中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB
。为了更好的管理这些在Buffer Pool
中的缓存页,设计InnoDB
的大叔为每一个缓存页都创建了一些所谓的控制信息
,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool
中的地址、链表节点信息、一些锁信息以及LSN
信息(锁和LSN
我们之后会具体唠叨,现在可以先忽略),当然还有一些别的控制信息,我们这就不全唠叨一遍了,挑重要的说嘛~控制块
吧,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool
对应的内存空间看起来就是这样的:碎片
是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片
了。当然,如果你把Buffer Pool
的大小设置的刚刚好的话,也可能不会产生碎片
~
free链表的管理
MySQL
服务器的时候,需要完成对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
申请的一大片连续内存空间之内,而是单独申请的一块内存空间。
free链表
之后事儿就好办了,每当需要从磁盘中加载一个页到Buffer Pool
中时,就从free链表
中取一个空闲的缓存页,并且把该缓存页对应的控制块
的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表
节点从链表中移除,表示该缓存页已经被使用了~缓存页的哈希处理
Buffer Pool
中,如果该页已经在Buffer Pool
中的话直接使用就可以了。那么问题也就来了,我们怎么知道该页在不在Buffer Pool
中呢?难不成需要依次遍历Buffer Pool
中各个缓存页么?一个Buffer Pool
中的缓存页这么多都遍历完岂不是要累死?表空间号 + 页号
来定位一个页的,也就相当于表空间号 + 页号
是一个key
,缓存页
就是对应的value
,怎么通过一个key
来快速找着一个value
呢?哈哈,那肯定是哈希表喽~
表空间号 + 页号
作为key
,缓存页
作为value
创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号
看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表
中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。flush链表的管理
Buffer Pool
中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页
(英文名:dirty page
)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,至于这个同步的时间点我们后边会作说明说明的,现在先不用管哈~Buffer Pool
中哪些页是脏页
,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如Buffer Pool
被设置的很大,比方说300G
,那一次性同步这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表
。链表的构造和free链表
差不多,假设某个时间点Buffer Pool
中的脏页数量为n
,那么对应的flush链表
就长这样:LRU链表的管理
Buffer Pool
对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool
大小,也就是free链表
中已经没有多余的空闲缓存页的时候岂不是很尴尬,发生了这样的事儿该咋办?当然是把某些旧的缓存页从Buffer Pool
中移除,然后再把新的页放进来喽~ 那么问题来了,移除哪些缓存页呢?Buffer Pool
的初衷,我们就是想减少和磁盘的IO
交互,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool
中了。假设我们一共访问了n
次页,那么被访问的页已经在缓存中的次数除以n
就是所谓的缓存命中率
,我们的期望就是让缓存命中率
越高越好~ 从这个角度出发,回想一下我们的微信聊天列表,排在前边的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?废话,当然是留下最近很频繁使用的了~Buffer Pool
的缓存页其实也是这个道理,当Buffer Pool
中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?呵呵,神奇的链表再一次派上了用场,我们可以再创建一个链表,由于这个链表是为了按照最近最少使用
的原则去淘汰缓存页的,所以这个链表可以被称为LRU链表
(LRU的英文全称:Least Recently Used)。当我们需要访问某个页时,可以这样处理LRU链表
:
Buffer Pool
中,在把该页从磁盘加载到Buffer Pool
中的缓存页时,就把该缓存页对应的控制块
作为节点塞到链表的头部。Buffer Pool
中,则直接把该页对应的控制块
移动到LRU链表
的头部。LRU链表
的头部,这样LRU链表
尾部就是最近最少使用的缓存页喽~ 所以当Buffer Pool
中的空闲缓存页使用完时,到LRU链表
的尾部找些缓存页淘汰就OK啦,真简单,啧啧…LRU链表
用了没多长时间就发现问题了,因为存在这两种比较尴尬的情况:
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
。预读
本来是个好事儿,如果预读到Buffer Pool
中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到LRU
链表的头部,但是如果此时Buffer Pool
的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表
尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币
,会大大降低缓存命中率。页
,当需要访问这些页时,会把它们统统都加载到Buffer Pool
中,这也就意味着吧唧一下,Buffer Pool
中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool
的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool
中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool
的使用,从而大大降低了缓存命中率。Buffer Pool
的两种情况:
Buffer Pool
中的页不一定被用到。Buffer Pool
时,可能会把那些使用频率非常高的页从Buffer Pool
中淘汰掉。InnoDB
的大叔把这个LRU链表
按照一定比例分成两截,分别是:
热数据
,或者称young区域
。冷数据
,或者称old区域
。InnoDB
存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct
的值来确定old
区域在LRU链表
中所占的比例,比方说这样:mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_pct'; +-----------------------+-------+ | Variable_name | Value | +-----------------------+-------+ | innodb_old_blocks_pct | 37 | +-----------------------+-------+ 1 row in set (0.01 sec)
old
区域在LRU链表
中所占的比例是37%
,也就是说old
区域大约占LRU链表
的3/8
。这个比例我们是可以设置的,我们可以在启动时修改innodb_old_blocks_pct
参数来控制old
区域在LRU链表
中所占的比例,比方说这样修改配置文件:[server] innodb_old_blocks_pct = 40
old
区域占LRU链表
的比例就是40%
。当然,如果在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量
,一经修改,会对所有客户端生效,所以我们只能这样修改:SET GLOBAL innodb_old_blocks_pct = 40;
young
和old
区域的LRU
链表之后,设计InnoDB
的大叔就可以针对我们上边提到的两种可能降低缓存命中率的情况进行优化了:
InnoDB
的大叔规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool
却不进行后续访问的页面就会被逐渐从old
区域逐出,而不会影响young
区域中被使用比较频繁的缓存页。Buffer Pool
的页被放到了old
区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到young
区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。有同学会想:可不可以在第一次访问该页面时不将其从old
区域移动到young
区域的头部,后续访问时再将其移动到young
区域的头部。回答是:行不通!因为设计InnoDB
的大叔规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。old
区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time
控制的,你看:mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_time'; +------------------------+-------+ | Variable_name | Value | +------------------------+-------+ | innodb_old_blocks_time | 1000 | +------------------------+-------+ 1 row in set (0.01 sec)
innodb_old_blocks_time
的默认值是1000
,它的单位是毫秒,也就意味着对于从磁盘上被加载到LRU
链表的old
区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s
(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s
),那么该页是不会被加入到young
区域的~ 当然,像innodb_old_blocks_pct
一样,我们也可以在服务器启动或运行时设置innodb_old_blocks_time
的值,这里就不赘述了,你自己试试吧~ 这里需要注意的是,如果我们把innodb_old_blocks_time
的值设置为0
,那么每次我们访问一个页面时就会把该页面放到young
区域的头部。LRU
链表划分为young
和old
区域这两个部分,又添加了innodb_old_blocks_time
这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old
区域,而不影响young
区域中的缓存页。LRU链表
这就说完了么?没有,早着呢~ 对于young
区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表
的头部,这样开销是不是太大啦,毕竟在young
区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表
进行节点移动操作是不是不太好啊?是的,为了解决这个问题其实我们还可以提出一些优化策略,比如只有被访问的缓存页位于young
区域的1/4
的后边,才会被移动到LRU链表
头部,这样就可以降低调整LRU链表
的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young
区域的1/4
中,再次访问该缓存页时也不会将其移动到LRU
链表头部)。
LRU链表
的优化措施呢?当然有啊,你要是好好学,写篇论文,写本书都不是问题,可是这毕竟是一个介绍MySQL
基础知识的文章,再说多了篇幅就受不了了,也影响大家的阅读体验,所以适可而止,想了解