Redis 内存淘汰策略

常见的过期清理策略有以下几种:

  • 定时删除:在设置键过期时间的同时,创建定时器,定时器在键过期时间来临时执行删除操作
  • 惰性删除:不考虑键的过期时间,只在每次操作键时,检查键是否过期。如果过期就删除,否则返回结果
  • 定期删除:每隔一段时间,程序对数据库进行一次检查,删除里面过期的键。具体删除多少键,检查多少数据由算法决定

redis 使用惰性删除和定期删除,其中定期删除并不会每次扫描所有 key。假设现在保存了十万个包含过期时长的 key,如果每次清理全部遍历一遍的话,CPU 消耗太大,极有可能成为 Redis 性能瓶颈

redis 将每个设置了过期时长的 key 单独放到独立字典中,默认每秒进行 10 次扫描,扫描采用一种贪心的思想:

  1. 从过期字典中随机抽取 20 个 Key
  2. 删除这 20 个 Key 中过期的 Key
  3. 如果删除的 Key 的占比超过 1/4,重复整个循环

从这里可以分析出,设置过期时长的 key 并不一定到期删除。为了防止即使设置过期时长,内存仍不够用的清理。redis 引入内存淘汰策略。当 redis 内存空间已经用完时,根据具体策略执行相应的动作,常见的内存淘汰策略有以下八种:

  1. noeviction:默认策略,报错,不淘汰任何 Key
  2. allkeys-lru:在所有 Key 中,通过 LRU 算法淘汰部分 Key
  3. allkeys-lfu:在所有 Key 中,通过 LFU 算法淘汰部分 Key
  4. allkeys-random:在所有的 key 中,随机淘汰部分 key
  5. volatile-lru:在设置了过期时间的 key 中,通过 LRU 算法淘汰部分 key
  6. volatile-lfu:在设置了过期时间的 key 中,通过 LFU 算法淘汰部分 key
  7. volatile-random:在设置了过期时间的 key 中,随机淘汰部分 key
  8. volatile-ttl:在设置了过期时间的 key 中,挑选 TTL(time to live,剩余时间)短的 key 淘汰

LRU(Least Recently Used) 算法可以清除最久未使用的数据,一般通过队列实现,实现方法如下:

  1. 对于添加操作,每次添加前在 LRU 队尾处新增 Node 节点记录数据
  2. 对于修改操作,每次修改前先修改 LRU 队列 Node 节点并将它移动到队尾
  3. 对于查询操作,每次查询前找到对应 Node 节点并将它移动到队尾
  4. 对于删除操作,先从 LRU 队列中删除对应 Node 节点

通过以上操作维护队列可以保证队头总是最久未使用的元素,队尾总是最新用到的元素,通过从队列头、内存中删除处理最久未使用的元素

Redis LRU 算法经历过两个版本,总的来说都是基于时钟字段:

struct redisServer {
	//全局时钟
	unsigned lruclock:LRU_BITS; 
	...
};
typedef struct redisObject {
	/* key对象内部时钟 */
	unsigned lru:LRU_BITS;
} robj;

该时钟只有 24 位,单位秒。每次操作或查询某个 Key 时,将当前系统时钟赋值给 Key 对象时钟字段,这样就可以通过对象时钟字段和系统时钟字段的差值确定多久未操作,差值越大,说明越久没使用过。一般情况下用系统时钟减去 Key 对象时钟判断,越小说明越近使用过。由于时钟字段只有 24 位,存在对象时钟字段值大于系统时钟字段值的情况,此时改使用加法,越大说明越近使用过

早期 Redis 采用随机选取淘汰法:每次随机选 N 个 Key,将最久没使用的淘汰掉。实现特别简单,但效率较低,每次都需要重新计算,没有用到上轮循环结果,并且由于随机选取的原因,可能更久未使用的 Key 也没有被淘汰

Redis 3.0 对 LRU 算法进行优化,引入缓冲池的概念。每次在遍历被抽取的 Key 时,将所有 Key 的空闲时间保存在缓冲池中,缓冲池采用最大堆的思想,保存着当前空闲时间最大的 n 个 Key,其中 n 表示缓冲池的大小,默认 16。后面随机抽取后,只需将所有 Key 的空闲时间放入缓冲池中,缓冲池按例维护空闲时间最大的 16 个 Key 对象,每次淘汰堆顶元素即可


LRU 算法虽然可以淘汰最近最久未使用的 Key,但部分场景下仍有问题:假设 Key1 一小时内被访问 100 次,Key2 只访问一次,但 Key2 最后才使用,此时根据 LRU 算法 key1 就会被淘汰,然而我们更希望 key2 被淘汰,因为它使用评率更低。在这种场景下 Redis 4.0 引入 LFU(Least frequently used) 策略,采用该策略可以淘汰最不经常使用对象:

LFU 算法将时钟字段划分为两部分,由原来的 24 位改为 16 位表示时钟,8 位表示访问频率。16 位能表示的时钟范围很小,为了扩大范围,这里使用分钟为单位。8位表示频率最大只能到 255,这里使用非线性的方式更新频率值,而是通过一个复杂的公式,其中包含两个新参数:

lfu-log-factor:调整频率 count 增长速率,该值越大,增长越慢
lfu-decay-time:调整频率 count 减少速率

访问 Key 时增长频率源码:

uint8_t LFULogIncr (uint8_t counter) {
	// 已经达到最大值不处理
	if (counter == 255) return 255;
	// 获取随机数
	double r = (double) rand() / RAND_MAX;
	// 当前频率减去初始频率
	double baseval = counter - LFU_INIT_VAL;
	if (baseval < 0) baseval = 0;
	double p = 1.0 / (baseval * server.lfu_log_factor + 1);
	if (r < p) counter++;
	return counter;
}

从代码可以看出:之前的 count 值越大、lfu_log_factor 值越大,计算出的 p 越小,增长的概率越低。为了防止新创建的 Key 淘汰几率过大,默认频率值为 5,当前频率值小于 5 时,每次访问都加 1

当真正需要淘汰某个 Key,计算频率时根据一下源码进行:

unsigned long LFUDecrAndReturn(robj *o) {
	// 通过高16位获取当前 key 的时间
    unsigned long ldt = o -> lru >> 8;
    // 通过低8位获取当前 count 值
    unsigned long counter = o -> lru & 255;
    // 计算要减去的量
    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;
}
// 计算时间差
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now - ldt;
    // 已经超过一个周期,16位 64435
    return 65535 - ldt + now;
}

从代码可以看出,lfu_decay_time 值越大,降低的越小、具体降低量由时钟决定。总的来说,LFU 计算出的结果更准确,因为它在时钟的基础上,引入频率的概念

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值