第 25 章

过期删除与内存淘汰:8种策略深解

第25章 过期删除与内存淘汰:8种策略深解

Redis 的内存管理涉及两个独立但互补的机制:过期删除(管理有 TTL 的 key 的生命周期)和内存淘汰(maxmemory 达到上限时的驱逐策略)。本章从源码层面深度分析这两个机制的实现原理、算法细节和工程权衡。


25.1 过期删除的三张存储结构

expires 字典

每个 redisDb 包含两个并行字典:

// server.h
typedef struct redisDb {
    dict *dict;       // 主数据字典:key → redisObject
    dict *expires;    // 过期字典:key → 过期时间戳(毫秒,long long)
    dict *blocking_keys;
    dict *watched_keys;
    int id;
    long long avg_ttl;        // 平均 TTL(用于统计)
    unsigned long expires_cursor; // activeExpireCycle 的扫描游标
    list *ready_keys;
} redisDb;

关键设计

时间戳的存储方式

// expire.c
void expireGenericCommand(client *c, long long basetime, int unit) {
    // 将相对时间转换为绝对时间戳(毫秒)
    long long when = unit == UNIT_SECONDS
                     ? basetime + (long long)c->argv[2]->ptr * 1000
                     : basetime + (long long)c->argv[2]->ptr;

    // 写入 expires 字典
    setExpire(c, c->db, c->argv[1], when);
}

void setExpire(client *c, redisDb *db, robj *key, long long when) {
    dictEntry *kde, *de;

    // 必须先确认 key 存在于主字典
    kde = dictFind(db->dict, key->ptr);
    serverAssertWithInfo(NULL, key, kde != NULL);

    de = dictAddOrFind(db->expires, dictGetKey(kde));
    // value 直接存时间戳,不封装成 robj
    dictSetSignedIntegerVal(de, when);

    // 通知 key space(如果开启了 notify-keyspace-events)
    int writable_slave = server.masterhost && server.repl_slave_lazy_flush;
    if (c && !writable_slave && server.notify_keyspace_events) {
        notifyKeyspaceEvent(NOTIFY_GENERIC, "expire", key, db->id);
    }
}

25.2 惰性删除:expireIfNeeded

每次读或写 key 时,Redis 都会先调用 expireIfNeeded 检查是否过期:

// db.c
int expireIfNeeded(redisDb *db, robj *key, int flags) {
    if (!keyIsExpired(db, key)) return 0;

    // 从库不主动删除(等主库的 DEL 命令),除非设置了 slave-read-only no
    if (server.masterhost != NULL) {
        if (server.current_client &&
            server.current_client->flags & CLIENT_MASTER) return 0;
        // 从库只在自己的读请求中跳过过期 key(返回空),不删除
        if (!(flags & EXPIRE_FORCE_DELETE_EXPIRED)) return 1;
    }

    // 更新统计
    server.stat_expiredkeys++;

    // 传播到从库和 AOF(主库执行删除时)
    propagateExpire(db, key, server.lazyfree_lazy_expire);

    notifyKeyspaceEvent(NOTIFY_GENERIC, "expired", key, db->id);

    int deleted;
    if (server.lazyfree_lazy_expire) {
        // 惰性过期:异步删除(UNLINK 语义)
        deleted = dbAsyncDelete(db, key);
    } else {
        // 同步删除
        deleted = dbSyncDelete(db, key);
    }

    if (deleted) notifyKeyspaceEvent(NOTIFY_GENERIC, "del", key, db->id);
    return deleted;
}

// 判断 key 是否已过期
int keyIsExpired(redisDb *db, robj *key) {
    mstime_t when = getExpire(db, key);
    mstime_t now;

    if (when < 0) return 0;  // key 无过期时间

    // 如果正在加载 RDB/AOF,使用加载时的时间(避免误删历史数据)
    if (server.loading) return 0;

    // 如果正在执行 Lua 脚本,使用脚本开始时的时间(保证脚本内一致性)
    if (server.script_caller)
        now = server.lua_time_start;
    else if (server.fixed_time_expire > 0)
        now = server.mstime;  // 事务中使用固定时间
    else
        now = mstime();

    return now > when;
}

