41-源码 8:精益求精 —— LFU vs LRU
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

源码 8:精益求精 —— LFU vs LRU

在第 27 小节,我们讲到了 Redis 的 LRU 模式,它可以有效的控制 Redis 占用内存大小,将冷数据从内存中淘汰出去。Antirez 在 Redis 4.0 里引入了一个新的淘汰策略 —— LFU 模式,作者认为它比 LRU 更加优秀。

LFU 的全称是Least Frequently Used,表示按最近的访问频率进行淘汰,它比 LRU 更加精准地表示了一个 key 被访问的热度。

如果一个 key 长时间不被访问,只是刚刚偶然被用户访问了一下,那么在使用 LRU 算法下它是不容易被淘汰的,因为 LRU 算法认为当前这个 key 是很热的。而 LFU 是需要追踪最近一段时间的访问频率,如果某个 key 只是偶然被访问一次是不足以变得很热的,它需要在近期一段时间内被访问很多次才有机会被认为很热。

Redis 对象的热度

Redis 的所有对象结构头中都有一个 24bit 的字段,这个字段用来记录对象的「热度」。

// redis 的对象头
typedef struct redisObject {
    unsigned type:4; // 对象类型如 zset/set/hash 等等
    unsigned encoding:4; // 对象编码如 ziplist/intset/skiplist 等等
    unsigned lru:24; // 对象的「热度」
    int refcount; // 引用计数
    void *ptr; // 对象的 body
} robj;

LRU 模式

在 LRU 模式下,lru 字段存储的是 Redis 时钟server.lruclock,Redis 时钟是一个 24bit 的整数,默认是 Unix 时间戳对 2^24 取模的结果,大约 97 天清零一次。当某个 key 被访问一次,它的对象头的 lru 字段值就会被更新为server.lruclock

默认 Redis 时钟值每毫秒更新一次,在定时任务serverCron里主动设置。Redis 的很多定时任务都是在serverCron里面完成的,比如大型 hash 表的渐进式迁移、过期 key 的主动淘汰、触发 bgsave、bgaofrewrite 等等。

如果server.lruclock没有折返 (对 2^24 取模),它就是一直递增的,这意味着对象的 LRU 字段不会超过server.lruclock的值。如果超过了,说明server.lruclock折返了。通过这个逻辑就可以精准计算出对象多长时间没有被访问——对象的空闲时间。

// 计算对象的空闲时间,也就是没有被访问的时间,返回结果是毫秒
unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK(); // 获取 redis 时钟,也就是 server.lruclock 的值
    if (lruclock >= o->lru) {
        // 正常递增
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION; // LRU_CLOCK_RESOLUTION 默认是 1000
    } else {
        // 折返了
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) * // LRU_CLOCK_MAX 是 2^24-1
                    LRU_CLOCK_RESOLUTION;
    }
}

有了对象的空闲时间,就可以相互之间进行比较谁新谁旧,随机 LRU 算法靠的就是比较对象的空闲时间来决定谁该被淘汰了。

LFU 模式

在 LFU 模式下,lru 字段 24 个 bit 用来存储两个值,分别是ldt(last decrement time)logc(logistic counter)

logc 是 8 个 bit,用来存储访问频次,因为 8 个 bit 能表示的最大整数值为 255,存储频次肯定远远不够,所以这 8 个 bit 存储的是频次的对数值,并且这个值还会随时间衰减。如果它的值比较小,那么就很容易被回收。为了确保新创建的对象不被回收,新对象的这 8 个 bit 会初始化为一个大于零的值,默认是LFU_INIT_VAL=5

ldt 是 16 个位,用来存储上一次 logc 的更新时间,因为只有 16 位,所以精度不可能很高。它取的是分钟时间戳对 2^16 进行取模,大约每隔 45 天就会折返。同 LRU 模式一样,我们也可以使用这个逻辑计算出对象的空闲时间,只不过精度是分钟级别的。图中的 server.unixtime 是当前 redis 记录的系统时间戳,和 server.lruclock 一样,它也是每毫秒更新一次。

// nowInMinutes
// server.unixtime 为 redis 缓存的系统时间戳
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}

// idle_in_minutes
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt; // 正常比较
    return 65535-ldt+now; // 折返比较
}

ldt 的值和 LRU 模式的 lru 字段不一样的是 ldt 不是在对象被访问时更新的。它在 Redis 的淘汰逻辑进行时进行更新,淘汰逻辑只会在内存达到 maxmemory 的设置时才会触发,在每一个指令的执行之前都会触发。每次淘汰都是采用随机策略,随机挑选若干个 key,更新这个 key 的「热度」,淘汰掉「热度」最低的。因为 Redis 采用的是随机算法,如果 key 比较多的话,那么 ldt 更新的可能会比较慢。不过既然它是分钟级别的精度,也没有必要更新的过于频繁。

