LRU 算法在 MySQL 和 Redis 中的实现与优化

本文转载于我的个人公众号“阿东编程之路

一. 什么是LRU算法

LRU 算法全称:Least Recently Used,故名思义就是最近最少被使用的。一般会用 LRU 算法来实现内存的淘汰机制。LRU 算法的核心就是淘汰掉最久未使用的数据

LRU 算法的思想就是认为最近被使用的数据是热点数据,下一次有很大可能性被访问,所以当内存不足时就要淘汰掉最不常用的数据来为后面进来的数据腾空

二. LRU在MySQL中的实现与优化

我们之前介绍了 MySQL 在内部维护了一个buffer pool,保存更新的脏数据,配合 redo log 防止崩溃脏数据丢失防止随机 IO 来提升写性能,同时也保存了一些干净数据减少 IO 来提升读性能。

对于内存数据带来的性能提升有个指标叫做“内存命中率”,在 MySQL 客户端输入命令查看 Innodb 引擎的状态:

show engine innodb status;

“Buffer pool hit rate 993 / 1000” 这个就代表 Innodb 引擎的内存命中率,图中的数据是 99.3%。而一般在线上的 MySQL 服务,内存命中率也要达到99%以上,充分利用有限的内存空间。

Innodb 的 buffer pool 大小一般也设置的非常大,设置成可用物理内存的 60%~80%。

但是就算内存设置的再大,也是小于磁盘空间的,如果 buffer pool 装满,也需要一套合理的内存淘汰机制来进行数据替换的。InnoDB 就是使用 LRU 算法来管理内存的。

buffer pool 将内存中的数据页维护在一个链表的数据结构里,可以认为链表中越靠头部的数据越常用,越靠链表尾部的数据越不常用

下图的就是基础版 LRU 算法的读取数据的逻辑,分为两种情况:

1. 读的数据在链表中

2. 读的数据不在链表中

  • 当要读的 p8 页在链表中,会将 p8 前后指针断开,然后将p5的指针指向p1,最后再把 p8 放到链表头部位置,将 p8 页返回;

  • 当读的 p6 页不在链表中,会去磁盘查并在 buffer pool 中申请新的数据页 p6,假设链表满了代表内存已满,不能申请新的内存空间,所以需要淘汰最不常用的数据,因为 p4 在尾部,所以可以认为 p4 是链表中最不常用的,然后将链表尾部的 p4 淘汰掉,将 p6 放置到链表头部,最后将 p6 页返回。

这样就完成了一个 LRU 算法的内存淘汰机制。

如果 InnoDB 就用上面这种模式的 LRU 算法会有什么问题?

  • 假设现在进行了一次几十个 G 的大表全表扫描 select * from  big_table ,会将链表中的大部分数据都淘汰掉,然后将这些实际并不常用的数据存入链表,会导致内存命中率大幅下降,所有压力都打到磁盘上,sql 语句相应也变慢,这对于线上服务肯定是不能容忍的。

所以 InnoDB 对 LRU 算法进行了优化,对整个链表按照 5:3 分成 young 区和 old 区,如下图所示:

我们看下优化后读数据的逻辑:

假设现在查询 p8 数据页,分三种情况:

1. 如果 p8 存在于链表的 young 区,那么后面的操作就和优化前的基础版 LRU 算法一样,将p8放到链表young区的头部。

2. 如果 p8 不在链表中,就不是和之前基础班一样放到的链表头部了,而是放到链表old区的头部。

3. 如果 p8 存在于链表的old区,就要判断p8存在链表的时间:

(1)p8 数据页在链表中的存在时间大于 1 秒,就会将 p8 移动到young区的头部

