侧边栏壁纸
博主头像
ProSayJ 博主等级

Talk is cheap. Show me the code.

  • 累计撰写 36 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

09-LRU算法

YangJian
2025-06-28 / 0 评论 / 0 点赞 / 2 阅读 / 0 字

LRU 链表(Least Recently Used,最近最少使用)

1. 如果 Buffer Pool 中的缓存页不够用了,该怎么办呢?

在执行 CRUD 操作时,无论是查询还是修改数据,都会将磁盘上的数据页加载到 Buffer Pool 的缓存页中。而加载数据页时,必须找到一个空闲的缓存页,因此会从 free 链表中取出一个空闲缓存页,然后将数据页加载进去。



随着不断地将磁盘上的数据页加载到空闲的缓存页中,free 链表中的空闲缓存页数量会越来越少。因为每次将一个数据页加载到缓存页时,free 链表中的空闲缓存页就会减少一个。

因此,随着数据页不断加载到空闲缓存页中,free 链表里的空闲缓存页迟早会被消耗殆尽。

在这种情况下,当你继续尝试将数据页加载到空闲缓存页时,该怎么办呢?

如果要把一个缓存页里的数据刷入磁盘,腾出来一个空闲缓存页,那么应该把哪个缓存页的数据给刷入磁盘呢?

2. 缓存命中率的概念

为了解决这个问题,我们需要引入 缓存命中率 的概念。

假设现在有两个缓存页,其中一个缓存页的数据被频繁查询和修改,比如在 100 次请求中,有 30 次都涉及这个缓存页的数据操作。这样的话,我们可以认为这个缓存页的
命中率较高

为什么这么说?因为在 100 次请求中,有 30 次直接使用了缓存,避免了从磁盘加载数据,因此缓存的命中率较高。

而另一个缓存页的数据,在刚加载到缓存后可能只被查询或修改过 1 次,接下来的 100 次请求中,没有再被访问过。这种情况下,该缓存页的
命中率较低,因为大部分请求需要重新从磁盘读取数据,而不是直接从缓存获取。

那么,如果你需要在这两个缓存页中选择一个刷回磁盘,以腾出一个新的空闲缓存页,你会选哪个呢?

答案显而易见,肯定是选择 命中率低的缓存页 刷入磁盘!

原因很简单,这个缓存页几乎没有被使用,数据占着缓存页却没有实际作用。如果让它继续占用内存,那无疑是在浪费资源。

3. 引入 LRU 链表来判断哪些缓存页不常用

接下来,我们要解决一个问题:如何判断哪些缓存页被频繁访问,哪些缓存页使用较少?

为此,我们引入 LRU 链表(Least Recently Used,最近最少使用)

通过 LRU 链表,我们可以跟踪缓存页的使用频率,确保当需要释放缓存页时,可以优先选择那些最近最少使用的缓存页,将其刷入磁盘,腾出空间。

LRU 链表的工作原理

其基本思路如下:

  1. 新加载的缓存页 —— 当从磁盘加载一个数据页到缓存时,会将对应的缓存页描述数据块放到 LRU 链表的头部

  2. 频繁使用的缓存页 —— 只要缓存页被访问,就会被移动到 LRU 链表的头部,确保活跃数据页始终保持在前面。

  3. 不常用的缓存页 —— 随着时间推移,较少被访问的缓存页会逐渐往 LRU 链表的尾部移动。

  4. 释放缓存页 —— 当缓存空间不足时,会优先从 LRU 链表的尾部 选择最近最少使用的缓存页,将其刷回磁盘,释放出缓存空间。

这样,通过 LRU 链表,我们就能有效区分 活跃缓存页冷缓存页,确保高效利用缓存资源。

当 free 链表中没有空闲缓存页时,就需要进行 缓存页淘汰。通常,数据库会采用 LRU(最近最少使用)改进版 LRU 等策略,选择一些较少使用的缓存页进行淘汰。如果被淘汰的缓存页是 脏页(即已被修改但未写入磁盘的数据页),则需要先将其 flush 到磁盘,再将该缓存页重新分配给新的数据页使用。

这一整套机制保证了 Buffer Pool 在有限的内存空间下,能够尽可能高效地缓存热点数据,减少磁盘 I/O,提高数据库性能。



4. 预读带来的一个巨大问题

上面提到的,当 Buffer Pool 中的缓存页已满,且没有空闲缓存页时,数据库会通过 LRU 链表来解决这个问题。具体做法是:从 LRU 链表的尾部找到一个最近最少使用的缓存页,将其数据刷入磁盘,腾出一个空闲缓存页,再加载新的磁盘数据页到该空闲缓存页中。

LRU 链表的机制也比较简单:每当从磁盘加载数据到缓存页时,缓存页就会被放入 LRU 链表的头部;如果后续访问了某个缓存页,该缓存页就会被移动到 LRU 链表的头部。最终,LRU 链表的尾部将存放最近最少访问的缓存页。

然而,尽管 LRU 机制看起来有效,但在实际使用中,它也存在着一个显著的问题,尤其是与 MySQL 的预读机制相关。

MySQL 的预读机制指的是,当你从磁盘加载一个数据页时,数据库可能会一次性把这个数据页相邻的其他数据页也加载到缓存中。也就是说,预读不仅仅是加载你需要的数据页,还会额外加载其他可能会用到的数据页,导致缓存占用增加。

举个例子,假设现在有两个空闲缓存页。当从磁盘加载一个数据页时,预读机制可能会把相邻的数据页也一并加载到缓存中,这样这两个数据页分别占用了一个空闲的缓存页。