惰性删除的缺点


25.3 定期删除:activeExpireCycle

activeExpireCycle 是 Redis 主动扫描并删除过期 key 的核心函数,由 serverCron 驱动:

// expire.c
#define ACTIVE_EXPIRE_CYCLE_SLOW    0  // 慢速模式(serverCron 每次执行,25% CPU 上限)
#define ACTIVE_EXPIRE_CYCLE_FAST    1  // 快速模式(beforeSleep 执行,1ms 时间上限)

void activeExpireCycle(int type) {
    // 时间限制(防止过期扫描占用过多 CPU)
    unsigned long
        effort = server.active_expire_effort - 1, /* 0-9, 默认0 */
        config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP + /* 20 */
                               ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4 * effort,
        config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +    /* 1000µs */
                                     ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4 * effort,
        config_cycle_slow_duration = ACTIVE_EXPIRE_CYCLE_SLOW_DURATION,     /* 25% CPU */
        config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE; /* 10% */

    long long start = ustime(), timelimit, elapsed;

    // 计算本次允许的时间上限
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        timelimit = config_cycle_fast_duration;
    } else {
        // 慢速模式:占用不超过 ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC% CPU
        // 默认 25%,即每次最多 250ms(如果 serverCron 每秒10次的话)
        timelimit = 1000000 * ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC / server.hz / 100;
    }

    // 遍历所有 DB
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        redisDb *db = server.db + (current_db % server.dbnum);
        current_db++;

        do {
            // 快速路径:如果 expires 字典为空,跳过
            if (dictSize(db->expires) == 0) break;

            unsigned long num = dictSize(db->expires);
            unsigned long slots = dictSlots(db->expires);

            // 如果 expires 字典填充率很低,跳过(避免空扫描)
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num * 100 / slots < 1)) break;

            expired = 0;
            sampled = 0;
            long long ttl_sum = 0;
            int ttl_samples = 0;

            // 随机采样 config_keys_per_loop(默认20)个 key
            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            while (sampled < num) {
                dictEntry *de;
                long long ttl;

                // 随机获取一个 expires 字典中的 entry
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                ttl = dictGetSignedIntegerVal(de) - now;

                // 检查是否过期并删除
                if (activeExpireCycleTryExpire(db, de, now)) expired++;

                if (ttl > 0) {
                    ttl_sum += ttl;
                    ttl_samples++;
                }
                sampled++;
            }

            // 更新平均 TTL 统计
            if (ttl_samples) db->avg_ttl = ttl_sum / ttl_samples;

            // 如果过期比例 > config_cycle_acceptable_stale(默认10%)
            // 继续采样(更激进地清理)
        } while (sampled == 0 ||
                 (expired * 100 / sampled) > config_cycle_acceptable_stale);

        // 超时检查
        elapsed = ustime() - start;
        if (elapsed > timelimit) {
            timelimit_exit = 1;
            server.stat_expire_cycle_time_used += elapsed;
            break;
        }
    }
}

activeExpireCycleTryExpire:实际删除

static int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key, sdslen(key));

        propagateExpire(db, keyobj, server.lazyfree_lazy_expire);
        if (server.lazyfree_lazy_expire) {
            dbAsyncDelete(db, keyobj);
        } else {
            dbSyncDelete(db, keyobj);
        }
        notifyKeyspaceEvent(NOTIFY_GENERIC, "expired", keyobj, db->id);
        decrRefCount(keyobj);
        server.stat_expiredkeys++;
        return 1;
    }
    return 0;
}

hz 配置对过期精度的影响

# redis.conf
hz 10          # 默认:每秒10次 serverCron = 过期扫描最低精度 100ms
hz 100         # 高精度:每秒100次,过期延迟最低 10ms,但 CPU 占用上升
dynamic-hz yes # 动态 hz:有大量客户端时自动提升(最高 500Hz)

