文章目录
一、概述
Redis 在 4.0 版本后,还引入了 LFU 算法,也就是最不频繁使用(Least Frequently Used,LFU)算法。
LFU 算法在进行数据淘汰时,会把最不频繁访问的数据淘汰掉。而 LRU 算法是把最近最少使用的数据淘汰掉,看起来也是淘汰不频繁访问的数据。那么,LFU 算法和 LRU 算法的区别到底有哪些呢?我们在实际场景中,需要使用 LFU 算法吗?
二、LFU 算法的基本原理
因为 LFU 算法是根据数据访问的频率来选择被淘汰数据的,所以 LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。
不过,访问次数和访问频率还不能完全等同。访问频率是指在一定时间内的访问次数,也就是说,在计算访问频率时,我们不仅需要记录访问次数,还要记录这些访问是在多长时间内执行的。否则,如果只记录访问次数的话,就缺少了时间维度的信息,进而就无法按照频率来淘汰数据了。
我来给你举个例子,假设数据 A 在 15 分钟内访问了 15 次,数据 B 在 5 分钟内访问了 10 次。如果只是按访问次数来统计的话,数据 A 的访问次数大于数据 B,所以淘汰数据时会优先淘汰数据 B。不过,如果按照访问频率来统计的话,数据 A 的访问频率是 1 分钟访问 1 次,而数据 B 的访问频率是 1 分钟访问 2 次,所以按访问频率淘汰数据的话,数据 A 应该被淘汰掉。
所以说,当要实现 LFU 算法时,我们需要能统计到数据的访问频率,而不是简单地记录数据访问次数就行。
三、LFU 算法的实现
首先,和 LRU 算法类似,LFU 算法的启用,是通过设置 Redis 配置文件 redis.conf 中的 maxmemory 和 maxmemory-policy。其中,maxmemory 设置为 Redis 会用的最大内存容量,而 maxmemory-policy 可以设置为 allkeys-lfu 或是 volatile-lfu,表示淘汰的键值对会分别从所有键值对或是设置了过期时间的键值对中筛选。
LFU 算法的实现可以分成三部分内容,分别是键值对访问频率记录、键值对访问频率初始化和更新,以及 LFU 算法淘汰数据。
1、键值对访问频率记录
通过 LRU 算法的学习,现在我们已经了解到,每个键值对的值都对应了一个 redisObject 结构体,其中有一个 24 bits 的 lru 变量。lru 变量在 LRU 算法实现时,是用来记录数据的访问时间戳。因为 Redis server 每次运行时,只能将 maxmemory-policy 配置项设置为使用一种淘汰策略,所以,LRU 算法和 LFU 算法并不会同时使用。而为了节省内存开销,Redis 源码就复用了 lru 变量来记录 LFU 算法所需的访问频率信息。
具体来说,当 lru 变量用来记录 LFU 算法的所需信息时,它会用 24 bits 中的低 8 bits 作为计数器,来记录键值对的访问次数,同时它会用 24 bits 中的高 16 bits,记录访问的时间戳。下图就展示了用来记录访问频率时的 lru 变量内容,你可以看下。
2、键值对访问频率的初始化与更新
2-1、键值对被创建时
当一个键值对被创建时,如果 Redis 将 maxmemory-policy 设置为 LFU 算法,那么,键值对 redisObject 结构体中的 lru 变量初始化值,会由两部分组成:
- 第一部分是 lru 变量的高 16 位,是以 1 分钟为精度的 UNIX 时间戳。
- 第二部分是 lru 变量的低 8 位,被设置为宏定义 LFU_INIT_VAL,默认值为 5。
2-2、键值对被访问时
当键值对被访问时,它的执行逻辑比较明确,一共分成三步:
第一步,根据距离上次访问的时长,衰减访问次数。
你可能会有疑问:访问键值对时不是要增加键值对的访问次数吗,为什么要先衰减访问次数呢?
其实,这就是我在前面一开始和你介绍的,LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑键值对的访问是多长时间段内发生的。键值对的先前访问距离当前时间越长,那么这个键值对的访问频率相应地也就会降低。
我给你举个例子,假设数据 A 在时刻 T 到 T+10 分钟这段时间内,被访问了 30 次,那么,这段时间内数据 A 的访问频率可以计算为 3 次 / 分钟(30 次 /10 分钟 = 3 次 / 分钟)。
紧接着,在 T+10 分钟到 T+20 分钟这段时间内,数据 A 没有再被访问,那么此时,如果我们计算数据 A 在 T 到 T+20 分钟这段时间内的访问频率,它的访问频率就会降为 1.5 次 / 分钟(30 次 /20 分钟 = 1.5 次 / 分钟)。以此类推,随着时间的推移,如果数据 A 在 T+10 分钟后一直没有新的访问,那么它的访问频率就会逐步降低。这就是所谓的访问频率衰减。
因为 Redis 是使用 lru 变量中的访问次数来表示访问频率,所以在每次更新键值对的访问频率时,就会通过 LFUDecrAndReturn 函数对访问次数进行衰减。
具体来说,LFUDecrAndReturn 函数会首先获取当前键值对的上一次访问时间,这是保存在 lru 变量高 16 位上的值。然后,LFUDecrAndReturn 函数会根据全局变量 server 的 lru_decay_time 成员变量的取值,来计算衰减的大小 num_period。
这个计算过程会判断 lfu_decay_time 的值是否为 0。如果 lfu_decay_time 值为 0,那么衰减大小也为 0。此时,访问次数不进行衰减。
否则的话,LFUDecrAndReturn 函数会调用 LFUTimeElapsed 函数(在 evict.c 文件中),计算距离键值对的上一次访问已经过去的时长。这个时长也是以 1 分钟为精度来计算的。有了距离上次访问的时长后,LFUDecrAndReturn 函数会把这个时长除以 lfu_decay_time 的值,并把结果作为访问次数的衰减大小。
这里,你需要注意的是,lfu_decay_time 变量值,是由 redis.conf 文件中的配置项 lfu-decay-time 来决定的。Redis 在初始化时,会通过 initServerConfig 函数来设置 lfu_decay_time 变量的值,默认值为 1。所以,在默认情况下,访问次数的衰减大小就是等于上一次访问距离当前的分钟数。比如,假设上一次访问是 10 分钟前,那么在默认情况下,访问次数的衰减大小就等于 10。
当然,如果上一次访问距离当前的分钟数,已经超过访问次数的值了,那么访问次数就会被设置为 0,这就表示键值对已经很长时间没有被访问了。
好了,到这里,updateLFU 函数就通过 LFUDecrAndReturn 函数,完成了键值对访问次数的衰减。紧接着,updateLFU 函数还是会基于键值对当前的这次访问,来更新它的访问次数。
第二步,根据当前访问更新访问次数。
- 当前访问次数等于最大值 255 的情况。此时,LFULogIncr 函数不再增加访问次数。
- 当前访问次数小于 255 的情况。此时,LFULogIncr 函数会计算一个阈值 p,以及一个取值为 0 到 1 之间的随机概率值 r。如果概率 r 小于阈值 p,那么 LFULogIncr 函数才会将访问次数加 1。否则的话,LFULogIncr 函数会返回当前的访问次数,不做更新。
从这里你可以看到,因为概率值 r 是随机定的,所以,阈值 p 的大小就决定了访问次数增加的难度。阈值 p 越小,概率值 r 小于 p 的可能性也越小,此时,访问次数也越难增加;相反,如果阈值 p 越大,概率值 r 小于 p 的可能性就越大,访问次数就越容易增加。
而阈值 p 的值大小,其实是由两个因素决定的。一个是当前访问次数和宏定义 LFU_INIT_VAL 的差值 baseval,另一个是 redis.conf 文件中定义的配置项 lfu-log-factor。
当计算阈值 p 时,我们是把 baseval 和 lfu-log-factor 乘积后,加上 1,然后再取其倒数。所以,baseval 或者 lfu-log-factor 越大,那么其倒数就越小,也就是阈值 p 就越小;反之,阈值 p 就越大。也就是说,这里其实就对应了两种影响因素。
- baseval 的大小:这反映了当前访问次数的多少。比如,访问次数越多的键值对,它的访问次数再增加的难度就会越大;
- lfu-log-factor 的大小:这是可以被设置的。也就是说,Redis 源码提供了让我们人为调节访问次数增加难度的方法。
第三步,更新 lru 变量值。
最后,到这一步,updateLFU 函数已经完成了键值对访问次数的更新。接着,它就会调用 LFUGetTimeInMinutes 函数,来获取当前的时间戳,并和更新后的访问次数组合,形成最新的访问频率信息,赋值给键值对的 lru 变量。
好了,到这里,你就了解了,Redis 源码在更新键值对访问频率时,对于访问次数,它是先按照上次访问距离当前的时长,来对访问次数进行衰减。然后,再按照一定概率增加访问次数。这样的设计方法,就既包含了访问的时间段对访问频率的影响,也避免了 8 bits 计数器对访问次数的影响。而对于访问时间来说,Redis 还会获取最新访问时间戳并更新到 lru 变量中。
那么最后,我们再来看下 Redis 是如何基于 LFU 算法淘汰数据的。
3、LFU 算法淘汰数据
在实现使用 LFU 算法淘汰数据时,Redis 是采用了和实现近似 LRU 算法相同的方法。也就是说,Redis 会使用一个全局数组 EvictionPoolLRU,来保存待淘汰候选键值对集合。
这个淘汰流程已经介绍过了,你可以再去整体回顾下。这里,我也再简要总结下,也就是分成三个步骤:
- 第一步,调用 getMaxmemoryState 函数计算待释放的内存空间;
- 第二步,调用 evictionPoolPopulate 函数随机采样键值对,并插入到待淘汰集合 EvictionPoolLRU 中;
- 第三步,遍历待淘汰集合 EvictionPoolLRU,选择实际被淘汰数据,并删除。
而当实现 LFU 算法时,因为 LFU 算法会对访问次数进行衰减和按概率增加,所以,它是使用访问次数来近似表示访问频率的。相应的,LFU 算法其实是用 255 减去键值对的访问次数,这样来计算 EvictionPoolLRU 数组中每个元素的 idle 变量值的。而且,在计算 idle 变量值前,LFU 算法还会调用 LFUDecrAndReturn 函数,衰减一次键值对的访问次数,以便能更加准确地反映实际选择待淘汰数据时,数据的访问频率。
所以说,当 LFU 算法按照访问频率,计算了待淘汰键值对集合中每个元素的 idle 值后,键值对访问次数越大,它的 idle 值就越小,反之 idle 值越大。而 EvictionPoolLRU 数组中的元素,是按 idle 值从小到大来排序的。最后当 freeMemoryIfNeeded 函数按照 idle 值从大到小,遍历 EvictionPoolLRU 数组,选择实际被淘汰的键值对时,它就能选出访问次数小的键值对了,也就是把访问频率低的键值对淘汰出去。
四、总结
- LFU 是在 Redis 4.0 新增的淘汰策略,它涉及的巧妙之处在于,其复用了 redisObject 结构的 lru 字段,把这个字段「一分为二」,保存最后访问时间和访问次数
- key 的访问次数不能只增不减,它需要根据时间间隔来做衰减,才能达到 LFU 的目的
- 每次在访问一个 key 时,会「懒惰」更新这个 key 的访问次数:先衰减访问次数,再更新访问次数
- 衰减访问次数,会根据时间间隔计算,间隔时间越久,衰减越厉害
- 因为 redisObject lru 字段宽度限制,这个访问次数是有上限的(8 bit 最大值 255),所以递增访问次数时,会根据「当前」访问次数和「概率」的方式做递增,访问次数越大,递增因子越大,递增概率越低
- Redis 实现的 LFU 算法也是「近似」LFU,是在性能和内存方面平衡的结果