LRU中,系统会根据使用的时间进行排序,内存紧张时会将最久没有用过的一批数据排除出去。LFU是按照最近的访问频率进行排序,它比LRU更加精准地表示了一个key被访问得热度。LFU是作者在Redis4.0里引入的一个新的淘汰策略。

在这里我们回顾以下Redis内存不足时的淘汰策略:
noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键
allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
allkeys-random:加入键的时候如果过限,从所有key随机删除
volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu:从所有键中驱逐使用频率最少的键

【Redis中LRU的亮点】
我们先看看普通的LRU的思路,比如Java中的实现方式是用HashMap结合双向链表,增、改、访问(命中)时都将节点移动到队尾,然后删除时删除队头的节点。
但Redis的LRU不是一个严格的LRU,它在需要淘汰时,只选取有限的key进行对比,排除掉访问时间最久的元素。
这意味着它不能选择整个候选元素的最优解,只是局部最优,从redis3.0开始,算法改进为维护一个回收候选key池,这改善了算法的性能,结果更接近于理论LRU算法的结果。
这么做的目的,无非就是降低计算规模,通过概率的手段来近似达到理论的LRU效果,这对于快速响应客户端的指令以及整体的效率都是有很大益处的。
可以看看理论LRU和这种近似LRU的效果对比:

【LFU的思路】
LRU有一个缺陷。
举一个极端的场景,比如在一个缓存池里维护一个定期更新的电视节目源,每个源就是一个json数据,如果按照LRU的算法根据json命中(数据更新、查看、插入等)来决定剔除哪些数据,那么在用户访问了一段时间之后,排在LRU队尾的一定是那些观众喜欢的热点电视节目,但这个时候突然来了一批新的电视节目,马上对这个缓存进行更新,之前排在队尾的用户喜欢的节目很可能就会被不断地挤压到队首甚至被剔除掉。
那么看到这里我们会想着记录key的访问次数,但是单纯的记录访问次数有两个要解决的问题:
>>在LRU算法中可以维护一个双向链表,然后简单的把被访问的节点移至链表开头,但在LFU中是不可行的,节点要严格按照计数器进行排序,新增节点或者更新节点位置时,时间复杂度可能达到O(N)。
>>只是简单的增加计数器的方法并不完美。访问模式是会频繁变化的,一段时间内频繁访问的key一段时间之后可能会很少被访问到,只增加计数器并不能体现这种趋势。
所以LFU的思路就是把访问频率高的保留,而访问频率低,虽然是近期被访问,但在LFU算法中排除的优先级会比较高,这就跟时间、次数都有关系了。
第一个问题,借鉴LRU的实现经验,维护了一个待淘汰的key的pool。第二个问题,随着时间的推移,计数器会减次数。

           16 bits      8 bits
      +----------------+--------+
      + Last decr time | LOG_C  |
      +----------------+--------+

如上所示,既是LFU的关键结构,一个24位的字段,前16位存储了一个按分钟记的时间低位,我们可以把它理解位最近的时间,右边的8位记录了计数器的对数值,反映了访问频率,而不是次数。
每一次key被访问时,logc都会更新,只不过这里由于是存储的对数值,所以采用的是Mirros概率计数器,保证很多次命中时,8位(最多记录255)还是能够承受。
到了内存紧张的时候(maxmemory),LDT的更新才会被触发,每一个key被命中时,ldt都会更新,同时也对logc进行衰减处理。
最后,随机挑选若干key,将其中logc最低的排除。后期的优化也是使用了pool来处理。

注意,为了避免新加入的元素会因为logc值过低被立即剔除,默认初始值为5。

【Redis对象头结构】

#define REDIS_LRU_BITS 24
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {<br>  //存放的对象类型
    unsigned type:4;
    //内容编码
    unsigned encoding:4;
    //与server.lruclock的时间差值
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */\
    //引用计数算法使用的引用计数器
    int refcount;
    //数据指针
    void *ptr;
} robj;

【LFU相关代码】

了解了上述logc增加和ldt计数/logc衰减的流程,我们其实不难发现,最关键的设计,就是两点:

1.logc是怎么增加的,这个概率增加如何实现,保证255能够容纳并准确反馈增长的趋势?

2.在内存紧张时,logc是如何衰减的,跟ldt有什么关系?

至于最后的随机抽取key,或者直接剔除logc最小的,或者放入pool处理,之前有了解过,这里不再叙述。

void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);//首先计算是否需要将counter衰减
    counter = LFULogIncr(counter);//根据上述返回的counter计算新的counter
    val->lru = (LFUGetTimeInMinutes()<<8) | counter; //robj中的lru字段只有24bits,lfu复用该字段。高16位存储一个分钟数级别的时间戳,低8位存储访问计数
}
 
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;//原来保存的时间戳
    unsigned long counter = o->lru & 255; //原来保存的counter
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    //server.lfu_decay_time默认为1,每经过一分钟counter衰减1
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;//如果需要衰减,则计算衰减后的值
    return counter;
}
 
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;//counter最大只能存储到255,到达后不再增加
    double r = (double)rand()/RAND_MAX;//算一个随机的小数值
    double baseval = counter - LFU_INIT_VAL;//新加入的key初始counter设置为LFU_INIT_VAL,为5.不设置为0的原因是防止直接被逐出
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);//server.lfu_log_facotr默认为10
    if (r < p) counter++;//可以看到,counter越大,则p越小,随机值r小于p的概率就越小。换言之,counter增加起来会越来越缓慢
    return counter;
}
 
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;//获取分钟级别的时间戳
}

 【参考】

 《Redis深度历险 核心原理与应用实践》

https://www.jianshu.com/p/c8aeb3eee6bc

posted on 2022-01-21 23:17  长江同学  阅读(1349)  评论(0编辑  收藏  举报