当 Redis 的可用内存达到 maxmemory 上限时,是否继续接受写入、淘汰哪些键,完全由 maxmemory-policy 决定。很多初学者只知道 LRU 代表最近最少使用、LFU 代表最不经常使用,但把标准算法直接套到 Redis 上就会出现偏差,因为 Redis 为了实现高吞吐和低内存开销,对两者都做了近似实现。

先明确一个前提:Redis 的淘汰策略并不是精确算法。标准 LRU 通常需要双向链表加哈希表来维护严格的访问顺序,标准 LFU 也要为每个键保存频次并构建高效排序结构。如果 Redis 为每个键都维护这些额外信息,内存占用和每次访问的写放大都会明显上升。尤其 Redis 是单线程处理命令,任何高开销操作都会直接拉高延迟。因此它选择在 redisObject 结构体里用一个 24 位的 lru 字段来承载淘汰信息,并通过随机采样降低单次决策成本。
一、先理解 Redis 内存淘汰的整体框架
Redis 触发淘汰的唯一前提是 maxmemory 大于 0 并且当前使用内存达到该阈值。若 maxmemory 设置为 0,在 64 位系统上等同于不设上限,但内存耗尽时仍会被操作系统终止,所以生产环境一般都会显式配置一个上限。当达到上限后,Redis 会按照 maxmemory-policy 执行动作,其中 noeviction 是默认策略,表示直接拒绝可能新增内存的写命令,但读命令和删除命令不受影响。
策略名称带有 volatile 的只能淘汰设置了过期时间的键,带有 allkeys 的则面向所有键。这是一个经常被忽略的区别。如果你的业务缓存键大多数没有设置 TTL,使用 volatile-lru 会因为没有可选淘汰对象而导致写入失败。真正通用的缓存场景应当优先考虑 allkeys-lru 或 allkeys-lfu。
Redis 的淘汰发生在每次执行写命令之前。它先检查内存是否超限,如果超限则从目标键空间中逐出一些键,直到内存回落到阈值以下或没有可淘汰的键。为了不让单个命令卡住太久,逐出过程有循环次数限制,这也是一些大键场景下内存仍然短暂超过 maxmemory 的原因。
二、LRU:近似采样如何逼近真实结果
标准 LRU 的核心是每次访问都把键移动到队头,当容量不足时淘汰队尾键。这种方案在内存对象数量很大时,链表节点和指针会带来不小的额外开销,而且每次访问都要修改链表,写入量较大。Redis 的近似 LRU 随机取若干个键,比较它们最后一次访问时间,淘汰最久未访问的键。
redisObject.lru 在 LRU 策略下保存的是该键最后一次被访问的时间戳,单位是秒。每次读取或写入键时,Redis 都会把当前 UNIX 时间更新到这个字段。淘汰时计算 当前时间 - lru 得到空闲时间,空闲时间最大的键被淘汰。默认采样数量由 maxmemory-samples 控制,值为 5。这个默认值在大多数场景下已经比较接近精确 LRU,但如果键数量非常多且访问模式复杂,可以提高到 10。
Redis 的近似 LRU 还引入了淘汰候选池机制,避免每次随机采样的结果抖动过大。候选池会保留若干空闲时间较长的键,新采样到的键会和候选池中的键共同排序,最终选择空闲时间最大的那个淘汰。以下是一段简化后的采样与候选池填充逻辑:
/* Redis 近似 LRU 淘汰:随机采样并选择空闲时间最大的键 */
int evictionPoolPopulate(dict *sampleDict, dict *keyDict, evictionPoolEntry *pool) {
int count = 0;
while (count < EVPOOL_SIZE) {
dictEntry *de = dictGetRandomKey(sampleDict);
if (de == NULL) break;
unsigned long long idle = estimateObjectIdleTime(dictGetVal(de));
/* 根据空闲时间插入候选池,空闲时间越大排名越靠前 */
insertIntoEvictionPool(pool, de, idle);
count++;
}
return count;
}
可以看到,Redis 不会扫描全部键,也不会维护一条全局 LRU 链表。这种设计牺牲了一点精确性,却换来了更少的内存元数据和更短的执行时间。实际测试中,采样数设为 10 时,近似 LRU 与标准 LRU 的行为重合度已经很高,而开销仍远低于标准实现。
使用 LRU 策略时还需要注意,短时间内批量访问大量冷数据会污染缓存。比如一个定时任务扫过几百万个键,这些键的访问时间被刷新,原本热点的键反而可能因为空闲时间变长而被淘汰。这是 LRU 本身对偶发批量访问比较敏感的表现,也是后来引入 LFU 的一个重要原因。
三、LFU:频次统计与时间衰减的落地方式
LFU 解决的核心问题是:一个键被访问过一次,不代表它以后会被频繁访问。LRU 只看最近时间,无法区分高频热点和偶尔访问的键。标准 LFU 为每个键维护频次计数器,淘汰频次最低的键。但 Redis 不能简单地对每次访问加一,因为长时间运行的旧键会积累极高的频次,新键永远竞争不过旧键。
Redis 在 lru 字段中同时保存了两个信息:高 16 位存最后访问时间,以分钟为单位;低 8 位存频次计数器,范围是 0 到 255。访问键时,频次不会无条件加一,而是按照概率递增。概率函数大致为 1/(counter * lfu_log_factor + 1),lfu_log_factor 默认是 10。计数器越大,增长越慢,这样频次不会无限膨胀。
只增加频次还不够,因为一旦某个键不再被访问,它仍然可能长期保持高频次,占用缓存。于是 Redis 引入了时间衰减机制,由 lfu-decay-time 控制,单位是分钟。默认值为 1,表示键每空闲一分钟,频次就会按一定规则降低一次。这样长期不活跃的旧热点会逐渐让位给新热点。以下是一段简化后的 LFU 频次更新逻辑:
/* Redis 近似 LFU:访问键时按概率增长频次,并随时间衰减 */
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes() << 8) | counter;
}
unsigned long LFULogIncr(unsigned long counter) {
if (counter == 255) return counter;
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;
}
LFU 的缺点是冷启动问题。新写入的键初始频次很低,在内存紧张时可能很快被淘汰,尤其是缓存刚启动或大批量导入数据时。可以通过调小 lfu_log_factor 让频次增长更快,或者使用 volatile-lfu 只淘汰设置了过期时间的键,以保护不过期的元数据类键。
另一个容易误解的地方是,LFU 并不适合所有场景。如果业务访问本身就非常均匀,没有明显高频键,LFU 的频次区分度会很弱,维护频次和衰减反而增加 CPU 开销。此时 LRU 可能更简单可靠。
四、LRU 与 LFU 的对比及调优建议
下面从几个维度对比两种策略:
| 维度 | LRU | LFU |
|---|---|---|
| 淘汰依据 | 最近访问时间 | 访问频次 |
| 核心优势 | 实现简单,对短期热点响应快 | 能保留长期高频键,抗偶发批量访问 |
| 主要劣势 | 批量冷数据访问会污染缓存 | 新键冷启动不友好,计算略复杂 |
| 适用场景 | 读多写少、热点相对集中 | 长期稳定热点、冷数据多但偶发访问 |
如果你不确定该用哪个,可以从 allkeys-lru 开始。这是 Redis 社区多年验证过的通用选择,命中率通常比较稳定。当你观察到某些高频键频繁被换出,或者定期批量任务导致命中率周期性下降时,再切换到 allkeys-lfu 并对比监控数据。切换策略不需要重启,直接修改配置文件或者运行时发送 CONFIG SET maxmemory-policy allkeys-lfu 即可。
调优参数时不要只看命中率这一项,还要关注 Redis 单线程延迟。例如 maxmemory-samples 从 5 提高到 20,确实能让 LRU 更接近精确淘汰,但每次淘汰都要比较更多样本,CPU 消耗会上升。对于 LFU,lfu-log-factor 越大,频次增长越慢,更适合长期运行的缓存;lfu-decay-time 越大,历史频次保留越久,热点切换越慢。
最后要注意,淘汰策略只能缓解内存压力,不能替代容量规划。如果业务数据量长期超过可用内存,无论如何调优都会出现频繁淘汰和命中率下跌。正确做法是根据业务增长预留内存空间,配合过期时间、持久化策略和集群扩容,让淘汰策略只作为突发情况的缓冲,而不是长期超载的遮羞布。