但问题来了:接下来,只有其中一个数据页被访问,而另一个通过预读加载的缓存页并没有被访问。这时,虽然这两个缓存页都在 LRU 链表的头部,但它们的实际使用频率却不同。这种情况下,LRU 链表可能会错误地认为这两个缓存页都频繁被访问,导致缓存空间被不常用的页面占用。

如上图:

如果这个时候,把上图中LRU尾部的那个缓存页刷入磁盘然后清空,你觉得合理吗?他可是之前一直频繁被人访问的啊!只不过在这一个瞬间,被新加载进来的两个缓存页给占据了LRU链表前面的位置,尤其是第二个缓存页,居然还是通过预读机制加载进来的,根本就不会有人访问!所以这是绝对不合理的,最合理的反而是把上图中LRU链表的第二个通过预读机制加载进来的缓存页给刷入磁盘和清空,毕竟他几乎是没什么人会访问的!

5. 哪些情况下会触发MySQL的预读机制?

我们已经了解了 MySQL 的预读机制可能带来的隐患,即当预读机制将相邻的数据页加载到缓存中时,这些数据页可能根本不会被访问,结果它们却出现在 LRU 链表的前面,从而使得频繁访问的缓存页被错误地刷入磁盘。

那么,究竟在什么情况下会触发 MySQL 的预读机制呢?我们来看看触发条件:

5.1. innodb_read_ahead_threshold 参数

MySQL 中有一个参数 innodb_read_ahead_threshold,默认值为 56。这个参数的意思是:如果顺序访问某个区内的多个数据页,且访问的数据页数量超过了这个阈值,那么 MySQL 会触发预读机制,把下一个相邻区的所有数据页加载到缓存中。

5.2. 缓存连续数据页

如果 Buffer Pool 中已经缓存了某个区的 13 个连续数据页,并且这些数据页经常被访问,那么 MySQL 会直接触发预读机制,把这个区内的其他数据页也加载到缓存中。

这一机制是通过参数 innodb_random_read_ahead 来控制的,默认是关闭的(OFF)。如果该参数开启,那么这个规则会生效,触发预读机制。

5.3. 小结:

在默认情况下,主要是通过第一个规则,即 innodb_read_ahead_threshold,来触发预读机制,加载相邻区的数据页到缓存。问题在于,这些被预读的缓存页可能并没有被访问,但它们却被放置在 LRU 链表的前部。这样,如果在清理缓存时,LRU 链表的尾部存放的是频繁访问的缓存页,它们可能会被误刷入磁盘,导致不合理的缓存页淘汰。

这种情况会使得缓存策略失效,频繁被访问的缓存页被错误地移除,影响性能。

6. 另一种导致频繁被访问的缓存页被淘汰的场景

我们再来看另一种可能导致频繁被访问的缓存页被误淘汰的情况,那就是 全表扫描

所谓的全表扫描,指的是类似以下的 SQL 查询:

SELECT * FROM USERS

这种查询没有任何 WHERE 条件,意味着它会把整个表的数据页从磁盘加载到 Buffer Pool 中。这时,所有的数据页可能都会被加载进来,并占据缓存页。

一旦这个查询执行完毕,LRU 链表的前面可能就全是由全表扫描加载的数据页,这些数据页虽然被加载到了缓存中,但可能后续很少被访问。与此同时,LRU 链表的尾部可能依旧保留着那些之前频繁访问的缓存页。

当需要清理缓存页以腾出空间时,LRU 链表尾部的频繁访问的缓存页可能会被误淘汰,而留下之前全表扫描加载进来的不常用的缓存页。这会导致缓存的使用效率大大降低,频繁访问的缓存页被清除,影响性能。

这种情况尤其是在执行了不常用的全表扫描查询后会发生,因此数据库优化时需要避免全表扫描对缓存的负面影响,或者确保缓存管理机制能够智能地处理这种情况。

7. 总结

使用简单的 LRU 链表机制存在一定的缺陷。因为预读机制或全表扫描可能会将大量数据页一次性加载到缓存中,这些数据页虽然被加载进来,但往往不会被频繁访问。这样一来,LRU 链表的前面就会充斥着这些“未来可能不常被访问”的缓存页。

与此同时,原本一直被频繁访问的缓存页,可能被推到 LRU 链表的尾部。

如果此时需要腾出空间来加载新的数据页,LRU 链表尾部的那些频繁访问的缓存页就可能会被错误地刷入磁盘,造成缓存效率的浪费。

因此,单纯依赖 LRU 链表机制,可能会导致缓存页管理不够智能,影响数据库性能和资源利用率。

8. 🙋‍♂️🙋‍♂️🙋‍♂️为什么MySQL要设计预读这个机制?加载一个数据页到缓存里去的时候,为什么要把一些相邻的数据页也加载到缓存里去呢?这么做的意义在哪里?预读机制是为了应对什么样的一个场景?

其实原因很简单,本质上还是为了 提升性能

假设你读取了 数据页 01 到缓存页里,接下来很可能会顺序读取 数据页 02。如果每次都单独读取一个数据页,那每次都要 触发一次磁盘 IO,这样性能就会受到影响。

为了优化磁盘 IO 开销,MySQL 设计了 预读机制。当你顺序读取了 多个数据页(比如 数据页 01 ~ 56),MySQL 会推测你可能会继续读取 数据页 57 及后续页,于是就提前将这些数据页加载到 Buffer Pool。这样,如果你真的要读取 数据页 60,就可以直接从 Buffer Pool 获取数据,避免再次触发磁盘 IO,从而提升查询效率。

但理想很美好,现实可能很骨感。

如果预读的数据页后续根本没人访问,那就等于白白占用了缓存,甚至会导致 LRU 链表前部充斥着这些“没用的”缓存页,把原本频繁被访问的数据页挤到 LRU 尾部,进而被淘汰。这时候,预读机制反而会拖累系统性能,而不是优化它。

0

评论区