MySQL 系列 缓冲区管理

MySQL 技术内幕:InnoDB存储引擎

Posted by lichao modified on January 10, 2024

BufferPool缓存的数据页类型有: 索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等。

InnoDB中,数据管理的最小单位为页,默认是16KB,页中除了存储用户数据,还可以存储控制信息的数据。InnoDB IO子系统的读写最小单位也是页

索引页与数据页

BufferPool缓存最热的数据页(data page)与索引页(index page)。当内存数据页跟磁盘数据页内容不一致的时候,称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。如果缓存区已满,这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页,就必须将脏页先刷到磁盘,变成干净页后才能复用。

把内存里的数据写入磁盘的过程,术语就是flush,刷脏页(flush)时机包括:

  1. InnoDB的redo log写满了:系统会停止所有更新操作,把checkpoint往前推进,redo log留出空间可以继续写,推进过程中对应的所有脏页都需要flush到磁盘上。
  2. 系统内存不足:当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
  3. MySQL系统“空闲”的时候。
  4. MySQL正常关闭的情况:会把内存的脏页都flush到磁盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的(导致MySQL偶尔“抖动”):

  1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
  2. redo log 写满,更新全部堵住,写性能跌为0,这种情况对敏感业务来说,是不能接受的。

脏页控制策略

相关的参数:

  • innodb_io_capacity这个参数会告诉 InnoDB 机器的磁盘能力。如果这个参数设置的过低,InnoDB 认为这个系统的 IO 能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。

  • innodb_max_dirty_pages_pct是脏页比例上限,默认值是75%, 会根据这个上限值和当前脏页比例,控制刷脏页的速度。

优化的 RUL 算法

InnoDB 使用改进的LRU(最近最少使用)算法管理缓冲池,并能够解决“预读失效”与“缓冲池污染”的问题;

缓冲池采用Least Recently Used(LRU)算法的变体,将缓冲池作为列表进行管理。缓冲池 LRU 数据淘汰时,会将“脏页”刷回磁盘。

缓冲模型

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 5/8 处。也就是说,靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域。

缓冲区URL示意图

  1. 上图中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。
  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页Pm,但是新插入的数据页Px,是放在LRU_old处。
  3. 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:
    1. 若这个数据页在LRU链表中存在的时间超过了1秒,就把它移动到链表头部;
    2. 如果这个数据页在LRU链表中存在的时间短于1秒,位置保持不变。1秒这个时间,是由参数 innodb_old_blocks_time控制的。其默认值是1000,单位毫秒。

这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描 200G 的历史数据表为例,我们看看改进后的LRU算法的操作逻辑:

  1. 扫描过程中,需要新插入的数据页,都被放到 old 区域;
  2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过1秒,因此还是会被保留在old区域;
  3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是young区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了Buffer Pool,但是对young区域完全没有影响,从而保证了Buffer Pool响应正常业务的查询命中率。

刷新邻接页

当刷新一个脏页时,InnoDB 存储引擎会检测该页所在区的所有页,如果是脏页,那么一起刷新。通过 AIO 可以将多个 IO 写入操作合并为一个 IO 操作。

AIO(Asynchronous IO),数据库系统采用 AIO 的方式来处理磁盘操作。

在 InnoDB 中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为1时会有上述的刷新邻接页机制,值为0时表示不找邻居,自己刷自己的。 找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机IO。机械硬盘的随机 IOPS 一般只有几百,相同的逻辑操作减少随机IO就意味着系统性能的大幅度提升。而如果使用的是 SSD 这类 IOPS 比较高的设备的话,就建议把innodb_flush_neighbors的值设置成0。因为这时候IOPS往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷脏页操作,减少SQL语句响应时间。

在 MySQL 8.0 中,innodb_flush_neighbors参数的默认值已经是 0 了。

写缓冲(change buffer)

在 MySQL 5.5 之前,叫插入缓冲(insert buffer),只针对 insert 做了优化;现在对 delete 和 update 也有效,叫做写缓冲(change buffer)。 写缓冲是降低磁盘 IO,提升数据库写性能的一种机制。

当更新一个数据页时,如果数据页在缓冲池中就直接更新,而如果这个数据页还没有在缓冲池中的话,在不影响数据一致性的前提下,并不会立刻将磁盘页加载到缓冲池,InooDB会将这些更新操作缓存在change buffer中。在下次查询需要访问这个数据页的时候,将数据页读入缓冲池,然后执行change buffer中与这个数据页有关的操作,即将数据合并(merge)恢复到原数据页中。通过这种方式就能保证这个数据逻辑的正确性。

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

适用条件:非唯一普通索引页(non-unique secondary index page),如果索引设置了唯一(unique)属性,在进行修改操作时,InnoDB 必须进行唯一性检查。也就是说,索引页即使不在缓冲池,磁盘上的页读取无法避免,否则无法校验是否唯一,此时就应该直接把相应的页放入缓冲池再进行修改。

redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

触发时机

change buffer 是可以持久化的数据。也就是说,change buffer在内存中有缓存,也会被写入到磁盘上;

写缓存数据合并(merge)时机:

  1. 访问这个数据页会触发merge;
  2. 有一个后台线程,认为数据库空闲时会定期merge;
  3. 数据库正常关闭的过程中,会执行merge操作;

适用范围

不适合使用写缓存的场景:

  1. 数据库都是唯一索引
  2. 或者,写入一个数据后,会立刻读取它;

这两类场景,在写操作进行时/进行后,要进行数据页读取,本来相应数据页面就要入缓冲池,此时写缓存反倒成了负担,增加了复杂度。

适合使用写缓存的场景:

  1. 数据库大部分是非唯一索引;
  2. 业务是写多读少,或者不是写后立刻读取;

可以使用写缓冲,将原本每次写入都需要进行磁盘 IO 的 SQL,优化定期批量写磁盘。

参数

change buffer用的是buffer pool里的内存,因此不能无限增大。

参数:innodb_change_buffer_max_size 介绍:配置写缓冲的大小,占整个缓冲池的比例,默认值是25%,最大值是50%。

参数:innodb_change_buffering 介绍:配置哪些写操作启用写缓冲,可以设置成 all/none/inserts/deletes 等。

参考文献

写缓冲(change buffer),这次彻底懂了!!!