ldt 更新的同时也会一同衰减 logc 的值,衰减也有特定的算法。它将现有的 logc 值减去对象的空闲时间 (分钟数) 除以一个衰减系数,默认这个衰减系数lfu_decay_time是 1。如果这个值大于 1,那么就会衰减的比较慢。如果它等于零,那就表示不衰减,它是可以通过配置参数lfu-decay-time进行配置。

// 衰减 logc
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8; // 前 16bit
    unsigned long counter = o->lru & 255; // 后 8bit 为 logc
    // num_periods 为即将衰减的数量
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

logc 的更新和 LRU 模式的 lru 字段一样,都会在 key 每次被访问的时候更新,只不过它的更新不是简单的+1,而是采用概率法进行递增,因为 logc 存储的是访问计数的对数值,不能直接+1

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
// 对数递增计数值
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255; // 到最大值了,不能在增加了
    double baseval = counter - LFU_INIT_VAL; // 减去新对象初始化的基数值 (LFU_INIT_VAL 默认是 5)
    // baseval 如果小于零,说明这个对象快不行了,不过本次 incr 将会延长它的寿命
    if (baseval < 0) baseval = 0; 
    // 当前计数越大,想要 +1 就越困难
    // lfu_log_factor 为困难系数,默认是 10
    // 当 baseval 特别大时,最大是 (255-5),p 值会非常小,很难会走到 counter++ 这一步
    // p 就是 counter 通往 [+1] 权力的门缝,baseval 越大,这个门缝越窄,通过就越艰难
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    // 开始随机看看能不能从门缝挤进去
    double r = (double)rand()/RAND_MAX; // 0 < r < 1
    if (r < p) counter++;
    return counter;
}

为什么 Redis 要缓存系统时间戳?

我们平时使用系统时间戳时,常常是不假思索地使用System.currentTimeInMillis或者time.time()来获取系统的毫秒时间戳。Redis 不能这样,因为每一次获取系统时间戳都是一次系统调用,系统调用相对来说是比较费时间的,作为单线程的 Redis 表示承受不起,所以它需要对时间进行缓存,获取时间都直接从缓存中直接拿。

redis 为什么在获取 lruclock 时使用原子操作?

我们知道 Redis 是单线程的,那为什么 lruclock 要使用原子操作 atomicGet 来获取呢?

unsigned int LRU_CLOCK(void) {
    unsigned int lruclock;
    if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
        // 这里原子操作,通常会走这里,我们只需要注意这里
        atomicGet(server.lruclock,lruclock);  
    } else {
        // 直接通过系统调用获取时间戳,hz 配置的太低 (一般不会这么干),lruclock 更新不及时,需要实时获取系统时间戳
        lruclock = getLRUClock(); 
    }
    return lruclock;
}

因为 Redis 实际上并不是单线程,它背后还有几个异步线程也在默默工作。这几个线程也要访问 Redis 时钟,所以 lruclock 字段是需要支持多线程读写的。使用 atomic 读写能保证多线程 lruclock 数据的一致性。

如何打开 LFU 模式?

Redis 4.0 给淘汰策略配置参数maxmemory-policy增加了 2 个选项,分别是 volatile-lfu 和 allkeys-lfu,分别是对带过期时间的 key 进行 lfu 淘汰以及对所有的 key 执行 lfu 淘汰算法。打开了这个选项之后,就可以使用 object freq指令获取对象的 lfu 计数值了。

> config set maxmemory-policy allkeys-lfu
OK
> set codehole yeahyeahyeah
OK
// 获取计数值,初始化为 LFU_INIT_VAL=5
> object freq codehole
(integer) 5
// 访问一次
> get codehole
"yeahyeahyeah"
// 计数值增加了
> object freq codehole
(integer) 6

思考题

  1. 你能尝试使用 py 或者 Java 写一个简单的 LFU 算法么?
  2. 如果一开始使用 LRU 模式,突然改变配置变成了 LFU 模式,想象一下 Redis 对象头的 lru 字段值,会对现有的对象产生什么影响?
