过期策略内存淘汰策略是 Redis 中两个不同的内存管理机制, 它们的目标和触发条件完全不同

过期策略:处理已经设置 TTL 的 key,在 key 过期的时候删除它们(src/expire.c)Redis

内存淘汰策略:当内存达到 maxmemory 限制时,根据策略选择 key 进行删除(src/evict

过期策略

Redis 的过期策略分为 2 种,被动过期和主动过期

被动过期

当用户访问一个 key 的时候,Redis 会检查 key 的状态,检查过期并删除 key

src/db.c:198-254

kvobj *lookupKey(redisDb *db, robj *key, int flags, dictEntryLink *link) {

    kvobj *val = dbFindByLink(db, key->ptr, link);

    if (val) {
        int is_ro_replica = server.masterhost && server.repl_slave_ro;
        int expire_flags = 0;
        if (flags & LOOKUP_WRITE && !is_ro_replica)
            expire_flags |= EXPIRE_FORCE_DELETE_EXPIRED;
        if (flags & LOOKUP_NOEXPIRE)
            expire_flags |= EXPIRE_AVOID_DELETE_EXPIRED;
        if (flags & LOOKUP_ACCESS_EXPIRED)
            expire_flags |= EXPIRE_ALLOW_ACCESS_EXPIRED;
            
        // 过期删除 key
        if (expireIfNeeded(db, key, val, expire_flags) != KEY_VALID) {
            /* The key is no longer valid. */
            val = NULL;
            if (link) *link = NULL;
        }
    }

    ... 

    return val;
}

主动过期

Redis 的主动过期采用 双循环的设计,在后台定期扫描并删除过期的 key,防止内存泄漏

src/server.c:1198-1207 有写,redis 会基于 hz 参数周期性地执行 serverCron() ,该函数调用 activeExpireCycle() 检查和删除过期的键。 hz 默认值为 10,代表每秒执行 10 次。

内存淘汰策略

src/evict

内存淘汰策略略指的是:当内存使用达到 maxmemory 限制时, 用于决定删除哪些键以释放内存的机制。这与过期策略不同, 淘汰策略是被动触发的内存管理手段。

redis.conf 中可以设置 maxmemory ,表示内存达到 maxmemory 字节时根据所选的策略删除 key

如果 Redis 无法根据策略移除键,或者策略设置为 noeviction ,Redis 将开始对那些需要使用更多内存的命令(如 SET、LPUSH 等)返回错误,并继续响应只读命令(如 GET)。

redis 提供了 8 种不同的淘汰策略(src/server.h:658-665):

#define MAXMEMORY_VOLATILE_LRU ((0<<8)|MAXMEMORY_FLAG_LRU)
#define MAXMEMORY_VOLATILE_LFU ((1<<8)|MAXMEMORY_FLAG_LFU)
#define MAXMEMORY_VOLATILE_TTL (2<<8)
#define MAXMEMORY_VOLATILE_RANDOM (3<<8)
#define MAXMEMORY_ALLKEYS_LRU ((4<<8)|MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_LFU ((5<<8)|MAXMEMORY_FLAG_LFU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_RANDOM ((6<<8)|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_NO_EVICTION (7<<8)

只淘汰设置了过期时间的 key(volatile-)

  • volatile-lru:近似 LRU 算法
  • volatile-lfu:近似 LFU 算法
  • volatitle-random:随机淘汰
  • volatile-ttl:优先淘汰 ttl 最短的

针对所有 key(allkeys-)

  • allkeys-lru:近似 LRU 算法
  • allkeys-lfu:近似 LFU 算法
  • allkeys-random:随机淘汰

不淘汰: noeviction , 不淘汰任何键,写操作返回错误

LRU

LRU:最近最少使用。打个比方,每次访问一个东西,把它放到榜首。如果排行榜满了,就把榜尾的东西踢掉,因为它已经很久没被用过了。

特点:记住的是“最近”的使用情况,不关心它被用了多少次。
适用场景:经常访问的网站、经常打开的文件

LFU

LFU:最不经常使用。每次访问一个东西,就给它加一分(增加使用频率)。如果排行榜满了,就把分数最低的东西踢掉。
特点:记住的是 “总共”的使用情况,看谁用的次数最少,不关心它最近有没有被用过。
适用场景:适合那些长期经常使用的数据。

为什么是近似 LRU/LFU

从性能、内存、可扩展性 3 个层面来考量

性能&内存:精确的 LRU/LFU 需要维护一个全局的数据结构来跟踪所有 key 的使用情况,每次访问 key 都要更新这个数据结构,这会带来一部分开销。而且还需要额外的内存来存储每个 key 的信息,这本身就是一种浪费。

可扩展性:精确的 LRU/LFU 很难扩展到大规模的集群环境。

所以 Redis 基于 采样(sampling) 的方式来实现近似 LRU/LFU

核心流程

以近似 LRU 为例

采样与评分

从数据库中随机采样 N 个 key(N = redis.conf的maxmemory-samples

默认采样 5 个 key

根据策略计算每个 key 的“淘汰分数”:

  • LRU:计算空闲时间,空闲时间越长分数越高
  • LFU:计算反向频率(255-频率),频率越低分数越高
  • TTL:使用反向过期时间,越快过期分数越高
    src/evict.c:139-158
/* Calculate the idle time according to the policy. This is called
 * idle just because the code initially handled LRU, but is in fact
 * just a score where a higher score means better candidate. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
    idle = estimateObjectIdleTime(kv);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
    /* When we use an LRU policy, we sort the keys by idle time
     * so that we expire keys starting from greater idle time.
     * However when the policy is an LFU one, we have a frequency
     * estimation, and we want to evict keys with lower frequency
     * first. So inside the pool we put objects using the inverted
     * frequency subtracting the actual frequency to the maximum
     * frequency of 255. */
    idle = 255-LFUDecrAndReturn(kv);
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
    /* In this case the sooner the expire the better. */
    idle = ULLONG_MAX - kvobjGetExpire(kv);
} else {
    serverPanic("Unknown eviction policy in evictionPoolPopulate()");
}

插入淘汰池

redis 维护一个 “淘汰池(EvictionPool)”,里面存放的是适合被淘汰的 key 的候选者。淘汰池按照 升序排序,每次需要淘汰的时候,就从这个池子里面选分数最高的,达到近似的效果。

每个参与评分的 key 都 可能进入到淘汰池:

  • 如果池没满,直接插入池中
  • 如果池满了:将分数更高的 key 插入,替换分数低的

src/evict.c:34-42

#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
    unsigned long long idle;    /* Object idle time (inverse frequency for LFU) */
    sds key;                    /* Key name. */
    sds cached;                 /* Cached SDS object for key name. */
    int dbid;                   /* Key DB number. */
    int slot;                   /* Slot. */
};

删除与传播

src/evict.c:592-619

/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
    if (pool[k].key == NULL) continue;
    bestdbid = pool[k].dbid;

    kvstore *kvs;
    if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
        kvs = server.db[bestdbid].keys;
    } else {
        kvs = server.db[bestdbid].expires;
    }
    de = kvstoreDictFind(kvs, pool[k].slot, pool[k].key);

    /* Remove the entry from the pool. */
    if (pool[k].key != pool[k].cached)
        sdsfree(pool[k].key);
    pool[k].key = NULL;
    pool[k].idle = 0;

    /* If the key exists, is our pick. Otherwise it is
     * a ghost and we need to try the next element. */
    if (de) {
        bestkey = kvobjGetKey(dictGetKV(de));
        break;
    } else {
        /* Ghost... Iterate again. */
    }
}

将高分的 key 从 pool 中删除后,会调用 deleteEvictedKeyAndPropagate() 把 key 从内存中删掉,并传播到从节点。

淘汰由主节点处理,通过 DEL 命令同步到从节点。