实测数据


25.4 从库的过期处理

从库的过期处理有一个重要的特殊性:

// 从库不主动删除过期 key
// 只有当主库发送 DEL/UNLINK 命令时,从库才删除

// 从库读取过期 key 时:
// - 返回空值(对客户端表现为 key 不存在)
// - 但 key 实际还在内存中,等待主库的 DEL 命令
// 这种设计保证了主从的最终一致性

// expireIfNeeded 中的从库逻辑
if (server.masterhost != NULL) {
    // 从库:不删除,但告诉调用者这个 key 已过期
    if (!(flags & EXPIRE_FORCE_DELETE_EXPIRED)) return 1;
}

从库过期问题的实际影响


25.5 内存淘汰机制:触发条件

当 Redis 的 used_memory 达到 maxmemory 时,每次新的写命令执行前触发内存淘汰:

// server.c — processCommand
int processCommand(client *c) {
    // ...
    if (server.maxmemory && !server.loading) {
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // 如果淘汰失败(noeviction 或无法淘汰)→ 返回错误
        if (out_of_memory && isCommandDeniedInOOM(c)) {
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }
    // 继续执行命令...
}

25.6 8种内存淘汰策略

策略总览

策略 淘汰范围 算法 最佳使用场景
noeviction 不淘汰 数据库型应用,数据不可丢失
allkeys-lru 所有 key LRU 近似 通用缓存(推荐)
allkeys-lfu 所有 key LFU Morris 热点不均匀的缓存
allkeys-random 所有 key 随机 访问均匀,不关心热点
volatile-lru 有 TTL 的 key LRU 近似 缓存与持久数据混存
volatile-lfu 有 TTL 的 key LFU Morris 混存 + 热点明显
volatile-random 有 TTL 的 key 随机 混存,访问均匀
volatile-ttl 有 TTL 的 key TTL 最小值 优先淘汰即将过期的 key

performEvictions 核心流程

// evict.c
int performEvictions(void) {
    if (!isSafeToPerformEvictions()) return EVICT_OK;

    // 检查是否需要淘汰
    size_t mem_reported, mem_tofree;
    if (getMaxmemoryState(&mem_reported, NULL, &mem_tofree, NULL) == C_OK)
        return EVICT_OK;  // 内存充足,不需要淘汰

    int keys_freed = 0;
    while (mem_tofree > 0) {
        // 寻找候选 key(依据策略)
        bestkey = NULL;
        server.eviction_pool_size = 0;

        for (int j = 0; j < server.dbnum; j++) {
            redisDb *db = server.db + j;
            dict *dict;

            // 根据策略选择字典范围
            if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
                dict = db->dict;          // 所有 key
            } else {
                dict = db->expires;       // 仅有 TTL 的 key
            }
            if (dictSize(dict) == 0) continue;

            // 根据具体策略填充候选池
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
                evictionPoolPopulate(j, dict, db->dict, pool);
            } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                evictionPoolPopulate(j, dict, db->dict, pool);
            } else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) {
                dictEntry *de = dictGetRandomKey(dict);
                bestkey = dictGetKey(de);
                bestdbid = j;
            } else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM) {
                dictEntry *de = dictGetRandomKey(dict);
                bestkey = dictGetKey(de);
                bestdbid = j;
            } else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
                // 找 TTL 最小的 key
                // 随机采样 maxmemory_samples 个,选 TTL 最小的
            }
        }

        // 从候选池中选出最佳 key
        if (bestkey == NULL && pool[0].key != NULL) {
            // 从 eviction pool 中取分数最低的(最适合被淘汰的)
            bestkey = pool[EVPOOL_SIZE-1].key;
        }

        if (bestkey) {
            // 执行淘汰
            robj *keyobj = createStringObject(bestkey, sdslen(bestkey));
            propagateExpire(db, keyobj, server.lazyfree_lazy_eviction);
            if (server.lazyfree_lazy_eviction) {
                dbAsyncDelete(db, keyobj);
            } else {
                dbSyncDelete(db, keyobj);
            }
            server.stat_evictedkeys++;
            mem_tofree -= estimatedSize;
            keys_freed++;
        }

        if (mem_tofree <= 0 || bestkey == NULL) break;
    }

    return keys_freed > 0 ? EVICT_OK : EVICT_FAIL;
}

