这篇文章主要介绍了MySQL中的缓冲池(buffer pool)的相关资料,包括缓冲池的预读机制、缓冲池的空间管理(LRU算法)、insert buffer、change buffer,希望对读者能有帮助

目录

为什么要有缓冲池?

初识缓冲池

缓冲池的预读机制

线性预读

随机预读

缓冲池的空间管理

传统LRU淘汰法

缓存页已经在缓存池中

缓存页不在缓存池中

预读失效

缓冲池污染

冷热数据分离

插入缓冲(insert buffer)

change buffer相关参数

为什么要有缓冲池?

我们知道,内存的读写速度远比磁盘IO读写速度要快,而MYSQL的数据及索引都存储在具有B+树结构的文件磁盘上,所以为了有效提高MySQL的读写速度,会把最常访问的数据放在缓存(cache)里,避免每次都去访问数据库,这里我们引入了缓冲池的概念。

初识缓冲池

简单来说缓冲池就是一块内存区域,它存在的原因之一是为了避免每次都去访问磁盘,把最常访问的数据放在缓存里,提高数据的访问速度。在数据库当中读取页的操作,首先将从磁盘读到的页存放在缓存池中。下一次再读相同的页时,首先判断该页是不是在缓冲池中。若在,直接读取。否则,读取磁盘上的页。

对于数据库中页的修改操作,则首先修改缓存池中的页,然后再以一定的频率刷新到磁盘上。需要注意的是,缓冲池刷新回磁盘并不是每次页发生更新时触发,而是通过一种称为Checkpoint的机制刷新回磁盘。

缓冲池中缓存的数据页类型有:索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等。不能简单地认为,缓冲池只是缓存索引页和数据页,它们只是占缓冲池很大的一部分而已。

MySQL缓冲池内存大小 mysql 缓冲池_数据

Buffer Pool默认大小 128M,缓冲池中页的大小默认为16KB

缓冲池大小可以通过innodb_buffer_pool_size参数来设置

查看缓存池大小:


mysql> show variables like 'innodb_buffer_pool_size'; *************************** 1. row Variable_name: innodb_buffer_pool_size Value: 134217728


为了减少数据库内部资源竞争,增加数据库并发能力,可以使用多个缓冲实例,每个页根据哈希值平均分配到不同缓冲池实例中,设置参数为innodb_buffer_poll_instances,默认为1。


mysql> show variables like 'innodb_buffer_pool_instances'; 1. row : innodb_buffer_pool_instances Value: 1


缓冲池的预读机制

我们知道,只要不存在或减少磁盘 I/O,MySQL的查询速度自然就会变快。那么对于加载数据页这种无法避免的磁盘 I/O 来说是否有更好的方式呢?既然避免不了,那减少磁盘 I/O 的次数总可以吧?这就是我们接下来要讲的缓冲池的预读机制。

预读机制是 Innodb 通过在缓冲池中提前读取多个数据页来优化 I/O 的一种方式,同时预读机制也是innoDB存储引擎的四大特性之一:插入缓冲(insertbuffer) 、二次写(doublewrite) 、自适应哈希索引(ahi) 、预读(readahead)。

磁盘读写的时,是按照页的方式来读取的(你可以理解为固定大小的数据,例如一页数据为 16K),每次至少读入一页的数据,如果下次读取的数据就在页中,就不用再去磁盘上读取了,从而减少了磁盘 I/O,比如可能会提前缓存接下来可能会用到的相邻的数据页,这里涉及两种预读算法来提高IO性能:线性预读(linear read-ahead)和随机预读(randomread-ahead)。

介绍两种算法前,我们先明确下算法中的一个定义:

extent:innodb中将64个page划分为一个extent。

为了区分这两种预读的方式,我们可以把线性预读放到以extent为单位,而随机预读放到以extent中的page为单位。

线性预读

线性预读以extent为单位,着眼于将下一个extent提前读取到buffer pool中。

线性预读方式有一个很重要的变量控制是否将下一个extent预读到buffer pool中,通过使用配置参数innodb_read_ahead_threshold控制触发innodb执行预读操作的时间。

如果一个extent中的被顺序读取的page超过或者等于该参数变量时,Innodb将会异步的将下一个extent读取到buffer pool中,可以设置为0-64的任何值(因为一个extent中也就只有64页),默认值为56,值越高,访问模式检查越严格。


mysql> show variables like 'innodb_read_ahead_threshold'; +-----------------------------+-------+ | Variable_name | Value | | innodb_read_ahead_threshold | 56 |


例如,如果将值设置为48,则InnoDB只有在顺序访问当前extent中的48个pages时才触发线性预读请求,将下一个extent读到内存中。如果值为8,即使程序段中只有8页被顺序访问,InnoDB也会触发异步预读。而在没有该变量之前,当访问到extent的最后一个page的时候,innodb会决定是否将下一个extent放入到buffer pool中。

随机预读