留言
Ctrl + Enter
全部评论(35)
BinK_1783的头像
删除
喜欢的东西就不要问别人好不好看,生活不会因为别人的话变好,喜不喜欢,决定权还是在自我
点赞
2
删除
怎么每个评论下都能看到老哥
点赞
回复
删除
回复
哈哈
怎么每个评论下都能看到老哥
点赞
回复
陆伯言的头像
删除
java后端
为什么不直接存时间戳,而是存时钟呢?感觉直接存时间戳更方便
点赞
1
删除
还一个问题 ldt 不是在对象被访问时更新的。它在 Redis 的淘汰逻辑进行时进行更新。这个不会有问题吗?logc的值不是根据ldt计算的吗?这样一直不更新,难道算出来的不会有误?
点赞
回复
只会狗刨的头像
删除
后端
收获多的一篇~点赞。但是建议标题改为“LFU和LRU淘汰的依据”
另外:
1、“默认 Redis 时钟值每毫秒更新一次,在定时任务serverCron里主动设置”--有误。
2、194天吧?
点赞
回复
繁星纵变的头像
删除
疑惑:使用了LFU后的lru字段只能精确到分钟,而过期删除又依赖于这个lru字段,就是说过期删除会有分钟级的延迟?
点赞
2
删除
过期是由 expire 字典控制的, lru 字段只是在 LFU 算法中提供频率统计的功能,它记录的时间戳用于频率递减的计算。
点赞
回复
删除
过期key的删除和内存限制下的淘汰是两回事,不要混为一谈
点赞
回复
吴宇晨的头像
删除
后端开发 @ xxx
如果LRU切换成LFU,那么一开始logc是时间戳后8位,会淘汰错一次,但是更新ldt的时候,原本的时间戳计算的结果应该会比当前时间小很多,所以如果设置了lfu_decay_time,那么原先的logc应该都会被清零,相当于重新计算了
点赞
回复
杨陆伟的头像
删除
技术管理
ldt和logc这里写的比较绕,我这样理解的,不知对不对
1. logc在每次key访问时递增(不是简单的加1),此时ldt也随之更新
2.ldt在Redis淘汰key时也会更新,此时未淘汰key的logc也随之更新,算法是衰减
点赞
3
删除
以下是我的想法,不知道对不对。
1.logc在每次key访问的时候,以一定的概率增加一,也就是key被访问后,有可能加1,有可能不加1。从代码的
if (r lru = (LFUGetTimeInMinutes()<<8) | counter;
}
在最后一行中就是对ldt和counter值同时进行更新
点赞
回复
删除
我觉得不对
以下是我的想法,不知道对不对。
1.logc在每次key访问的时候,以一定的概率增加一,也就是key被访问后,有可能加1,有可能不加1。从代码的
if (r lru = (LFUGetTimeInMinutes()<<8) | counter;
}
在最后一行中就是对ldt和counter值同时进行更新
点赞
回复
查看更多回复
杨陆伟的头像
删除
技术管理
为什么server.lruclock要清零?是考虑到字段长度不够存吗?
点赞
1
删除
因为lrucLock是时间戳对常量值取模,当时间增长后会出现模为0的状态,这也算上面说的重置为0
1
回复
H2O酱51786的头像
删除
搬砖工程师 @ SB科技
lru第二个图感觉有点问题.lru长度不能超过LRU_CLK_MAX吧
2
回复
黄晓楷的头像
删除
有个疑问是LRU模式标题下的第一张图的右边的图..为啥对象的lru值能比max值还长??
点赞
1
删除
已经多走了一轮了
点赞
回复
Rosan的头像
删除
-------通过这个逻辑就可以精准计算出对象多长时间没有被访问——对象的空闲时间。
这里计算时间的时候为什么不用考虑server.lruclock折返多次的情况呢?
2
5
删除
因为这里没有考虑一个对象的过期时间会超过(2^24-1)s这么久,实际上如果过期时间真的这么长,也很难在现有的内存使用情况下计算多次折返,除非增加几个比特用来记状态。
点赞
回复
删除
我也在想这个问题
考虑server.lruclock折返多次的情况下,keyA在折返第n次时最后访问,keyB在折返第n+1次时最后访问,此时,通过lruclock + (LRU_CLOCK_MAX - o->lru)算出来的idle_time,并不能严格地反应出keyA和keyB的真实idle_time的相对大小
点赞
回复
查看更多回复
程序猿5199的头像
删除
如果考虑从LRU切换至LFU,LFU时间戳是不是可以设计为前16位和LRU完全相同,后8位使用计数器?
点赞
回复
熊宝不想说话的头像
删除
php开发工程师
2^24-1 是大约194天吧
6
5
删除
2^24-1=8388608s 相当于 97 天吧?
点赞
回复
删除
不好意思说错了。应该是 1<<24 = 8388608s 相当于 97 天吧?
2^24-1=8388608s 相当于 97 天吧?
点赞
回复
查看更多回复