25.7 LRU 近似算法:Redis 的独特设计

为什么不用精确 LRU

精确 LRU 需要一个按访问时间有序的双向链表,每次访问都要调整链表,开销是 O(1) 但内存开销大:

每个 key 的精确 LRU 链表节点:2个指针(16字节)
100万个 key:额外 16MB 内存
更严重:指针修改的 cache miss 开销

Redis 的近似 LRU

// 每个 redisObject 的 lru 字段(24bit)存储 LRU 时钟值
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:24;   // LRU 时钟(秒,24bit,低位截断,约194天循环)
    int refcount;
    void *ptr;
} robj;

// LRU 时钟更新(server.lruclock,每次 serverCron 更新)
#define LRU_CLOCK_MAX ((1<<24)-1)  // 16777215 秒
#define LRU_CLOCK_RESOLUTION 1000  // 1秒精度

unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

// 计算 key 的空闲时间(毫秒)
unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        // 时钟回绕(24bit 溢出,194天后)
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
    }
}

evictionPoolPopulate:近似 LRU 采样

// evict.c
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict,
                          struct evictionPoolEntry *pool) {
    int j, k, count;
    dictEntry *samples[server.maxmemory_samples];  // 默认5个

    // 随机采样 maxmemory_samples 个 key
    count = dictGetSomeKeys(sampledict, samples, server.maxmemory_samples);

    for (j = 0; j < count; j++) {
        unsigned long long idle;
        sds key;
        robj *o;
        dictEntry *de;

        de = samples[j];
        key = dictGetKey(de);

        // 在 keydict(主字典)中找到 value(eviction pool 可能来自 expires dict)
        if (sampledict != keydict) de = dictFind(keydict, key);
        o = dictGetVal(de);

        // 计算"空闲时间"分数(LRU 或 LFU 都用此接口,但含义不同)
        if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
            idle = estimateObjectIdleTime(o);
        } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
            // LFU 模式:空闲时间分数 = 255 - lfu_freq
            // 频率越低 → idle 分数越高 → 越先被淘汰
            idle = 255 - LFUDecrAndReturn(o);
        } else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
            // TTL 越小 → idle 分数越高
            idle = ULLONG_MAX - (long)dictGetVal(de);
        }

        // 插入 eviction pool(按 idle 升序,末尾 idle 最大即最应淘汰)
        // pool 大小 EVPOOL_SIZE = 16
        k = 0;
        while (k < EVPOOL_SIZE && pool[k].key &&
               pool[k].idle < idle) k++;

        if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) continue; // pool已满且新entry idle最小

        // 将新entry插入合适位置,移动其他entry
        // ...
    }
}

近似 LRU 的精度


25.8 LFU Morris 计数器:Redis 4.0 的创新

传统计数的问题

纯计数器的 LFU 会导致:

Redis 的 LFU 实现

Redis 复用 redisObject.lru 字段(24bit)作为 LFU 计数器:

// lfu 字段布局(24bit):
// 高16bit = 最后访问时间(分钟,低16位截断,约45天循环)
// 低8bit  = Morris 计数器(0–255)

#define LFU_INIT_VAL 5  // 新 key 的初始计数值

// 获取 LFU 频率(同时执行时间衰减)
uint8_t LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;      // 高16bit:最后访问分钟
    unsigned long counter = o->lru & 255; // 低8bit:Morris 计数

    // 计算需要衰减的量
    // lfu-decay-time(默认1分钟):每经过 lfu-decay-time 分钟,计数减1
    unsigned long num_periods = LFUTimeElapsed(ldt) / server.lfu_decay_time;
    if (num_periods) {
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    }
    return counter;
}

