【Redis】结合源码,一看就懂的Redis内存淘汰机制

一、前言

Redis 是一个内存数据结构存储系统,主要用于高速缓存、消息队列等场景。由于 Redis 将所有数据存储在内存中,因此内存管理和优化非常重要。

当 Redis 的内存使用达到其配置的最大限制时,就需要采取措施来释放内存,否则 Redis 将无法接受新的写入请求,从而影响应用的性能。

 

二、删除策略

Redis 对于过期的 key,有两种删除策略:

  • 定期删除

  • 惰性删除

Redis 本身是一个典型的 key-value 内存存储数据库,因此所有的 key、value 都保存在 Dict 结构中

不过在其 database 结构体中,有两个 Dict:一个用来记录 key-value;另一个用来记录 key-TTL

typedef struct redisDb{
	dict *dict;						// 存放所有 key 及 value 的地方,也被称为 keyspace
    dict *expires;					// 存放每一个 key 及其对应的 TTL 存活时间,只包含设置了 TTL 的 key
    dict *blocking_keys;			// Keys with clients waiting for data (BLPOP)
    dict *ready_keys;				// Blocked keys that received a PUSH
    dict *watched_keys;				// WATCHED keys for MULTI/EXEC CAS
    int id;							// Database ID,0~15
   	long long avg_ttl;				// 记录平均 TTL 时长
    unsigned long expires_cursor;	// expire 检查时在 dict 中抽样的索引位置
    list *defrag_later;				// 等待碎片整理的 key 列表
} redisDb;

 

1、定期删除:通过一个定时任务,周期性的抽样部分过期的 key,然后执行删除

执行周期有两种:

  • Redis 服务初始化函数 initServer() 中设置定时任务,按照 server.hz 的频率来执行过期 key 清理,模式为 SLOW

  • Redis 的每个事件循环前会调用 beforeSleep() 函数,执行过期 key 清理,模式为 FAST

SLOW 模式规则:

  • 执行频率受 server.hz 影响,默认为10,即每秒执行10次,每个执行周期 100ms

  • 执行清理耗时不超过一次执行周期的25%,默认 slow 模式耗时不超过 25ms

  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期(注意这里是随机抽取的),如果没达到时间上限(25ms)并且过期 key 比例大于 25%,再进行一次抽样,否则结束

FAST 模式规则(过期 key 比例小于25%不执行 ):

  • 执行频率受 beforeSleep() 调用频率影响,但两次 FAST 模式间隔不低于 2ms

  • 执行清理耗时不超过1ms

  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期(注意这里是随机抽取的),如果没达到时间上限(1ms)并且过期 key 比例大于 25%,再进行一次抽样,否则结束

Q:为什么要随机呢?

A:假如 Redis 存了几十万个 key ,每隔 100ms 就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载。

2、惰性删除:并不是在 TTL 到期后就立刻删除,而是在访问一个 key 的时候,检查该 key 的存活时间,如果已经过期才执行删除

 

// 查找一个 key 执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags){
    // 检查 key 是否过期
    expireIfNeeded(db, key);
    return loopupKey(db, key, flags);
}

// 查找一个 key 执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags){
    robj *val;
    // 检查 key 是否过期
    if(expirIfNeeded(db, key) == 1){
        // ...
    }
    return NULL;
}

int expireIfNeeded(redisDb *db, robj *key){
    // 判断是否过期,如果未过期直接结束并返回 0
    if(!keyIsExpired(db, key)) return 0;
    // ...
    // 删除过期 key
    deleteExpiredKeyAndPropagate(db, key);
    return 1;
}

 

三、淘汰策略

已经有定期删除和惰性删除机制了,为什么还需要淘汰策略呢?

1、内存限制

Redis 默认情况下会尽可能多地使用系统可用的内存。然而,在生产环境中,通常会对 Redis 实例设置最大内存限制(通过 maxmemory 配置项),以防止 Redis 占用过多的系统内存资源,影响其他服务的运行。

2、避免 OOM 错误

当 Redis 达到最大内存限制时,如果没有适当的内存淘汰机制,Redis 可能会因为内存不足而拒绝新的写入操作,甚至导致操作系统出现 Out Of Memory (OOM) 错误。内存淘汰策略可以在内存达到上限时自动释放不再需要的数据,从而避免这种情况发生。

3、保证服务可用性

内存淘汰策略可以在内存紧张的情况下,通过牺牲部分数据的持久性来保证 Redis 服务的基本可用性。例如,在内存不足时,可以选择性地删除最近最少使用的键(LRU),从而为新的数据腾出空间。

同时为了在不牺牲一致性的情况下获得正确行为。

当 key 过期时,DEL 操作将同时在 AOF 文件中合成并获取所有附加的从节点,此时过期的这个处理过程集中到主节点中,还没有一致性错误的可能性