随机预读以extent中的page为单位,着眼于将当前extent中的剩余的page提前读取到buffer pool中,如果某一个extent中,有多个页被读到,就会认为读到这个extent中其他页的可能性也很大,就会把该extent中的其他页也都提前读到buffer pool中。


mysql> show variables like 'innodb_random_read_ahead'; +--------------------------+-------+ | Variable_name | Value | | innodb_random_read_ahead | OFF |


由于随机预读方式给innodb code带来了一些不必要的复杂性,同时在性能也存在不稳定性,在5.5中已经将这种预读方式废弃,默认是OFF。

缓冲池的空间管理

从上面的内容中,大家想必已经知道了缓冲池其实是有大小限制的,那如何控制用有限空间的缓冲池发挥出最大缓存价值呢?就是我们接下来要讨论的缓冲池的空间管理。

其实对缓冲池进行管理的关键部分是如何安排进池的数据并且按照一定的策略淘汰池中的数据,保证池中的数据不“溢出”,同时还能保证常用数据留在池子中。

传统LRU淘汰法

通常来说,缓冲池是通过LRULatest Recent Used,最近最少使用)算法来进行管理的。即最多使用页在LRU列表前端,而最少使用页在LRU列表后端。当缓冲池不能存放新读取到的页时,将首先释放LRU列表中末端的页。

缓存页已经在缓存池中

这种情况下会将对应的缓存页放到 LRU 链表的头部,无需从磁盘再进行读取,也无需淘汰其它缓存页。如下图所示,如果要访问的数据在 6 号页中,则将 6 号页放到链表头部即可,这种情况下没有缓存页被淘汰。

MySQL缓冲池内存大小 mysql 缓冲池_数据_02

缓存页不在缓存池中

缓存页不在缓冲中,这时候就需要从磁盘中读入对应的数据页,将其放置在链表头部,同时淘汰掉末尾的缓存页 。如下图所示,如果要访问的数据在 60 号页中,60 号页不在缓冲池中,此时加载进来放到链表的头部,同时淘汰掉末尾的 17 号缓存页。

MySQL缓冲池内存大小 mysql 缓冲池_缓冲池_03

为了减少数据移动,LRU一般用链表实现。传统的LRU缓冲池算法十分直观,OS,memcache等很多软件都在用,看上去很简单,同时也能达到缓冲池淘汰缓存页的诉求,但是大家可以想想这样

可能会存在哪些问题?接下来我们引入预读失效缓冲池污染。

预读失效

上面我们提到了缓冲池的预读机制可能会预先加载相邻的数据页。假如加载了 20、21 相邻的两个数据页,如果只有页号为 20 的缓存页被访问了,而另一个缓存页却没有被访问。此时两个缓存页都在链表的头部,但是为了加载这两个缓存页却淘汰了末尾的缓存页,而被淘汰的缓存页却是经常被访问的。这种情况就是预读失效,被预先加载进缓冲池的页,并没有被访问到,这种情况是不是很不合理。

要优化预读失效,思路是?

  • 让预读加载进缓存且尚未被访问的页,停留在缓冲池LRU里的时间尽可能短;
  • 让真正被读取的页,才挪到缓冲池LRU的头部;

以此来保证真正被读取的热数据留在缓冲池里的时间尽可能长。

缓冲池污染

还有一种情况是当执行一条 SQL 语句时,如果扫描了大量数据或是进行了全表扫描,此时缓冲池中就会加载大量的数据页,从而将缓冲池中已存在的所有页替换出去,这种情况同样是不合理的。这就是缓冲池污染,并且还会导致 MySQL 性能急剧下降。

例如,有一个数据量较大的用户表,当执行:

select * from user where name like "%John%";

虽然结果集可能只有少量数据,但这类like不能命中索引,必须全表扫描,就需要访问大量的页。

冷热数据分离

这样看来,传统的 LRU 方法并不能满足缓冲池的空间管理。因此,Msyql 基于 LRU 设计了冷热数据分离的处理方案。也就是将 LRU 链表分为两部分,一部分为热数据区域,一部分为冷数据区域。

MySQL缓冲池内存大小 mysql 缓冲池_缓冲池_04

innodb_old_blocks_time

innodb_old_blocks_time是什么?


mysql> show variables like 'innodb_old_blocks_time'\G; 1. row : innodb_old_blocks_time Value: 1000


答:innodb_old_blocks_time代表数据页在冷数据区停留时间窗口,单位是毫秒,默认是1000,即同时满足“被访问”与“在老生代停留时间超过1秒”两个条件,才会被插入到新生代头部。

为什么要存在innodb_old_blocks_time呢?

答:如果数据页刚被加载到冷数据区就被访问了,如果之后再也不访问它的话,这些批量被访问的页面,会换出大量热数据,造成热数据区的浪费。如果1s 后不再访问该数据页,可能之后也不会去频繁访问它,也就没有移至热缓冲区的必要了,当缓存页不够的时候,从冷数据区淘汰它们就行了。 而如果1s后再次访问该数据页,再把该数据页加到热数据区头部也不迟。