(2)p8 数页页在链表中的存在时间小于 1 秒,位置就保持不动。(这个1秒只是默认值,可以通过 innodb_old_blocks_time 来调整

有了冷热分区的LRU算法,对于大表的全表扫描出来的数据页也不会立刻放到整个链表的头部,而是放到old区,有效的防止了young区的数据被并不常用的数据替换,保证了young区在大部分情况下都可以正常响应查询。

看到这里有没有觉得这个分区LRU算法很熟悉,是不是和 Java 垃圾回收的分代收集有异曲同工之妙?果然技术都是相通的!

三. LRU在Redis中的实现与优化

高性能非关系型内存数据库 Redis 大家肯定都用过,“快”这个字就是 Redis 最大的特点。所有操作都基于内存的,拥有高效的数据结构,单线程省去线程切换的开销(网络 IO 和键值对读写是单线程的,6.0 版本对网络 IO 增加了多线程处理但是读写还是单线程),并且在网络 IO 上采用多路复用机制(这块后面的文章会详细讲),这些都是 Redis “快”的原因。

我们一般都去使用 Redis 来对数据库做一层保护和提高查询性能,但是由于 Redis 是基于内存的,自然也需要一套内存淘汰机制。

Redis 的内存淘汰机制有很多种,今天我们只说下 Redis 的 LRU 策略。有了刚才 InnoDB 的 LRU 基础,我们来看下 Redis 的 LRU 实现:

  • 在 InnoDB 的 buffer pool 中,数据页本身就是放在链表中,而 Redis 在内存中有自己的数据结构比如 String,List,Set 这些,如果为了实现 LRU 把所有的数据都放到一个额外需要维护的链表里,这对于内存十分珍贵的内存数据库来说会带来额外的空间开销

  • 针对这点,Redis 对 LRU 算法进行了优化,Redis 在访问键值对时,会在元数据结构-RedisObject(记录键值对的元数据信息:最近访问时间、访问次数等)中记录下访问时间。在进行内存淘汰时,第一次会随机选择 N 个数据放到集合中,然后会比较出这个 N 个数据中最近访问时间最早的键进行淘汰

这个 N 值可以通过 maxmemory-samples 参数进行配置。

  • 如果后面再次进行淘汰时,必须当随机淘汰的选键值集合达到 maxmemory-samples 的数量后才能进行淘汰,所以就需要再次挑选键值对进入集合,当然挑选也是有个策略:进入集合的键值对的最近访问时间必须小于集合中所有键值对的最小访问时间,挑选达到 maxmemory-samples 就会再次进行淘汰。

Redis 用这种随机抽样的方式来维护一个较小的 LRU 集合,根据一定的策略进行淘汰数据,不仅节省了空间还减少了对链表数据移动的性能损耗。

但是我们回头看下InnoDB在基础版 LRU 出现的问题,在现在 Redis 这种LRU 算法下会出现吗?

如果只判断访问时间,进行单次大查询后,这些并不常用的数据的访问时间反而比较大,淘汰时就会将一些热点数据淘汰

针对同样的问题,Redis 从 4.0 增加了另一种策略:LFU 淘汰策略。对比 LRU,LFU在LRU的 “筛选访问时间” 的基础上增加了判断 “数据访问次数” 来淘汰数据。我们来看下的LFU是怎么在LRU的基础上进行优化的:

  • LFU 的策略是在 LRU 的基础上为每个键在 RedisObject 里维护一个计数器,记录该键的访问次数。使用LFU策略时除了筛选淘汰数据的逻辑其他和 LRU 一致(随机选取N个数据进行筛选避免链表开销),筛选淘汰的逻辑变成了这样:先判断访问次数,淘汰访问次数低的数据;如果两个数据的访问次数一致,再判断访问时间进行淘汰

  • 从访问次数和访问时间两个维度来进行筛选就可以防止单次访问的数据一直占用空间而不被淘汰了。

  • 在Redis中为每个的键值对都维护了一个 RedisObject 来存储元数据信息,必然会有一定的内存开销,所以节省内存空间是 Redis 的准则

  • 使用LRU策略,元数据中用 24bit 的 lru 字段来存访问时间,而用了 LFU 策略,为了节省空间就把原来的 24bit 拆成了两部分:16bit 的 Idt 存访问时间戳,8 bit 的 counter 存访问次数

  • 但是 8bit 的空间最多只能存 2的8次方 - 1  = 255,一个键值的访问次数都有可能超过千万,如果每次访问都加一,那这种的为了节省空间的方式就不能准确判断访问次数了,也就达不到效果。

Redis又又又优化了

没错,计数优化逻辑如下:

  • 在实现LFU策略时,并不是每次访问都进行加一,而是有个收敛的操作:当数据被访问时,用计数器的当前值乘以配置项 lfu_log_factor 再加一,然后取其倒数,得到p值;接着把p值和一个取值范围在(0,1)间的随机数 r 值比较大小,只有p值大于 r 值才会加 1

  • 对于 factor 的大小配置和访问次数的计数器大小 Redis 官网有个图来说明这个策略的收敛效果(https://redis.io/docs/manual/eviction/)

可以看到随着factor的值配置越大,越不容易达到最大值 255。

但是如果的某个键值在一段时间内被大量访问后就不再访问了,对应的访问次数和访问时间都比较大,那这批数据该怎么处理?

  • 没错,Redis 又要开始优化了!Redis 又对 counter 增加了一个衰减机制,进新筛选淘汰时会计算的当前时间和 lru 访问时间的差值再除衰减因子:lfu_decay_time,得到的值就是需要衰减的值。

所以就算某个键值在一段时间内被大量访问后就不再访问了,筛选比较也会进行衰减从而被淘汰。

到这里你是不是想问为什么 Redis 一个增加计数器的操作设计的这么麻烦,想要收敛可以直接损失精度取模不是更简单吗?

  • 我觉得优秀的算法模型总是通过不断的测试和实践出来的,以上算法肯定是经过无数次的实验得出的能保持高缓存命中率的模型。以下是阿东个人理解:在Redis这种内存模型下,访问次数越多的数据肯定越少(关系如下图),因为热点的数据就那么点,所以需要对访问次数少的数据增加区分度,而“取倒数和 0 到 1 之间的随机数比较”这个操作会操作会让计数器增长速度越来越慢,正好适合内存中访问次数越多数据就越少的场景,对不同访问次数的数据有着更高的区分度,更加正确的进行淘汰。简单来说就是需要对多量数据进行高区分,少量数据进行低区分(个人理解可能表达的不太好,不知道兄弟们有没有get到,没有get到可以私聊我讨论下)。

不得不说,Redis也太能优化了!从上述算法我们能学到一点:没有最好的技术,只有最适合的技术!

四. 总结

本篇文章我们介绍了 MySQL 中 InnoDB 的分区 LRU 算法,通过分 young 区和 old 区来防止单次的大表查询把热点页淘汰出去,导致内存命中率大幅下降从而影响线上业务;还有 Redis 中的随机 LRU 算法通过维护一个随机抽样的集合和访问时间来减少维护链表的开销,和有着高区分度的优化版本 LFU 算法来减少额外的内存占用。分区和随机抽样的思想都非常值得我们在工作中借鉴!

如果觉得文章不错可以点个赞和关注

欢迎关注我的个人公众号“阿东编程之路”,只分享干货!


参考书籍及文档:

1.《MySQL实战45讲》 作者:林晓斌

2.《Redis核心技术与实战》 作者:蒋德钧

3. https://redis.io/docs/manual/eviction/

4.《深入理解MySQL核心技术》 作者:帕奇维

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值