但是,存在以下特殊情况,虽然连接到主节点的从节点不会独立过期 key(会等待来自 master 的 DEL),但它们仍将使用数据集中现有过期的完整状态,因此,当切换 slave 作为 master 时,它将能够独立过期 key,完全充当 master

可是,很多过期 key,还没及时去查,定期删除也漏掉了,大量过期 key 堆积内存,Redis 内存殆耗尽!因此还需有内存淘汰机制!

内存淘汰就是当 Redis 内存使用达到设置的上限时,主动挑选部分 key 删除以释放更多内存的流程

Redis 会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰

int processCommand(client *c){
    // 如果服务器设置了 server.maxmemory 属性,并且并未有执行 Lua 脚本
    if(server.maxmemory && !server.lua_timedout){
        // 尝试进行内存淘汰 performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // ...
        if(out_of_memory && reject_cmd_on_oom){
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // ...
    }
}

Redis 支持 8 种不同策略来选择要删除的 key:

  • noeviction: 不淘汰任何 key,但是内存满时不允许写入新数据,默认就是这种策略

  • volatile-ttl: 对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰

  • allkeys-random:对全体 key,随机进行淘汰,也就是直接从 db->dict 中随机挑选

  • volatile-random:对设置了 TTL 的 key ,随机进行淘汰,也就是从 db->expires 中随机挑选

  • allkeys-lru: 对全体 key,基于 LRU 算法进行淘汰

  • volatile-lru: 对设置了 TTL 的 key,基于 LRU 算法进行淘汰

【后面两种是在 Redis 4.0 之后加入的】

  • allkeys-lfu: 对全体 key,基于 LFU 算法进行淘汰

  • volatile-lfu: 对设置了 TTL 的 key,基于 LFU 算法进行淘汰

内存淘汰策略可以通过配置文件来修改,redis.conf 对应的配置项是 maxmemory-policy 修改对应的值就行,默认是 noeviction

其中主要的算法有两种:LRU 和 LFU

以下是 Redis 4.0 的配置文件:

需要修改配置文件,可以直接编辑 redis.conf 文件,也可以使用命令对其进行修改  

config set maxmemory-policy allkeys-lru

 

四、算法简介

1、LRU

LRU(Least Recently Used,即最近最少使用)会将最不常用的数据筛选出来,保留最近频繁使用的数据。

LRU 会把所有数据组成一个链表:

  • 链表头部称为 MRU 代表最近最常使用的数据;

  • 链表尾部称为 LRU 代表最近最不常使用的数据;

采用 LRU 可以淘汰最近不常使用的数据,但是直接使用也会存在一些问题:

LRU 算法在实现过程中使用链表管理所有缓存的数据,这会给 Redis 带来额外的开销,而且,当有数据访问时就会有链表移动操作,进而降低 Redis 的性能。

于是,Redis 对 LRU 的实现进行了一些改变:

  • 记录每个 key 最近一次被访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)

  • 在第一次淘汰数据时,会先随机选择 N 个数据作为一个候选集合,然后淘汰 lru 值最小的(N 可以通过 config set maxmemory-samples 100 命令来配置)

  • 后续再淘汰数据时,会挑选数据进入候选集合,进入集合的条件是:它的 lru 小于候选集合中最小的 lru

  • 如果候选集合中数据个数达到了 maxmemory-samples,Redis 就会将 lru 值小的数据淘汰出去

2、LFU

LFU(Least Frequently Used,即最不经常使用)基于数据访问次数来淘汰数据,在 Redis 4.0 时添加进来。

它在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。

LRU 使用了 RedisObject 中的 lru 字段记录时间戳,lru 是 24bit 的,LFU 将 lru 拆分为两部分:

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳

  • counter 值:lru 字段的后 8bit,表示数据的访问次数

使用 LFU 策略淘汰缓存时,会把访问次数最低的数据淘汰,如果访问次数相同,再根据访问的时间,将访问时间戳最小的淘汰。

为什么 Redis 有了 LRU 还需要 LFU 呢?

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。

当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。

由于 LRU 是基于访问时间的,如果系统对大量数据进行单次查询,这些数据的 lru 值就很大,使用 LFU 算法就不容易被淘汰。

RedisObject 源代码:

typedef struct redisObject {
    unsigned tyep:4;			// 对象类型
    unsigned encoding:4;		// 编码方式
    /**
     *	LRU:以秒为单位记录最近一次访问时间,长度 24 bit
     *	LFU:高 16 位以分钟为单位记录最近一次访问时间,低 8 位记录逻辑访问次数
     *		counter 仅有 8 位很容易就溢出了,技巧是用一个逻辑计数器,给予概率的对数计数器,而不是一个普通的递增计数器
     **/
    unsigned lru:LRU_BITS;
    
    int refcount;				// 引用计数,计数为 0 则可以回收
    void *ptr;					// 数据指针,指向真实数据
} robj;

LFU 的数据结构:  