// LFUTimeElapsed:计算距上次访问经过了多少分钟
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now - ldt;
    return 65535 - ldt + now;  // 处理16bit回绕(45天)
}

// Morris 计数器递增(概率性增加)
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;  // 已到最大值
    double r = (double)rand() / RAND_MAX;
    // 递增概率 = 1 / (counter * lfu-log-factor + 1)
    // 计数越大,递增概率越低 → 防止计数无限增长
    double p = 1.0 / (counter * server.lfu_log_factor + 1);
    if (r < p) counter++;
    return counter;
}

访问时更新 LFU

// object.c — lookupKey 调用后执行
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes() << 8) | counter;
}

LFU 配置

# redis.conf

# LFU 计数器衰减速度:每 lfu-decay-time 分钟衰减1(默认1)
lfu-decay-time 1

# LFU 计数增长速度因子(默认10)
# 因子越大 → 计数增长越慢 → 需要更多访问才能达到高计数
# 因子越小 → 计数增长越快 → 更容易达到最大值255
lfu-log-factor 10

lfu-log-factor 对应的访问次数与计数关系

lfu-log-factor 100次访问到达的计数值 1000次访问到达的计数值
0 104 255
1 18 200
10 10 18
100 8 11

25.9 淘汰策略选择指南

不同业务场景的建议

# 场景1:纯缓存(所有 key 可淘汰)
maxmemory-policy allkeys-lru
# 优点:自动淘汰最久未访问的 key,无需设置 TTL

# 场景2:热点不均匀的缓存(少量 key 被频繁访问)
maxmemory-policy allkeys-lfu
# 优点:LFU 比 LRU 更准确地识别真正的热点

# 场景3:缓存与持久数据混存
maxmemory-policy volatile-lru  # 或 volatile-lfu
# 缓存 key 设 TTL,持久数据不设 TTL
# 淘汰时只淘汰有 TTL 的缓存 key,保留持久数据

# 场景4:数据库类应用(不允许丢失数据)
maxmemory-policy noeviction
# 写满后新写入命令返回 OOM 错误,由应用层处理

# 场景5:临时数据(优先淘汰即将过期的)
maxmemory-policy volatile-ttl
# 适合 session、验证码等短 TTL 数据

maxmemory 设置建议

# 建议设置为物理内存的 70–80%(预留给 OS、fork 等)
maxmemory 6gb          # 8GB 内存的机器

# 集群模式:每个节点单独设置 maxmemory
# 建议 = 总内存 / 主节点数(不含从节点内存)

# 主从模式:主库设 maxmemory,从库不设(或设更大值)
# 从库的 output buffer 不计入 maxmemory

# 查看当前内存使用
redis-cli INFO memory | grep "used_memory:"
redis-cli INFO memory | grep "maxmemory:"

25.10 监控与调优

过期删除监控

# 查看过期 key 统计
redis-cli INFO stats | grep expired
# expired_keys:12345678  # 累计删除的过期 key 数量

# 查看每秒过期速率(结合 INFO stats + 时间差计算)
redis-cli --stat | grep expired

# 主动触发过期扫描(调试用)
redis-cli DEBUG SLEEP 0  # 触发一次 serverCron 而不等待

淘汰统计

redis-cli INFO stats | grep evicted
# evicted_keys:0  # 累计淘汰的 key 数量

# 实时监控淘汰速率
redis-cli MONITOR  # 观察 DEL 命令频率

# keyspace 统计
redis-cli INFO keyspace
# db0:keys=1000000,expires=500000,avg_ttl=3600000

调优参数

# 提高过期扫描精度(默认10,可调至100)
hz 100
dynamic-hz yes

# 提高过期扫描力度(0-9,默认0)
active-expire-effort 5

# 提高 LRU 采样数(默认5,调至10可显著提升精度)
maxmemory-samples 10

# 开启 lazyfree 加速过期删除
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes

本章小结

本章评分
4.7  / 5  (5 评分)

💬 留言讨论