MySQL的缓存机制

MySQL的缓存机制

本文简单总结下MySQL中的缓存机制,具体来说是,使用InnoDB作为作为存储引擎时的各种缓存机制。主要包含三个方面:

  • MySQL层——查询缓存:作用不大,高版本mysql已经去掉了这个功能。
  • InooDB层——buffer pool:很重要,是保证InnoDB高性能的核心功能之一,也是本文的重点介绍对象。
  • 三大日志文件的缓存:binglog、redolog和undolog三个日志文件,写文件时,先写到缓存,然后再刷盘。

1 查询缓存

具体可以参考:这篇博客

1.1 概述

MySQL查询缓(QC:QueryCache)在MySQL 4.0.1中引入, 5.6中默认禁用,5.7中被deprecated(废弃)以及8.0版本被Removed 。查询缓存存储SELECT语句的文本以及发送给客户机的结果集,如果再次执行相同的SQL,Server端将从查询缓存中检索结果返回给客户端,而不是再次解析执行SQL,查询缓存在session之间共享,因此,一个客户端生成的缓存结果集,可以响应另一个客户端执行同样的SQL。

可以通过query_cache_sizequery_cache_type来配置缓存。如下图所示,开启缓存后,收到查询请求后,先看缓存中有没有,有的话直接从缓存中返回查询结果,没有的话再去数据库里面取。

那如何判断缓存中是否缓存了本次查询呢? 通过SQL文本是否完全一致来判断,包括大小写,空格等所有字符完全一模一样才可以共享,共享好处是可以避免硬解析,直接从QC获取结果返回给客户端,下面的两个SQL是不共享滴,因为一个是from,另一个是From。

1
2
3
4
--SQL 1
select id, balance from account where id = 121;
--SQL 2
select id, balance From account where id = 121;

1.2 为什么弃用

主要还是带来的收益很小,更新操作需要锁缓存,很多场景下反而会降低数据库并发能力。

The query cache is deprecated as of MySQL 5.7.20, and is removed in MySQL 8.0. Deprecation includes query_cache_type,可以看到从MySQL 5.6的默认禁用,5.7的废弃以及8.0的彻底删除,Oracle也是综合了各方面考虑做出了这样的选择。

QueryCache的特性对业务场景要求过于苛刻,与实际业务很难吻合,而且开启之后,对数据库并发度和处理能力都会降低很多,下面总结下为何MySQL从Disabled->Deprecated->Removed QueryCache的主要原因。

同时查询缓存碎片化还会导致服务器的负载升高,影响数据库的稳定性,在Oracle官方搜索QueryCache可以发现,有很多Bug存在,这也就决定了MySQL 8.0直接果断的Remove了该特性。

2 Buffer Pool

我们知道数据库中的数据是存在磁盘中,磁盘的IO速度是很慢的,如果每次查询都需要到磁盘里面去取数据,会严重影响数据库性能。因此必然需要建立缓存,把数据库中的数据加载到缓存中,需要的时候,先到缓存中找,缓存中没有,再去磁盘中找。

2.1 InnoDB缓存池概述

Buffer Pool 是在 MySQL 启动的时候,向操作系统申请的一片连续的内存空间,默认配置下 Buffer Pool 只有 128MB

可以通过调整 innodb_buffer_pool_size 参数来设置 Buffer Pool 的大小,一般建议设置成可用物理内存的 60%~80%。

InnoDB 会把存储的数据划分为若干个「页」,以页作为磁盘和内存交互的基本单位,一个页的默认大小为 16KB。因此,Buffer Pool 同样需要按「页」来划分。

在 MySQL 启动的时候,InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的16KB的大小划分出一个个的页, Buffer Pool 中的页就叫做缓存页。此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到 Buffer Pool 中。

所以,MySQL 刚启动的时候,你会观察到使用的虚拟内存空间很大,而使用到的物理内存空间却很小,这是因为只有这些虚拟内存被访问后,操作系统才会触发缺页中断,接着将虚拟地址和物理地址建立映射关系。

Buffer Pool 除了缓存「索引页」和「数据页」,还包括了 undo 页,插入缓存、自适应哈希索引、锁信息等等。

为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一个控制块,控制块信息包括「缓存页的表空间、页号、缓存页地址、链表节点」等等。

控制块也是占有内存空间的,它是放在 Buffer Pool 的最前面,接着才是缓存页,如下图:

上图中控制块和缓存页之间灰色部分称为碎片空间(因为可能剩余一点空间,不够存放一对控制块和缓存页)。

查询一条记录,就只需要缓冲一条记录吗?

不是的。

当我们查询一条记录时,InnoDB 是会把整个页的数据加载到 Buffer Pool 中,因为,通过索引只能定位到磁盘中的页,而不能定位到页中的一条记录。将页加载到 Buffer Pool 后,再通过页里的页目录去定位到某条具体的记录。

2.2 Buffer Pool的管理

2.2.1 空闲页管理