struct entry {
    /* Field that the LFU Redis implementation will have (we have
     * 24 bits of total space in the object->lru field). */
    uint8_t counter;    /* Logarithmic counter. */
    uint16_t decrtime;  /* (Reduced precision) time of last decrement. */

    /* Fields only useful for visualization. */
    uint64_t hits;      /* Number of real accesses. */
    time_t ctime;       /* Key creation time. */
};

LFU 的访问次数之所以叫做逻辑访问次数,是因为并不是每次 key 被访问都计数,而是通过运算:

  • 如果计数器已经是最大值 255,则不再递增

  • 生产一个随机数 r,介于 0~1 之间

  • 计算了当前计数器值与初始值之间的差值 baseval

  • 如果 baseval 小于 0,则将其设置为 0

  • limit 计算了递增的概率,其值随着 baseval 的增大而减小

  • 如果生成的随机数 r 小于 limit,则计数器递增 1

  • 返回最终的计数器值

#define COUNTER_INIT_VAL 5

/* Increment a couter logaritmically: the greatest is its value, the
 * less likely is that the counter is really incremented.
 * The maximum value of the counter is saturated at 255. */
uint8_t log_incr(uint8_t counter) {
    if (counter == 255) return counter;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter-COUNTER_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double limit = 1.0/(baseval*10+1);
    if (r < limit) counter++;
    return counter;
}

对应的概率分布计算公式为:  

\frac{1.0}{((counter - COUNTER\_INIT\_VAL)*server.lfu\_log\_factor + 1)} 

其中 COUNTER_INIT_VAL 默认为 5,其实简单说就是,越大的数,递增的概率越低

严格按照 LFU 算法,时间越久的 key,counter 越有可能越大,被剔除的可能性就越小

counter 只增长不衰减就无法区分热点 key,为了解决这个问题,redis 提供了衰减因子 server.lfu_decay_time,其单位为分钟

计算方法也很简单,如果一个 key 长时间没有访问那么他的计数器 counter 就要减少,减少的值由衰减因子来控制

/* Simulate an access to an entry. */
void access_entry(struct entry *e) {
    e->counter = log_incr(e->counter);
    e->hits++;
}

 

五、实践

选择合适的内存淘汰策略需要根据实际应用场景和业务需求来决定。以下是一些建议:

  • 数据完整性要求高:选择 noeviction 策略。

  • 缓存场景,希望保留最新、最常使用的数据:选择 allkeys-lruvolatile-lru 策略。

  • 数据新鲜度要求不高,需要快速响应:选择 allkeys-randomvolatile-random 策略。

  • 希望优先删除即将过期的数据:选择 volatile-ttl 策略。

  • 希望删除最不频繁使用的数据:选择 allkeys-lfuvolatile-lfu 策略。

通过合理选择和配置内存淘汰策略,可以确保 Redis 在内存受限的情况下仍能提供稳定的服务。

  • noeviction

    • 描述:当 Redis 达到最大内存限制时,任何写入操作都会返回错误,并拒绝执行。

    • 适用场景:适用于严格要求数据完整性的场景,不允许丢失任何数据。

  • volatile-ttl

    • 描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰。

    • 适用场景:适用于希望优先删除即将过期的数据。

  • allkeys-random

    • 描述:当 Redis 达到最大内存限制时,对全体 key,随机进行淘汰。

    • 适用场景:适用于对数据的新鲜度要求不高,但需要快速响应的场景。

  • volatile-random

    • 描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,随机进行淘汰。

    • 适用场景:适用于希望优先删除即将过期的数据,但对数据的新鲜度要求不高。

  • allkeys-lru

    • 描述:当 Redis 达到最大内存限制时,对全体 key,基于 LRU(最近最少使用)算法进行淘汰。

    • 适用场景:适用于缓存场景,希望删除最近最少使用的数据。

  • volatile-lru

    • 描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,基于 LRU 算法进行淘汰。

    • 适用场景:适用于希望优先删除即将过期的数据,保留持久的数据。

  • allkeys-lfu

    • 描述:当 Redis 达到最大内存限制时,对全体 key,基于 LFU(最不频繁使用)算法进行淘汰。

    • 适用场景:适用于希望删除最不频繁使用的数据。

  • volatile-lfu

    • 描述:当 Redis 达到最大内存限制时,对设置了 TTL 的 key,基于 LFU 算法进行淘汰。

    • 适用场景:适用于希望优先删除即将过期的数据,并且希望删除最不频繁使用的数据。

 

参考文章:

Redis 内存淘汰机制详解 

绝对能让你彻底明白的Redis的内存淘汰策略 

Redis的过期策略和内存淘汰策略及LRU算法详解 

高效利用内存资源:掌握 Redis 内存管理与淘汰策略 

 

一  叶  知  秋,奥  妙  玄  心 

  • 8
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qx_java_1024

祝老板生意兴隆,财源广进!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值