那冷热数据分离且数据页已存在于热数据区的情况下,如果访问该数据页一定会将该数据页放在热数据区链表头部吗?

答:首先热数据区域里的缓存页是会被经常访问的,如果每访问一个缓存页就插入一次链表头,热数据区数据页链表将会不断调整,带来的性能开销也是不容忽视的。那MySQL是如何处理的呢?Mysql 中优化为热数据区的后 3/4 部分被访问后才将其移动到链表头部去,对于前 1/4 部分的缓存页被访问了不会进行移动。

innodb_old_blocks_pct是什么?

上面我们说到将缓存页链表分为两部分,一部分为冷数据区,一部分为热数据区,那具体各占的比例为多少呢?这里引入参数  innodb_old_blocks_pct


mysql> show variables like 'innodb_old_blocks_pct'; 1. row : innodb_old_blocks_pct Value: 37


innodb_old_blocks_pct控制老生代占整个LRU链长度的比例,默认是37,即整个LRU中新生代与老生代长度比例是63:37。如果把这个参数设为100,就退化为普通LRU了。

总结:MySQL缓冲池通过冷热数据分离的方式管理缓存数据,可以防止并解决预读失效的问题,预读到缓冲池且暂未被访问的数据放在冷数据区,如一直未被访问,会比热数据区的缓存页更快的清除出缓冲池。而通过设置innodb_old_blocks_time会使因全表扫描而被加载入缓冲池且之后没被访问过的这部分数据页,比热数据区里的“热数据页”更早被淘汰出缓冲池。

插入缓冲(insert buffer)

我们在讲插入缓存(insert buffer)之前,再来回顾一下文章上面提到过的:innoDB存储引擎的四大特性:插入缓冲(insert buffer) 、二次写(doublewrite) 、自适应哈希索引(ahi) 、预读(readahead)。四大特性之一就是insert buffer,下面跟随我一起来了解下insert buffer究竟是什么。

        我们知道,mysql的索引数据是存储在磁盘上的,一般的主键索引为自增id,新数据索引写入的位置都是顺序的,无须磁盘的随机IO,效率很高。而普通索引数据新增大概率无序,一般情况下需要磁盘的随机IO,效率较低。

        为了解决普通索引插入效率低下的问题,InnoDB 存储引擎引入 Insert Buffer 的概念,对于普通索引(非聚集索引)不是直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓存池中,如果在则直接将索引数据插入缓存池中的索引页,反之则先将索引数据放入 Insert buffer 对象中,然后以一定频率和辅助索引(也叫二级索引,这里指的是普通索引)页子节点进行合并操作,此时通常能将多个插入操作合并到一个操作中,以提高插入性能。

        和insert buffer类似,接下来我们介绍change buffer,其实也是MySQL在我们对非唯一的二级索引进行DML(删除行、写入行、修改行)操作时作出的优化逻辑,目的都是为了让mysql的性能更好。有读者可能注意到,为什么是非唯一的二级索引呢,我们下面会讲到。change buffer大致的工作原理以下面我举的一个update sql的例子来具体分析说明:

update company_table set company_name = '博伟集团有限公司' where id = 1

         sql比较简单,就是更新id=1的数据行的company_name字段,这条update sql执行起来大概有以下几步:

  1. 检查需要被update的数据是否在buffer pool中。
  2. 如果数据存在于buffer pool中,则更新buffer pool中对应的数据页数据。
  3. 如果数据不存在buffer pool中,进行磁盘的IO操作,将其数据页读取到缓存池中,再进行update操作。

  那change buffer的优化的可以体现在以上第三步中,第三步可优化为当数据所在数据页不存在buffer pool中时,可先将update操作缓存在change buffer中(目的是省去这次随机的磁盘IO)即可返回结果,无须等待磁盘IO所耗时间。等之后MySQL空闲了、或者是MySQL关闭前、或者是有读取操作时再将这部分缓存操作merge到B+Tree中(此时再维护二级索引B+树的变更)。下图可以简单的体现该过程。

MySQL缓冲池内存大小 mysql 缓冲池_MySQL缓冲池内存大小_05

change buffer相关参数

        参数:innodb_change_buffer_max_size

        作用:控制change buffer能占用buffer pool总内存的比例

        范围:默认25(表示change buffer最大能占用其25%的内存),最大50。

        参数:innodb_change_buffering

        作用:控制change buffer对哪些dml起作用

        可选参数:all(insert、delete、update)、none(不缓存任何操纵)、inserts、deletes、purges

        最后解决我们以上遗留的一个问题,为什么insert buffer和change buffer使用前提都是非唯一的二级索引呢?这个其实也比较好理解,如果是唯一索引,那每次insert或update操作前都需要去内存或磁盘上验证是否已存在相同的索引值,以保证索引唯一。这其实就说明唯一索引的写操作其实是不能被缓存的,因为唯一索引的写操作需要立即响应是否索引数据有冲突,是和普通索引可允许null值和重复值是不一样的。