Buffer Pool 是一片连续的内存空间,当 MySQL 运行一段时间后,这片连续的内存空间中的缓存页既有空闲的,也有被使用的。

那当我们从磁盘读取数据的时候,总不能通过遍历这一片连续的内存空间来找到空闲的缓存页吧,这样效率太低了。

所以,为了能够快速找到空闲的缓存页,可以使用链表结构,将空闲缓存页的「控制块」作为链表的节点,这个链表称为 Free 链表(空闲链表)。

Free 链表上除了有控制块,还有一个头节点,该头节点包含链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。

有了 Free 链表后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 Free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从 Free 链表中移除。

2.2.2 页定位

前面提到,当我们需要访问某个页的数据时先要到缓存中去找,那么我们怎么知道页在不在缓存中呢?难道要遍历缓存中的所有页?

回头想想,我们其实是根据表空间号+页号来定位一个页的,也就相当于表空间号+页号是一个key( 键) ,缓冲页控制块就是对应的value(值)。怎么通过一个key 来快速找到一个value呢?当然是哈希表了!

因此我们可以用“表空间号+页号”作为key,用缓冲页控制块作为value来创建一个hash表。在需要访问某个页的数据时,先从哈希表中根据表空间号和页号看看是否有对应的缓冲页。如果有,直接使用缓存中的即可,如果没有,再去数据库中找该页,并把它加载到缓冲区中的某个空闲页。

2.2.3 脏页管理

设计 Buffer Pool 除了能提高读性能,还能提高写性能,也就是更新数据的时候,不需要每次都要写入磁盘,而是将 Buffer Pool 对应的缓存页标记为脏页,然后再由后台线程将脏页写入到磁盘。

那为了能快速知道哪些缓存页是脏的,于是就设计出 Flush 链表,它跟 Free 链表类似的,链表的节点也是控制块,区别在于 Flush 链表的元素都是脏页。

有了 Flush 链表后,后台线程就可以遍历 Flush 链表,将脏页写入到磁盘。

2.3.4 页换入换出

我们知道缓存空间是有限的,如果缓存满了,但是我们要访问的页不在缓存中该怎么办呢?这时我们就要淘汰掉缓存中的某些页。那具体该淘汰那些缓存页呢?

一个经典的方法就是LRU,即淘汰最近最少使用的页。简单的 LRU 算法的实现思路是这样的:

  • 当访问的页在 Buffer Pool 里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
  • 当访问的页不在 Buffer Pool 里,除了要把页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。

至此,我们知道缓存池中通过3种类型链表来管理数据页:

  1. 空闲页:用空闲页链表来链接所有空闲页,需要的时候从链表中取一个即可;
  2. 干净页:缓存了数据,但是页数据没有被更改,位于LRU链表中;
  3. 脏页:缓存了数据,并且页中数据被修改了,脏页同时存在于LRU链表和Flush链表。

MySQL中使用的是增强版LRU,因为简单的LRU存在预读问题和全表扫描问题。

预读问题

InnoDB提供了一个看起来很贴心的服务——预读。所谓预读,就是InnoOB 认为执行当前的请求时,可能会在后面读取某些页面,
于是就预先把这些页面加载到Buffer Pool 中。根据触发方式的不同,预读又可以细分为下面两种:

  • 线性预读:InnoDB提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问的某个区的页面超过该值,会触发一次异步读取下一个区中全部的页面到 Buffer Pool 中的请求。
  • 随机预读:如果某个区的13 个连续的页面都被加载到了Buffer Pool 中, 无论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他页面到Buffer Pool中的请求。可以通过innodb-random-read ahead 系统变量打开该功能,它的默认值为OFF ,也就意味着InnoDB 并不会默认开启随机预法的功能。

预读本来是好事, 但是可能这些被提前加载进来的数据页,并没有被访问,相当于这个预读是白做了,这个就是预读失效。 并且如果此时Buffer Pool剩余容量不大的话,预读的内容会挤掉很多LRU链表尾部的元素,降低缓存命中率。

全表扫描

有的小伙伴可能会写一些需要进行全表扫描的语句(比如在没有建立合适的索引或者压根儿没有WHERE 子句的查询时)。全表扫描意味着什么?意味着我们可能要加载很多数据到缓存中,这时候如果缓存空闲空间不够话,可能要淘汰很多页。但是全表扫描的数据,在后面的查询中很可能用不到,这时候我们又要重新把需要的页加载到缓存中。

改进LRU

InnoDB将LRU链表划分成两部分:old区域和yong区域,如下图所示:

  • young区域:存储热数据。
    • 预读的页加载到young区,当预读数据被真正使用时,才加载到old区。
  • old区域:存储冷数据,默认占总空间的37%。

这种方案可以很好的解决预读问题,但是对于全表扫描的问题该如何解决呢?

在进行全表扫描时,虽然首次加载到Buffer Pool 中的页放到了old 区域的头部, 但是后续会被马上访问到,每次进行访问时又会把该页放到young 区域的头部, 这样仍然会把那些使用频率比较高的页面给"排挤" 下去。

