《MySQL是怎样运行的 —— 从跟上理解MySQL》—— 第十七章
一、Buffer Pool简介
为了缓存磁盘中的页,InnoDB
在MySQL
服务器启动的时候就向操作系统申请了一片连续的内存,这片内存叫做Buffer Pool
(缓冲池)。
默认情况下Buffer Pool
只有128M
大小。可以在启动服务器的时候配置innodb_buffer_pool_size
参数的值:
[server] |
其中,268435456
的单位是字节,即指定Buffer Pool
的大小为256M
。需要注意的是,Buffer Pool
也不能太小,最小值为5M
(当小于该值时会自动设置成5M
)。
二、Buffer Pool内部组成
Buffer Pool
中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB
。为了更好的管理这些在Buffer Pool
中的缓存页,InnoDB
为每一个缓存页都创建了一些控制信息
,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool
中的地址、链表节点信息、一些锁信息以及LSN
信息等。
每个缓存页对应的控制信息占用的内存大小是相同的,每个页对应的控制信息占用的一块内存称为控制块,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前面,缓存页被存放到 Buffer Pool 后边:
每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的空间不够一对控制块和缓存页的大小,这个用不到的内存空间就被称为
碎片
。如果把
Buffer Pool
的大小设置的刚刚好的话,也可能不会产生碎片
。
每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。而设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。
三、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链表
,需要为这个链表定义了一个基节点
(包含着链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息,占用空间不大,大约40字节)。需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool
申请的一大片连续内存空间之内,而是单独申请的一块内存空间。
问题解决:每当需要从磁盘中加载一个页到Buffer Pool
中时,就从free链表
中取一个空闲的缓存页,并且把该缓存页对应的控制块
的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表
节点从链表中移除,表示该缓存页已经被使用了。
四、缓冲页的哈希处理
问题:需要访问某个页中的数据时,怎么知道该页在不在
Buffer Pool
中呢?
解决方案:用表空间号 + 页号
作为key
,缓存页
作为value
创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号
看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表
中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
我们其实是根据表空间号 + 页号
来定位一个页的,也就相当于表空间号 + 页号
是一个key
,因此使用哈希是非常高效的做法。
五、flush链表的管理
如果修改了Buffer Pool
中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页。
最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。
问题:如果不立即同步到磁盘的话,那之后再同步的时候怎么知道
Buffer Pool
中哪些页是脏页
,哪些页从来没被修改过呢?
解决方案:再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表
。
六、LRU链表的管理
6.1 缓冲区不够?
问题:
Buffer Pool
对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool
大小,即free链表
中已经没有多余的空闲缓存页的时候该如何处理?
解决:把某些旧的缓存页从Buffer Pool
中移除,然后再把新的页放进来。
问题:移除哪些缓存页呢?
回到设立Buffer Pool
的初衷:减少和磁盘的IO
交互,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool
中了。
假设我们一共访问了n
次页,那么被访问的页已经在缓存中的次数除以n
就是所谓的缓存命中率,期望就是让缓存命中率
越高越好。
6.2 简单版LRU链表
当Buffer Pool
中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。
问题:怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?
解决方案:再创建一个链表,由于这个链表是为了按照最近最少使用
的原则去淘汰缓存页的,所以这个链表可以被称为LRU链表
。
当我们需要访问某个页时,可以这样处理LRU链表
:
- 如果该页不在
Buffer Pool
中,在把该页从磁盘加载到Buffer Pool
中的缓存页时,就把该缓存页对应的控制块
作为节点塞到链表的头部。 - 如果该页已经缓存在
Buffer Pool
中,则直接把该页对应的控制块
移动到LRU链表
的头部。
也就是说:只要我们使用到某个缓存页,就把该缓存页调整到LRU链表
的头部,这样LRU链表
尾部就是最近最少使用的缓存页。所以当Buffer Pool
中的空闲缓存页使用完时,到LRU链表
的尾部找些缓存页淘汰即可。
6.3 划分区域的LRU链表
使用简单的LRU链表
存在两种比较尴尬的情况:
1)情况一: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链表
尾部的一些缓存页会很快的被淘汰掉,会大大降低缓存命中率。
2)情况二:执行全表扫描的查询语句
扫描全表意味着将访问到该表所在的所有页。假设这个表中记录非常多的话,那该表会占用特别多的页
,当需要访问这些页时,会把它们统统都加载到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] |
在服务器运行期间也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量
,一经修改,会对所有客户端生效:
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
控制的: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_time
的值设置为0
,那么每次我们访问一个页面时就会把该页面放到young
区域的头部。
总结:因为将LRU
链表划分为young
和old
区域这两个部分,又添加了innodb_old_blocks_time
这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old
区域,而不影响young
区域中的缓存页。
6.4 进一步优化LRU链表
问题:对于
young
区域的缓存页来说,每次访问一个缓存页就要把它移动到LRU链表
的头部,这样开销有点大,毕竟在young
区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表
进行节点移动操作是不太好的。
优化策略:只有被访问的缓存页位于young
区域的1/4
的后边,才会被移动到LRU链表
头部,这样就可以降低调整LRU链表
的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young
区域的1/4
中,再次访问该缓存页时也不会将其移动到LRU
链表头部)。
优化策略还有很多,最终目的就是:尽量高效的提高
Buffer Pool
的缓存命中率。
七、刷新脏页到磁盘
后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:
- 从
LRU链表
的冷数据中刷新一部分页面到磁盘。后台线程会定时从LRU链表
尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth
来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为BUF_FLUSH_LRU
。 - 从
flush链表
中刷新一部分页面到磁盘。后台线程也会定时从flush链表
中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为BUF_FLUSH_LIST
。
有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool
时没有可用的缓存页,这时就会尝试看看LRU链表
尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表
尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE
。
八、多个Buffer Pool实例
Buffer Pool
本质是InnoDB
向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool
中的各种链表都需要加锁处理什么的,在Buffer Pool
特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool
可能会影响请求的处理速度。所以在Buffer Pool
特别大的时候,可以把它们拆分成若干个小的Buffer Pool
,每个Buffer Pool
都称为一个实例
,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,独立的等等,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。
可以在服务器启动的时候通过设置innodb_buffer_pool_instances
的值来修改Buffer Pool
实例的个数:
[server] |
这样就表明要创建2个Buffer Pool
实例:
每个Buffer Pool
实例实际占多少内存空间:innodb_buffer_pool_size / innodb_buffer_pool_instances
⚠️注意:Buffer Pool
实例不是创建的越多越好,分别管理各个Buffer Pool
也是需要性能开销的,InnoDB
规定:当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances
的值修改为1。而鼓励在Buffer Pool
大小或等于1G的时候设置多个Buffer Pool
实例。
九、innodb_buffer_pool_chunk_size
在MySQL 5.7.5
之前,Buffer Pool
的大小只能在服务器启动时通过配置innodb_buffer_pool_size
启动参数来调整大小,在服务器运行过程中是不允许调整该值的。
MySQL
在5.7.5
以及之后的版本中支持了在服务器运行过程中调整Buffer Pool
大小的功能,但是有一个问题,就是每次重新调整Buffer Pool
大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool
中的内容复制到这一块新空间,这是极其耗时的。所以MySQL
决定不再一次性为某个Buffer Pool
实例向操作系统申请一大片连续的内存空间,而是以一个chunk
为单位向操作系统申请空间。即一个Buffer Pool
实例其实是由若干个chunk
组成的,一个chunk
就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块:
上图代表的Buffer Pool
就是由2个实例组成的,每个实例中又包含2个chunk
。
正是因为有了这个chunk
的概念,在服务器运行期间调整Buffer Pool
的大小时就是以chunk
为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。chunk
的大小是在启动操作MySQL
服务器时通过innodb_buffer_pool_chunk_size
启动参数指定的,它的默认值是134217728
,也就是128M
。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。
为什么不允许在服务器运行过程中修改innodb_buffer_pool_chunk_size的值?
因为innodb_buffer_pool_chunk_size的值代表InnoDB向操作系统申请的一片连续的内存空间的大小,如果在服务器运行过程中修改了该值,就意味着要重新向操作系统申请连续的内存空间并且将原先的缓存页和它们对应的控制块复制到这个新的内存空间中,这是十分耗时的操作!另外,innodb_buffer_pool_chunk_size的值并不包含缓存页对应的控制块的内存空间大小,所以实际上InnoDB向操作系统申请连续内存空间时,每个chunk的大小要比innodb_buffer_pool_chunk_size的值大一些,约5%。
十、配置Buffer Pool时的注意事项
1)innodb_buffer_pool_size
必须是innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances
的倍数(这主要是想保证每一个Buffer Pool
实例中包含的chunk
数量相同)。
假设指定innodb_buffer_pool_chunk_size
的值是128M
,innodb_buffer_pool_instances
的值是16
,那么这两个值的乘积就是2G
,也就是说innodb_buffer_pool_size
的值必须是2G
或者2G
的整数倍。如果指定的innodb_buffer_pool_size
大于2G
并且不是2G
的整数倍,那么服务器会自动的把innodb_buffer_pool_size
的值调整为2G
的整数倍。
2)如果在服务器启动时,innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances
的值已经大于innodb_buffer_pool_size
的值,那么innodb_buffer_pool_chunk_size
的值会被服务器自动设置为innodb_buffer_pool_size / innodb_buffer_pool_instances
的值。
十一、查看Buffer Pool的状态信息
MySQL
提供了SHOW ENGINE INNODB STATUS
语句来查看关于InnoDB
存储引擎运行过程中的一些状态信息,其中就包括Buffer Pool
的一些信息,
SHOW ENGINE INNODB STATUS\G |
每个值的意思:
Total memory allocated
:代表Buffer Pool
向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小。Dictionary memory allocated
:为数据字典信息分配的内存空间大小,注意这个内存空间和Buffer Pool
没什么关系,不包括在Total memory allocated
中。Buffer pool size
:代表该Buffer Pool
可以容纳多少缓存页
Free buffers
:代表当前Buffer Pool
还有多少空闲缓存页,也就是free链表
中还有多少个节点。Database pages
:代表LRU
链表中的页的数量,包含young
和old
两个区域的节点数量。Old database pages
:代表LRU
链表old
区域的节点数量。Modified db pages
:代表脏页数量,也就是flush链表
中节点的数量。Pending reads
:正在等待从磁盘上加载到Buffer Pool
中的页面数量。当准备从磁盘中加载某个页面时,会先为这个页面在Buffer Pool
中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到LRU
的old
区域的头部,但是这个时候真正的磁盘页并没有被加载进来,Pending reads
的值会跟着加1。Pending writes LRU
:即将从LRU
链表中刷新到磁盘中的页面数量。Pending writes flush list
:即将从flush
链表中刷新到磁盘中的页面数量。Pending writes single page
:即将以单个页面的形式刷新到磁盘中的页面数量。Pages made young
:代表LRU
链表中曾经从old
区域移动到young
区域头部的节点数量。需要注意,一个节点每次只有从old
区域移动到young
区域头部时才会将Pages made young
的值加1,也就是说如果该节点本来就在young
区域,由于它符合在young
区域1/4后边的要求,下一次访问这个页面时也会将它移动到young
区域头部,但这个过程并不会导致Pages made young
的值加1。Page made not young
:在将innodb_old_blocks_time
设置的值大于0时,首次访问或者后续访问某个处在old
区域的节点时由于不符合时间间隔的限制而不能将其移动到young
区域头部时,Page made not young
的值会加1。需要注意,对于处在young
区域的节点,如果由于它在young
区域的1/4处而导致它没有被移动到young
区域头部,这样的访问并不会将Page made not young
的值加1。youngs/s
:代表每秒从old
区域被移动到young
区域头部的节点数量。non-youngs/s
:代表每秒由于不满足时间限制而不能从old
区域移动到young
区域头部的节点数量。Pages read
、created
、written
:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率。Buffer pool hit rate
:表示在过去某段时间,平均访问1000次页面,有多少次该页面已经被缓存到Buffer Pool
了。young-making rate
:表示在过去某段时间,平均访问1000次页面,有多少次访问使页面移动到young
区域的头部了。需要注意的一点是,这里统计的将页面移动到young
区域的头部次数不仅仅包含从old
区域移动到young
区域头部的次数,还包括从young
区域移动到young
区域头部的次数(访问某个young
区域的节点,只要该节点在young
区域的1/4处往后,就会把它移动到young
区域的头部)。not (young-making rate)
:表示在过去某段时间,平均访问1000次页面,有多少次访问没有使页面移动到young
区域的头部。需要注意的一点是,这里统计的没有将页面移动到young
区域的头部次数不仅仅包含因为设置了innodb_old_blocks_time
系统变量而导致访问了old
区域中的节点但没把它们移动到young
区域的次数,还包含因为该节点在young
区域的前1/4处而没有被移动到young
区域头部的次数。LRU len
:代表LRU链表
中节点的数量。unzip_LRU
:代表unzip_LRU链表
中节点的数量(由于我们没有具体介绍过这个链表,现在可以忽略它的值)。I/O sum
:最近50s读取磁盘页的总数。I/O cur
:现在正在读取的磁盘页数量。I/O unzip sum
:最近50s解压的页面数量。I/O unzip cur
:正在解压的页面数量。