初识内存管理
因为Mysql的数据存储在磁盘,磁盘的读写速率远低于内存,但是我们执行一条SQL语句,其执行速率一般非常快,远高于磁盘的读写速率。这就不得不提今天要讲的主角-缓冲池(Buffer Pool), 简单来讲缓冲池就是内存的一块区域,主要用来高速缓冲数据和索引,正是因为缓冲池的引入,避免了磁盘的检索,才提高了SQL语句的执行速率。
何为Buffer Pool
MySQL执行的SQL语句需要对数据进行增删改查,其都是在内存中进行的,即在Buffer Pool中。
MySQL在启动之初,会对缓冲区进行初始化,这时期的缓冲区都是空闲页,也就是未使用的内存。随着MySQL Server不断地运行,会将磁盘中的数据页逐渐的加载到内存,磁盘中磁盘页和缓冲池中的数据页的大小都是16KB。因此,一次磁盘IO,会将读取的数据放入到内存的数据页中进行存储,其过程如下:
当磁盘的数据被加载到内存后,对读写数据的影响:
- 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则就会将磁盘页中的数据加载到缓冲区,然后再从缓冲区进行读取。
- 写数据时:如果数据存在于 Buffer Pool 中,那直接修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页(该页的内存数据和磁盘上的数据已经不一致),为了减少磁盘I/O,不会立即将脏页写入磁盘,而是后台线程按照一定的频率进行刷新,缓冲池会采用一种叫做checkpoint的机制将脏页数据回写到磁盘上,这样做的好处就是提升了数据库的整体性能。
到这里应该就可以解释为什么磁盘的读写速率很慢,但是执行一条SQL语句的速度非常快了,总结一下就是:
因为缓冲池的存在,极大的减少了磁盘IO的开销,否则一切性能则无从谈起。
当然上面磁盘的数据加载到内存过程表面看起来非常合理,但是设想一下,假设每次执行SQL的时候,数据都不在缓冲区,那么就需要每次都先从磁盘页进行读取,磁盘I/O次数过多,这当然是一件非常糟糕的事情,要知道MySQL的设计者当然已经为我们考虑到了这些问题,那么MySQL是如何做的呢?那么缓冲池的预读机制呼之欲出 ↩️
缓冲池的预读机制
MySQL利用两种机制来保障预读
- MySQL的局部性原理机制
通常意义来讲,当存储引擎进行磁盘I/O,需要读取某一页的数据,那么往往其附近区域的磁盘页也有可能被读取,主要原因是磁盘在写入数据时,都会将数据保存在一块连续的存储空间,因此不难理解MySQL在读取数据时,默认会使用局部性思想预读数据,通俗点讲就是磁盘I/O读取数据时,默认将其附近的一整页也就是16KB的数据全部加载至内存
存储引擎InnoDB的预读机制
预读的大致流程图:
MySQL进行读请求时,Server层将请求交给引擎层进行处理,而引擎层间接依赖于文件系统统的I/O读取机制
大致的步骤为:
- 为了保证请求数据的顺序性,所有的请求会会先放到请求队列中
- 数据准备完毕后,数据会被放到响应队列中
- 最终由MySQL的异步线程将响应队列中的数据进行读取,完成一次读取过程
- 异步线程不断地处理请求队列,当然,存储引擎非常聪明,会根据队列中的请求进行一系列判断,比如后面几个读请求的数据是否相邻,再根据自身的系统的I/O带宽进行预读,进行读请求的合并处理,一次性预读多页数据放在响应队列中,等待数据库读取
注意上面提到的进行读请求的合并处理,会一次性预读多页数据,那么究竟预读几页,什么情况下才会预读是我们需要考虑的问题,MySQL总共有两种策略:
注:extent(区,大小为64个数据页,每个数据页16KB,总共就是64 * 16 KB = 1024KB)默认为1M,关于MySQL的表结构存储,如段,页,区,表空间这些概念,之后会专门写一篇博客来谈论
- 线性预读:当前extent中的数据页读取达到一定数量时(通过innodb_read_ahead_threshold设置),触发预读直接读取下一个extent,显然,线性预读的单位是extent
- 随机预读:当前extent中的数据页达到一定数量时,触发预读同时会将剩余的数据页全部读入响应队列,显然,随机预读的单位是page
上面就是MySQL的预读机制,总结一下就是:
当MySQL服务端收到SELECT请求时,当一个extent的数据页读取达到一定数量时,InnoDB的预读机制会被触发,根据线性预读后者随机预读策略选择将下一个extent或者剩余的数据页全部读取到响应队列
缓冲池的预读机制失效问题
到这里其实也就解决了我们前面提到的如何降低磁盘I/O次数的问题,减少了I/O次数,并且因为预读机制的存在,后续的数据可以直接从内存中读取,提升了查询效率。当然,任何事情并不总是完美无瑕的,如果MySQL利用局部性原理或者InnoDB的预读机制提前加载的数据,在很长的一段时间内并没有被使用,那么缺点也是显而易见的,造成了MySQL内存资源的浪费和额外的I/O开销,这也就是常见的预读失效问题。
缓冲池的污染问题
由于缓冲池加载大量的数据页,导致大量的旧的热数据页被置换掉,MySQL的性能急剧下降,称为缓冲池污染
要解决预读机制失效问题和缓冲池污染问题就必须要对缓冲池中的数据页采用更为先进的管理策, 那么接下来要讲的一个问题是如何对缓冲池中加载的数据页进行管理。↩️
Buffer Pool如何对加载的数据页进行管理?
当然,对缓冲池中的数据页进行管理是亟需的工作,因为随着数据页不断地加载到缓冲池,缓冲池内存使用量不断地增加。如果不加以限制,最终内存耗尽,造成内存溢出等灾难性后果。那么就需要设计一种安全策略对缓冲池的数据进行淘汰,最容易想到的就是LRU算法。LRU(Least recently used)就是被广泛的应用在memcache、redis、guava Cache、OS等进行页面置换和回收的经典算法。
- 如果传统的LRU算法如何进行缓冲页管理?
大家很容易想到,按照末尾淘汰机制去管理数据页,LRU的数据结构采用双向链表,维护指向前后节点的指针。下面针对以下两种情况简单进行说明:
- 数据页在缓冲区的LRU链表中
- 数据页不在缓冲区的LRU链表中
假设缓冲区的LRU长度为10,缓冲了数据页编号为1,3,5...的页,如下图所示
针对情况1,假设现在需要访问的数据在数据页9,那么LRU算法会修改链表的指针引用,将当前访问的数据页移动到链表头部,此时无数据页淘汰,如下图所示
针对情况2,假设现在需要访问的数据在数据页40,那么LRU算法会修改链表的指针引用,仍然将当前访问的数据页指向链表头部,此时尾部的数据页被淘汰,如下图所示
上面的LRU算法表面看上去是可行的,如果是对于比较简单的场景可能没有问题,但是请思考下面的场景:
场景1:之前我们提到的缓冲池的预读机制,如果是随机预读机制,会把相邻的数据页同时加载到内存中。如果此时MySQL需要访问的数据位于数据页34,并假设触发了预读机制,需要同时加载了34,35两个相邻的数据页,如果采用了上面的LRU算法,则很容易知道LRU会将初始状态下位于末尾的70和8的数据页进行淘汰。我们可能面临的比较糟糕的情况是,缓冲池预读机制可能成为未来的累赘,因为可能预读的数据在后续的访问中,并不是我们所需要访问的,而我们付出的代价不仅有加载了无用的数据页,而且淘汰了末尾的热点数据页。这种情况就是前面提到的预读失效问题
场景2:当执行一条SQL语句时,如果扫描数据量较大,此时缓冲池就需要加载大量的数据页,从而将之前已经存在的旧数据页全部进行淘汰,最后会导致大量的热数据被置换掉,MySQL的性能急剧下降,这种情况就是前面提到的缓冲池污染问题
预读机制失效问题的解决方案
当然,有了问题就需要针对性的进行解决,我们看下MySQL是如何进行解决的,首先针对预读失效场景进行分析,如果我们是设计者会如何进行思考呢?
- 首先最容易想到的是,既然是因为加入到LRU链表头部的预读数据造成了误淘汰,那么我们能否让真正被访问的数据才移动到链表头部,暂时不要因为预读的数据而误伤了尾部的数据页
- 另外可以尽量的降低影响面,对于预读失效的数据页,尽量的让停留在缓冲池LRU的时间尽量的短
MySQL针对预读失效的场景是如何进行设计的呢?当然MySQL的设计者可都是一批聪明绝顶的人,这些问题可难不倒他们。
MySQL采用了冷热数据分离策略,如下图所示
其具体做法就是:
- 将LRU链表分为热数据区域(young区,新生代区)和冷数据区域(old区,老年代区),看到这些是不是有一种似曾相识的感觉,JVM里面的新生代和老年代大抵亦是利用这种思想
- 将新生代和老年代首尾相连,默认新生代 : 老年代=7 : 3,当然这个比例可以根据需要设定
新页(缓冲池中不存在的数据页,如第一次进行加载),会放入到老年代头部
- 对于真正需要访问的数据页才会放到新生代的头部
- 如果老年代的数据页没有被读取,则会比新生代的数据页更容易被淘汰
为了更好的理解LRU的执行过程,针对以下场景进行分析:
场景1:假设预读到了新数据页40,那么LRU的执行过程,如下图所示
- 新数据页40会插入到老年代头部,同时老年代尾部的数据页8会被淘汰
- 假设新数据页40不会被真正读取,即出现了预读失败,根据前面所讲的淘汰顺序,老年代的数据会比新生代的数据页更快的被淘汰, 那么数据页40将会比新生代的数据页更快的被淘汰
场景2:假设预读到了新数据页40,并且在老年代停留时间窗口之外(有关停留时间窗口下文会讲到)被访问到了该数据页,那么LRU的执行过程,如下图所示
- 新数据页40会插入到老年代头部,同时冷数据区域尾部的数据页8会被淘汰
- MySQL访问到数据页40的数据行后,数据页40会被移动到新生代的头部,此时不会有数据页淘汰
通过场景1,2可以看到通过冷热数据区域分离的精妙之处,新生代尾部的数据页50,无论预读的数据页有没有被读取,都没有被淘汰掉,这样就很好的解决了 “预读失效” 问题。
缓冲池污染问题的解决方案
这里提前讲一下MySQL针对LRU做的额外优化:
老年代停留时间窗口策略
加载到老年代的数据页,如果在停留时间窗口内就被访问,那么MySQL并不会立刻把老年代的数据域移动至新生代头部,而只有在老年代的生存时间达到时间窗口,才会被插入到新生代
当然大家不禁要问,时间窗口策略有什么好处,解决了什么问题?
可以细想一下,假设老年代数据页在时间窗口内被访问了一次后,再也不被访问了,其实没有移动至新生代的必要,而应该从老年代尽快的去淘汰它,那么如果移动到新生代必然会带来性能的影响,并且也会造成热数据区域的浪费。 当然,也有可能在时间窗口外,访问了一次数据页之后再也不访问了,那么同样会造成浪费。但是,MySQL做的最起码减少了影响范围。
新生代1:3链表移动策略
新生代的数据页,如果被访问了并不是每次都需要移动到链表头部。MySQL优化为只有新生代后3/4部分被访问才会移动到链表头部,另外1/4的数据页被访问并不会移动到新生代头部。
同样,大家思考下MySQL这是解决了什么问题?
因为新生代即热数据区域,存放的都是经常被访问的数据,如果每次被访问,都需要将数据页移动至新生代头部,那么会造成新生代异常忙碌,造成CPU资源的浪费。通过这种策略,减少了新生代链表的移动次数。
MySQL解决缓冲池污染问题采用的就是上面提到的老年代停留时间窗口策略,接下来我们可以具体分析一下过程
首先分析一个场景,假设有一个SQL查询
select * from user where name like "%yangyong%"
因为MySQL前缀匹配不能走索引,因此需要进行全表扫描,需要访问大量的数据页,大致过程如:
- 把数据页插入到老年代头部
- 把读取到的数据页移动到新生代头部
- 读取数据行进行字符串匹配,如果满足要求加入到结果集
- 直至扫描完所有的数据行
最终导致所有的热数据区域的数据页被置换掉,缓冲池受到污染。
假设上述SQL进行批量扫描,数据页61,62,63,64,65将要被访问,如下图所示
如果没有老年代时间停留窗口策略,这些被批量访问的数据页将会置换出大量的热数据,如下图所示
加入老年代时间停留窗口策略后,会先将需要访问的数据页加载到老年代,而优先淘汰掉老年代中的数据页,如下图所示
假设大于停留时间窗口T后,数据页61,62被访问,那么会被移动到新生代的头部,如下图所示
通过上面的分析过程不能理解MySQL是如何通过老年代停留时间窗口机制来解决缓冲池污染问题的。
作者结语:本篇就是分享的关于MySQL如何进行内存管理的内容,always day one!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。