LRU的young区存储的时热点数据,全表扫描问题的本质在于,一些非热点数据,把yong区的热点数据给挤出去了。那我们只要提高数据进入young区的门槛就能很好解决该问题。InnoDB是这样做的,进入到 young 区域条件增加了一个停留在 old 区域的时间判断。具体是这样做的,在对某个处在 old 区域的缓存页进行第一次访问时,就在它对应的控制块中记录下来这个访问时间:

  • 如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该缓存页就不会被从 old 区域移动到 young 区域的头部
  • 如果后续的访问时间与第一次访问的时间不在某个时间间隔内,那么该缓存页移动到 young 区域的头部

这个间隔时间是由 innodb_old_blocks_time 控制的,默认是 1000 ms。

也就说,只有同时满足「被访问」与「在 old 区域停留时间超过 1 秒」两个条件,才会被插入到 young 区域头部,很明显,在一次全表扫描的过程中,多次访问一个页面(也就是读取同一个页面中的多条记录)的时间不会超过1s。

另外,InnoDB 针对 young 区域其实做了一个优化,为了防止 young 区域节点频繁移动到头部。young 区域前面 1/4 被访问不会移动到链表头部,只有后面的 3/4被访问了才会。

2.3.5 其他

  • 多个Buffer pool:在Buffer Pool 特别大并且多线程并发访问盘特别高的情况下,单一的Buffer Pool 可能会影响请求的处理速度.所以在Buffer Pool 特别大时,可以把它们拆分成若干个小的Buffer Pool ,每个Buffer Pool 都称为一个实例。它们都是独立的——独立地申请内存空间、独立地管理各种链表等等,在多线程并发访问时并不会相互影响, 从而提高了并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer PooI 实例的个数。
  • buffer chunk:在MySQL 5 .7 .5版本之前,只能在服务器启动时通过配置innodb_buffer_pool_size 启动选项来调整Buffer Poo1 的大小,在服务器运行过程中是不允许调整该值的。
    • 不过设计MySQL 的大叔在MySQL 5.7.5 以及之后的版本中,支持了在服务器运行过程中调整Buffer Poo1 大小的功能。但是有一个问题,就是每次重新调整Buffer Poo1 的大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧Buffer Poo1 中的内容复制到这一块新空间,这是极其耗时的。
    • 所以,设计MySQL 的大叔决定不再一次性为某个Buffer Poo1 实例向操作系统申请一大片连续的内存空间,而是以一个chunk 为单位向操作系统申请空间。也就是说, 一个Buffer Pool实例其实是由若干个chunk 组成的。一个chunk 就代表一片连续的内存空间, 里面包含了若干缓冲页与其对应的控制块。

3 日志文件buffer

3.1 binlog cache

对于binglog数据,也是先将日志写到缓存中,然后等事务提交后,再写入磁盘。

  • 分配内存:首先为每个session分配独立内存空间,用来存储二进制日志的缓存,可以通过binlog_cache_log设置缓存大小;
  • 临时文件:当缓存大小超过binlog_cache_log后,会将数据写入大小为max_binlog_cache_size的临时文件;
    • 临时文件存放于tmpdir目录下,以"ML"开头;
    • 执行多语句事务时,如果缓存数据超过max_binlog_cache_size+binlog_cache_log,会报错。
  • 刷盘:事务提交后,将binglog cache和binlog临时文件中所有数据写入磁盘。

3.2 redo log

3.2.1 buffer结构

redo log中数据存储方式是:把MTR(Min-Transaction,不可分割的一组日志)生成的redo日志放在大小为512字节的页中,称之为block。因此,redo log缓存也有着同样地存储格式,如下图所示:

在服务器启动时就向操作系统申请了一片连续空间,称之为redo log buffer。

3.2.2 数据写入

向log buffer中写入redo日志的过程是顺序写入的,也就是先写前面的block,当该block写满了,再写下一个block。InnoDB中存储了一个全局变量buf_free,用来指示下一条日志应该写到哪里,如下图所示:

3.2.3 刷盘时机

遇到下面这些情况,会将buffer中的数据刷新到磁盘中:

  1. log buffer空间不足
  2. 事务提交
  3. 后台有一个线程,大约每秒一次的频率将buffer中数据刷新到磁盘
  4. 正常关闭服务器时
  5. 做checkpoint时

3.3 undo log

undo log数据存储在表空间中,因此它的缓存和数据页缓存一样,依靠前文提到的buffer pool实现的。我们看前文提到的buffer pool结构图中,其中有一项就是undo log页。

参考资料

  1. https://xiaolincoding.com/mysql/buffer_pool/buffer_pool.html
  2. https://segmentfault.com/a/1190000038554542
  3. 《MySQL是怎样运行的》
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2022 Yin Peng
  • 引擎: Hexo   |  主题:修改自 Ayer
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信