一.基本概念
1.概述
MySQL InnoBD引擎中的Buffer Pool(缓冲池)本质是MySQL数据库中的内存组件,也就是说Buffer Pool是一片内存数据结构。
在MySQL中一个Buffer Pool的默认大小为128MB,在实际生产环境中该默认值还是有些偏小,例如:如果数据库配置为16核32G的机器,Buffer Pool大小可以给2GB的内容,可以在MySQL的主配置文件中修改如下参数:
[server] innodb_buffer_pool_size = 2147483648 |
---|
2.数据页
对于MySQL等关系型数据库来说,核心数据模型是:表,字段,行等概念。在我们的认知中一个数据库中会有多张数据库表,每张数据库表有多个字段,一张数据库表中多行数据组成,而每行数据都有属于自己的字段值。当然这只是逻辑意义上的概念。
在Buffer Pool中,数据并不是一行行的排列在这个内存数据结构中的。实际上MySQL对数据抽象出一个数据页的概念,它会把很多行数据放在了一个数据页里,也就是说存储在磁盘文件中的数据是通过数据页也为单位进行组织的,一个磁盘文件对应多个数据页,每个数据页存储了多行数据。所以,数据页是MySQL中抽象出来的数据单位。
当更新一行数据时,数据库会找到这行数据所在的数据页,从磁盘文件中把这行数据所在的数据页加载到Buffer Pool中去。如下图:
![]() |
---|
3.缓存页
那么磁盘上的数据页和Buffer Pool中的数据是如何对应起来的呢?
当更新数据时,正常情况下需要从磁盘文件中把这条数据所对应的数据页加载到Buffer Pool中去。而Buffer Pool中存放的多个数据页叫做缓存页。
Buffer Pool中默认情况下,一个缓存页的大小和磁盘上的一个数据页的大小是一一对应的,均为是16KB。
4.缓存页元数据
每个缓存页都会对应一个描述信息,这个描述信息本身也是一块数据,它可以代表当前缓存页元数据,例如:当前缓存页所对应的数据页所属的表空间、数据页编号、当前缓存页在Buffer Pool中的地址信息等。
在Buffer Pool中,每个缓存页的描述数据放在最前面,然后各个缓存页放在后面,大概如下图所示:
![]() |
---|
【注意】Buffer Pool中缓存页的描述数据大小相当于缓存页大小的5%左右。估算每个描述数据大概为800个字节左右。假设Buffer Pool大小为128MB,实际上Buffer Pool真正的最终大小会超出一些,可能有个130MB左右的大小用于存放各个每个缓存页的描述数据。
二.内部数据结构
1.Buffer Pool初始化过程
(1)MySQL数据库启动之后,会按照设置的Buffer Pool大小(可能会比配置的大小稍微大一些)向OS申请一块内存区域作为Buffer Pool的内存区域。
(2)内存区域申请完毕之后,MySQL数据库就会按照默认的缓存页大小(16KB)以及每个缓存页对应的描述数据大小(约800个字节)在Buffer Pool中划分出来多个缓存页和分别对应的描述数据。
上述两个步骤完成之后,如下图所示:
![]() |
---|
此时Buffer Pool中缓存页内部都是没有任何数据占用的,当需要对数据执行增删改查的操作时,才会将数据对应的数据页从磁盘文件中读取并加载到Buffer Pool中的缓存页中。
2.free链表
【思考-1】随着增删改查操作的执行,就需要不断的从磁盘上读取对应的数据页放入Buffer Pool中对应的缓存页里中,并将数据进行缓存,方便后续对内存中的数据执行执行相关操作。 那么对于MySQL而言,在Buffer Pool中一个数据页对应一个缓存页,当时不断加载时,空闲的缓存页会被耗尽,当需要加载新的缓存页时,执行引擎是如何知道哪些缓存页是空闲的呢?
在Buffer Pool中会有一个双向链表数据结构的free链表。在这个链表中存储的每个节点是一个空闲缓存页的描述数据块的地址。只要存在一个缓存页是空闲的,此时对应的描述数据块就会被放入这个free链表中。
当数据库启动时,在没有数据交互的时间点中所有的缓存页都是空闲的,此时代表这个一个空数据库,那么所有缓存页的描述数据块将都会被放入这个free链表中。如下图所示:
![]() |
---|
在free链表会有一个基础节点引用链表的头节点和尾节点,这个节点找中会存储了当前链表中空间的存储页描述数据块的节点个数。
【思考-2】如何将磁盘上的数据页读取到Buffer Pool的缓存页中去?
①首先从free链表中里获取一个描述数据块,这样就可以获取到这个描述数据块对应的空闲缓存页。
②接下来将磁盘上的数据页读取到对应的缓存页中去。
③然后将相关的一些描述数据写入缓存页的描述数据块中去,比如:当前数据页所属的表空间等信息。
④最后将步骤①中的描述数据块从free链表中移除即可。
整个过程大致如下图:
![]() |
---|
3.数据页缓存哈希表
【思考】MySQL如何知道一个数据页是否被缓存?
在执行增删改查时,首先需要知道当前这个数据页是否被缓存,如果没有被缓存就需要执行上述的步骤;如果数据页已经被缓存,就直接使用即可。
所以此时还需要一个哈希表数据结构用于存储已经被占用的缓存页地址。这个哈希表的key采用表空间号和数据页号组成,value存储缓存页地址。
当需要使用一个数据页时,可以通过表空间号以及数据页号作为key去这个哈希表中进行查询,如果已经有了,说明数据页已被缓存。
当每次读取一个新的数据页到缓存之后,都需要将对应的缓存页相关信息写入这个哈希表中。如果后续继续使用这个数据页时,就可以从哈希表中直接读取已经被缓存的缓存页所以对应的数据页。如下图所示:
![]() |
---|
4.flush链表
当需要更新数据时,其实就是更新Buffer Pool中缓存页里的数据,这个操作是内存级别的。一旦更新了缓存页中的数据,那么缓存页里的数据和磁盘上的数据页里的数据是不一致的,所以我们称这个缓存页对应的数据为“脏数据”,而这个缓存页被称之为脏页。
【思考】所有的脏页最终在某个时机都需要被刷新回磁盘文件持久化,但不可能在同一时间点Buffer Pool中所有缓存页都刷回磁盘的,可能存在某些缓存页因为查询时被读到Buffer Pool中,但之后再也没修改过。那么在这么多的缓存中如何快速的知道哪些是脏页呢?
在InnoDB执行引擎中引入了另外一种数据结构就是flush链表,它本质也是通过缓存页的描述数据块中的两个指针,让被修改过的缓存页的描述数据块组成一个双向链表。
只要被修改过的缓存页都会将其描述数据块加入到flush链表中,所以flush链表中存储了哪些缓存页为脏页,这些脏页在后续都需要flush刷新到磁盘上文件中的。如下图所示:
![]() |
---|
从上图页可以看出flush链表的结构和free链表大致是一样的。
三.LRU深度分析
1.场景引入
【思考】当执行增删改查操作时,实际上都会把磁盘中的数据页加载到Buffer Pool缓存页中。每加载一个缓存页都需要消耗free链表中的一个空闲缓存页描述数据块,随着不断的如此往复会使得free链表中的空闲缓存页越来越少?那么free链表被完全耗尽没有一个空闲缓存页时怎么办呢?
当出现上述情况时,则表示此时无法从磁盘上加载新的数据页到Buffer Pool中的缓存页了。所以只有一个办法就是:淘汰部分缓存页。
被淘汰的缓存页只有一种选择就是将其刷写到磁盘文件中,接着这个缓存页就可以清空,让它重新变成一个空闲的缓存页,并加入到free链表中。
【思考】应该把什么样的缓存页的数据刷入磁盘呢?
此时需要引入LRU链表来解决这个问题。LRU(最近最少使用Least Recently Used)链表用来知道哪些缓存页是最近最少被使用的。当需要腾出一个缓存页的时候,就可以选择这个链表中最近最少被使用的缓存页了。
2.工作原理
当从磁盘加载一个数据页到缓存页的时候,就把这个缓存页的描述数据块放到LRU链表头部。只要有数据的缓存页都会在LRU链表中,而且最近被加载数据的缓存页都会放到这个链表的头部。
假设当前某个缓存页的描述数据块在LRU链表尾部,后续只要查询或者修改了这个缓存页中的数据,此时需要把这个缓存页挪移动到LRU链表的头部,这样就是代表了当前这个歌缓存页是最近被访问过的缓存页,如下图所示:
![]() |
---|
3.LRU痛点分析
LRU的思想固然很好,但是该机制在MySQL实际运行过程中是会存在非常严重的问题。下面主要分析两种隐患。
3.1预读机制
MySQL中的预读机制会在从磁盘上加载一个数据页的时候,可能会连带着把这个数据页相邻的其他数据页也加载到缓存中去。
举个例子:假设现在有两个空闲缓存页,然后在加载一个数据页时,连带着把他的一个相邻的数据页也加载到缓存里去。但实际上只有一个缓存页是被访问了,另外一个通过预读机制加载的缓存页其实并没有被访问,此时这两个缓存页可都在LRU链表的头部,如下图所示:
![]() |
---|
上图中第二个缓存页是通过预读机制捎带着加载进来的,也位于链表的前部分区域,但问题是实际上这个缓存页并有被访问,导致了不能满足最近最少使用的原则。
除了第二个缓存页之外,第一个缓存页以及链表末端的两个缓存页实际上都是最近可能一直被访问的缓存页。此时,一旦没有空闲缓存页,就需要从LRU链表的尾部把所谓的“最近最少使用”的那个缓存页进行落盘操作,这显然也是不合理的。按照LRU的理论,应该是把第二个通过预读机制加载进来的缓存页进行刷磁和清空操作,但是此时却没有办法进行这样的操作。
预读机制设计的根本目的是提升性能。假设读取了数据页01到缓存页里去,那么接下来有可能会继续顺序读取数据页01相邻的数据页02到缓存页里去,此时预读机制设计以及减少磁盘IO的次数。
再假设如果在一个区内,顺序读取了非常多的数据页,比如数据页01至数据页56都被依次顺序读取了。此时MySQL可能会判断用户可能会接着会继续顺序读取后面的数据页,所以提前把后续的一大堆数据页,比如数据页57~数据页72都读取到Buffer Pool中,在后续读取数据页60时就可以直接从Buffer Pool中获取数据了。
在MySQL中有如下几种情况会触发预读机制。
(1)参数innodb_read_ahead_threshold(默认值:56)代表如果顺序的访问了一个区里的多个数据页,当访问的数据页数量超过了这个阈值,此时就会触发预读机制,将下一个相邻区中的所有数据页都加载到Buffer Pool中。
(2)如果Buffer Pool中缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁会被访问的,此时就会直接触发预读机制,将这个区里的其他的数据页都加载到缓存里中。这个机制是通过参数innodb_random_read_ahead(默认值:OFF)进行来控制的。
所以默认情况下,上述第一种情况发生的概率较大。一旦触发这个机制,会将很多相邻区里的数据页加载到缓存里去,并且进入LRU链表靠近头部的区域,反而会导致一些可能最近被频繁访问的缓存页被刷盘并清空。
3.2全表扫描
全表扫描,就是执行了类似带有“SELECT *”的SQL语句。因为没有任何过滤筛选条件,会导致将这个表里所有的数据页都从磁盘加载到Buffer Pool中。
可能出现的一种情况就是:全表扫描之后几乎再也没用到这个表里的数据,造成此时LRU链表的尾部可能全部都是之前一直被高频访问的缓存页被太早的刷盘并清空。
4.LRU优化
为了解决LRU链表在MySQL实际运行时的问题,InnoDB执行引擎在设计LRU链表时候采取了冷热数据分离的思想。
它会被LRU链表拆分为两个部分,即热数据区和冷数据区。冷热数据的比例是由参数innodb_old_blocks_pct参数控制的,默认配置为37,代表了冷数据占比37%。如下图所示:
![]() |
---|
数据页第一次被加载到缓存时,将该缓存页放在冷数据区域的链表头部,如下图:
![]() |
---|
【思考】冷数据区域的缓存页什么时候会被放入到热数据区域?
第一次被加载了数据的缓存页都会不停的移动到冷数据区域的链表头部。按照正常的解决思路可能会是:只要对冷数据区域的缓存页进行了一次访问就立即把这个缓存页放到热数据区域的头部,如下图所示:
![]() |
---|
其实这样也是不合理的,假设刚加载了一个数据页到某个缓存页,此时这个缓存页会在冷数据区域的链表头部,如果1毫秒之后就访问了这个缓存页,之后就再也不访问了,那么这样的缓存页也放到个热数据区,显示这个缓存页不是很“热”。
所以在这种情况下MySQL设定了一个规则,可以通过参数innodb_old_blocks_time(,默认值1000毫秒)参数进行配置。该参数表示一个数据页被加载到缓存页之后,在默认情况下1s之后再访问这个缓存页,此时才会被移动到热数据区域的链表头部。
这种冷热数据分离的设计对之前的问题是如何解决的呢?
①对于预读机制和全表扫描加载进来的多个缓存页会放在LRU链表的冷数据区域的前面。假设此时热数据区域已经有很多被频繁访问的缓存页,对于热数据区域来说仍然存放被频繁访问的缓存页,只要热数据区域有缓存页被访问,他还是会被移动到热数据区域的链表头部去。
那么通过预读机制和全表扫描加载到Buffer Pool中并且被低频访问的缓存页来说此时都在冷数据区域里,和热数据区域里的频繁访问的缓存页是相互隔离的。
②对于一个全表扫描的查询来说在1s内把多个缓存页加载进来,接着然后就访问了这些缓存页,通常根据配置innodb_old_blocks_time在这个时间点内被访问的缓存页是不会从冷数据区域转移到热数据区域的。除非在冷数据区域里的缓存页,进过innodb_old_blocks_time配置的时间之外被访问了,此时会判定为未来可能会被频繁访问的缓存页,接着移动到热数据区域的链表头部。
③当缓存页不够用了,需要淘汰一些缓存页,此时存在于LRU链表中的冷数据区域的尾部的缓存页就会直接被淘汰并且刷入磁盘。
【思考】在LRU链表热数据区域中,当访问了一个缓存页,是立刻将其移动到热数据区域的链表头部吗?如下图所示:
![]() |
---|
对于热数据区域中的缓存页可能是经常被访问的,所以如此频繁的进行移动对于性能来说也是不好的。
在LRU链表热数据区域的访问规则也有一个优化点,即只有在热数据区域的后3/4部分的缓存页被访问了才会移动到链表头部去。如果位于热数据区域的前面1/4的缓存页被访问不会移动到链表头部去的。
这样做的好处是:尽可能的减少LRU链表中的节点移动。
【总结】MySQL InnoDB对于LRU的优化:
①通过参数innodb_old_blocks_pct设置链表冷热数据分离比例,做到冷热数据分离。
②通过参数innodb_old_blocks_time设置冷数据升级为热数据的规则。
③热数据区域后3/4部分的缓存页被访问了才会移动到链表头部,减少链表中节点频繁移动造成的性能下降。
5.淘汰策略
MySQL对于脏页数据的持久化时机主要如下:
(1)定时从**LRU尾部的部分缓存页刷入磁盘**
执行引擎并不是在缓存页满的时候才会去LRU冷数据区域尾部的多个缓存页进行刷盘,而是会通过一个后台线程中的定时任务,每隔一段时间就会把LRU链表的冷数据区域的尾部的一些缓存页进行刷盘,接着再把他们的描述数据块放回到free链表中,从flush链表中移除,从LRU链表中移除。如下图所示:
![]() |
---|
(2)对**flush链表中部分缓存页定时刷入磁盘
执行引擎会在MySQL不太繁忙的时候,将flush链表中的缓存页都刷入磁盘中,这样被修改过的脏数据会尽可能的刷磁持久化。
当flush链表中的缓存页被刷入了磁盘,这些缓存页也会从flush链表和LRU链表中移除,然后重新加入到free链表中。
(3)实在找不到空闲缓存页如何处理?**
从LRU链表的冷数据区域的尾部找到一个缓存页,将其刷盘和清空,相关操作同策略一。
四.生产经验与最佳实践
1.通过多个Buffer Pool优化数据库并发性能
【思考】Buffer Pool的本质就是一大块内存数据结构,由多个缓存页和其描述数据块组成,另外配套的各种链表数据结构(free、flush、LRU)来辅助执行引擎的运行。
当MySQL同时接收到多个请求,会用多个线程来处理这多个请求,每个线程会负责处理一个请求。但是多个线程最终还是同时去访问相同的Buffer Pool呢,包括需要操作相同的数据结构。如下图:
![]() |
---|
对于多线程并发访问同一个Buffer Pool必然是要加锁,让多个线程串行化,这样对与数据库的性能好吗?
其实对于同一个Buffer Pool,即使有多个线程会加锁串行着排队执行,性能也还好。因为大部分情况下,每个线程都是查询或者更新缓存页里的数据,这个操作是内存级别的,操作free、flush、LRU这些链表在大量的测试下基本都是微秒级,所以性能想对来说还不错。
但是理论上来说,每个线程加锁串行化排队执行,这种方案终究还是不好的。有时候在竞争到锁之后可能还要从磁盘里读取数据页加载到Buffer Pool缓存页中,这需要耗费一次磁盘IO操作。当面对超大流量情况下,肯定还是表现不佳。
在MySQL生产环境可以给设置多个Buffer Pool来提高数据库并发能力。一般来说,MySQL默认规则是:如果Buffer Pool分配的内存小于1GB,那么最多就只会设置一个Buffer Pool。
如果生产环境机器内存很大,可以考虑会给Buffer Pool分配较大内存,比如配置为8G。那么此时可以设置多个Buffer Pool,此时需要在MySQL服务器端进行如下配置:
[server] innodb_buffer_pool_size = 8589934592 innodb_buffer_pool_instances = 4 |
---|
上面这段配置表示:Buffer Pool内存设置为8GB,同时配置了4个Buffer Pool,此每个Buffer Pool内存大小为2GB。
这样每个Buffer Pool就可以负责管理一部分的缓存页和描述数据块,并且拥有自己独立的free、flush、LRU等数据结构。当多个请求到来时就可以打到不同的Buffer Pool上来提升数据库并发性能。
2.通过chunk支持数据库运行期Buffer Pool动态调整
MySQL通过chunk机制可以吧Buffer Pool拆小,也就是说Buffer Pool是由很多chunk组成的。chunk的大小是通过参数innodb_buffer_pool_chunk_size(默认值:128MB)进行控制的。
举个例子:假设给Buffer Pool设置总大小为8GB,配置4个Buffer Pool,那么每个Buffer Pool大小为2GB。此时每个Buffer Pool是由一系列的128MB大小的chunk组成,也就是说每个Buffer Pool会有16个chunk。然后每个Buffer Pool里中的每个chunk就是一系列的描述数据块和缓存页,在每个Buffer Pool中的多个chunk共享一套free、flush、LRU数据结构。如下图所示:
![]() |
---|
【思考】chunk机制是如何支持运行期间动态调整Buffer Pool大小呢?
比如Buffer Pool总大小为8GB,现在需要动态扩容至16GB,那么此时只要申请一系列的128MB大小的chunk就可以了,只要每个chunk是连续的128MB内存就可以。然后把这些申请到的chunk内存分配给Buffer Poo就行了。这样的好处是:此时并不需要额外申请16GB的连续内存空间,然后还要把已有的数据进行拷贝。
3.基于机器配置设置合理的Buffer Pool
对于研发来说可能会想,如果线上服务器有32G内存,那么给Buffer Pool设置30GB内容,这样大量的操作都是内存级别的,性能一定会不错。
其实一般而言这样的方法论是不合理的。因为操作系统内核可能就需要几个GB的内存。甚至这台机器上可能还有别服务或者进程在运行。所以设置一个超大内存给Buffer Pool反而会舍得其反。甚至可能会因为内存吃紧导致MySQL启动失败。
最佳实践来说,如果内存资源充足,需要追求相对高性能可以给Buffer Pool设置机器内存的50%~60%左右,这些相对合理些。
在确定了Buffer Pool内存总大小之后,就需要考虑设置多少个Buffer Pool以及chunk的大小。最佳实践是:Buffer Pool总大小 =(chunk大小 * Buffer Pool数量)的倍数。
比如默认的chunk大小为128MB,那么如果机器的内存是32GB,预计可以给Buffer Pool总大小在20GB左右。假设Buffer Pool数量为16个,那么此时chunk大小 Buffer Pool的数量= 16 128MB = 2048MB。
然后Buffer Pool总大小如果是20GB,此时Buffer Pool总大小为2048MB的10倍,基本上就差不多了。
当然,也可以设置多一些Buffer Pool数量,比如设置32个Buffer Pool,那么此时Buffer Pool总大小(20GB)就是(chunk大小128MB * 32个buffer pool)的5倍,也是可以的。这样每个Buffer Pool大小是640MB,然后每个Buffer Pool包含5个128MB的chunk。
4.如何查看InnoDB执行引擎的基本情况
在MySQL中可以通过如下命令查看InnoDB的基本情况:
SHOW ENGINE INNODB STATUS |
---|
返回结果如下:
Total memory allocated xxxx; Dictionary memory allocated xxx Buffer pool size xxxx Free buffers xxx Database pages xxx Old database pages xxxx Modified db pages xx Pending reads 0 Pending writes: LRU 0, flush list 0, single page 0 Pages made young xxxx, not young xxx xx youngs/s, xx non-youngs/s Pages read xxxx, created xxx, written xxx xx reads/s, xx creates/s, 1xx writes/s Buffer pool hit rate xxx / 1000, young-making rate xxx / 1000 not xx / 1000 Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s LRU len: xxxx, unzip_LRU len: xxx I/O sum[xxx]:cur[xx], unzip sum[16xx:cur[0] |
---|
(1)Total memory allocated表示Buffer Pool最终总大小。
(2)Buffer pool size表示Buffer Pool一共能容纳多少个缓存页。
(3)Free buffers表示free链表中一共有多少个空闲的缓存页是可用的。
(4)Database pages和Old database pages表示就LRU链表中一共有多少个缓存页,以及冷数据区域里的缓存页数量。
(5)Modified db pages表示flush链表中的缓存页数量,即当前有多少个脏页。
(6)Pending reads和Pending writes表示等待从磁盘上加载进缓存页的数量,以及即将从LRU链表中刷入磁盘的数量、即将从flush链表中刷入磁盘的数量。
(7)Pages made young和not young表示已经LRU冷数据区域里访问之后转移到热数据区域的缓存页的数量,以及在LRU冷数据区域里默认值情况下1s内被访问了没进入热数据区域的缓存页的数量。
(8)youngs/s和not youngs/s表示每秒从冷数据区域进入热数据区域的缓存页的数量,以及每秒在冷数据区域里被访问了但是不能进入热数据区域的缓存页的数量。
(9)Pages read xxxx, created xxx, written xxx,xx reads/s, xx creates/s, 1xx writes/s,表示已经读取、创建和写入了多少个缓存页,以及每秒钟读取、创建和写入的缓存页数量。
(10)Buffer pool hit rate xxx / 1000表示每1000次访问有多少次是直接命中了buffer pool里的缓存的。
(11)young-making rate xxx / 1000 not xx / 1000表示没1000次访问中有多少次访问让缓存页从冷数据区域移动到了热数据区域,以及没移动的缓存页数量。
(12)LRU len表示LRU链表中的缓存页的数量。
(13)I/O sum表示最近50s读取磁盘页的总数。
(14)I/O cur表示现在正在读取磁盘页的数量。