本文按照"过期时间怎么存 → 过期 key 怎么删 → 内存满了怎么办 → 淘汰谁最合理 → LRU 和 LFU 在 Redis 里到底怎么实现"的思路,把 Redis 的过期删除策略和内存淘汰策略这条主线彻底讲清楚。这也是前几篇 Redis 文章(持久化、主从哨兵、Cluster)之后,回到单机视角的一篇基础但很关键的补充。


一、从"到期就删除"说起:过期时间到底是怎么实现的

在业务里我们经常会给 Redis 的 key 设置一个过期时间,比如登录 Token 30 分钟失效、验证码 5 分钟失效、限时活动缓存 1 小时失效。这些场景都依赖 Redis 提供的过期机制。

Redis 提供了一系列命令来设置过期时间:

  • expire <key> <n>:设置 key 在 n 秒后过期,比如 expire key 100 表示 key 在 100 秒后过期。
  • pexpire <key> <n>:设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示 key2 在 100000 毫秒(100 秒)后过期。
  • expireat <key> <n>:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期。
  • pexpireat <key> <n>:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期。

当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:

  • set <key> <value> ex <n>:设置键值对的时候,同时指定过期时间(精确到秒)。
  • set <key> <value> px <n>:设置键值对的时候,同时指定过期时间(精确到毫秒)。
  • setex <key> <n> <value>:设置键值对的时候,同时指定过期时间(精确到秒)。

1.1 过期字典:Redis 是怎么记住 key 的过期时间的

那么这个过期时间在 Redis 内部是怎么实现的呢?实际上,Redis 会把设置了过期时间的 key 放入一个过期字典里面。这个过期字典存储了 key 值和 value 值,value 就是对应的过期时间,结构如下:

typedef struct redisDb {
    dict *dict;    /* 数据库键空间,存放着所有的键值对 */
    dict *expires; /* 键的过期时间 */
    ....
} redisDb;

可以看到,redisDb 结构里维护了两个 dict

  • dict:存放所有键值对的数据库键空间。
  • expires:专门存放设置了过期时间的 key 及其过期时间。

这两个字段本质上都是哈希表,可以实现 O(1) 的快速查找。过期字典的存在意味着:Redis 不需要在每个 key 上单独挂一个过期字段,而是把"是否过期"这件事独立出来管理,这样未设置过期时间的 key 就完全不会受影响。

1.2 读命令进来时怎么判断过期

有了过期字典之后,当有读命令进来时,Redis 会先查询过期字典,看看这个 key 有没有设置过期时间:

  • 没有:说明这个 key 是持久保留的,直接正常读取就好了。
  • :需要对比当前的系统时间判断是否过期。如果过期了就要进行删除。

其实我现在描述的这种情况,就是对应的惰性删除了——也就是当访问对应的 key 的时候才去判断是否过期,过期再删除。这个流程可以用下图表示:

不存在

存在

未过期

已过期

客户端发起读/写命令

在 dict 中查找 key

在 expires 字典中
是否存在该 key?

正常处理命令并返回

当前系统时间
是否超过过期时间?

删除该 key

返回 nil 表示 key 不存在

这个流程非常自然,但围绕"什么时候去删"这件事,Redis 实际上设计了不止一种策略。下面我们就来详细讲讲三种过期删除策略。


二、过期删除的三种策略

回到一开始的问题:key 设置了过期时间,真的会到期就删除吗?

如果是"到期立刻删除",那就意味着 Redis 必须有一个机制去实时监控每一个 key 的过期时间,到期立刻触发删除。这种做法对 CPU 的开销会过于大——想象一下,如果实例里有几百万个带 TTL 的 key,Redis 主线程光顾着检查过期都来不及,还怎么处理客户端的命令?

所以 Redis 不会真的"到期就删"。它一共设计了三种过期删除策略,每种策略都是在 CPU 开销和内存占用之间做不同的取舍。

2.1 惰性删除

惰性删除的核心思路是:不主动检查,等到访问的时候再判断

每次客户端访问某个 key 时,Redis 会顺便查一下过期字典,看看这个 key 是否已经过期。如果过期了就删掉,并返回 nil;如果没过期就正常处理命令。

优点:开销很小,不需要定时或者实时扫描,对 CPU 非常友好,很节省对应的 CPU 开销。

缺点:对于某些访问频率比较低的 key,它会在过期之后滞留在内存中比较久的时间,比较占用内存。极端情况下,如果一些 key 设置了过期时间之后再也没被访问过,它们就会一直留在内存里,直到触发内存淘汰策略才被清掉。

2.2 定时删除

定时删除的核心思路是:给每个设置了 TTL 的 key 单独安排一个定时事件

当我们对于一个 key 设置了对应的 TTL,就开启一个定时事件(Timer),当时间到达的时候,由事件处理器自动执行 key 的删除操作。

优点:显而易见,这种策略下过期 key 不会长时间滞留在内存中,可以让内存尽快地释放,内存占用最友好。

缺点:也很明显。如果同时设置了很多个 key 的过期时间很接近(比如大批量缓存同时失效),在某个时间窗口内会有大量的定时事件同时启动,会占据一定量的 CPU 时间,这显然影响服务器的整体吞吐量和响应时间。而且每个 key 都要维护一个定时事件,本身的资源开销也不小。

2.3 定期删除

定期删除的核心思路是:周期性地随机取出一定量的 key 进行检查,看看是否过期,如果过期则进行删除

它是对惰性删除和定时删除的一种折中:

  • 不像惰性删除那样完全被动,至少会主动扫描一部分 key。
  • 不像定时删除那样每个 key 都要维护一个 Timer,也不存在某个时间点集中触发的问题。

优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据,减少了过期键对内存空间的无效占用。

缺点

  • 内存清理效果不如定时策略好,可能有些 key 已经过期但是还是会遗留比较久的时间。
  • 占用的 CPU 时间也是有一定量的,系统占用相比惰性删除来说要多。
  • 很难界定这个周期时间和随机抽取的 key 的量。如果频繁则系统占用资源多,如果不频繁或者 key 量少,内存清理又不到位。所以这是一个比较难确定的值。

2.4 三种策略对比

可以用一张表把三种策略的取舍总结清楚:

策略 触发时机 CPU 开销 内存友好度 实现复杂度
惰性删除 访问 key 时才判断 极低(被动触发) 差(过期 key 可能长期滞留)
定时删除 到期时刻自动触发 高(大量定时事件可能集中触发) 最好(到期即删) 高(维护大量 Timer)
定期删除 周期性随机抽样 中等(可控) 中等(部分过期 key 会被遗留)

2.5 Redis 的最终选择:惰性 + 定期

显然每种策略都有各自的优缺点,单独使用哪一种都不够完美。所以 Redis 选择组合使用惰性删除 + 定期删除,以求在 CPU 资源占用和避免内存浪费之间取得平衡:

  • 惰性删除作为兜底:保证每次访问 key 时拿到的数据一定是有效的,不会把过期数据返回给客户端。
  • 定期删除作为补充:周期性清理掉一部分长期没被访问的过期 key,避免它们长期占用内存。

下面分别讲一下这两种策略在 Redis 中的具体实现细节。


三、Redis 中惰性删除和定期删除的具体实现

3.1 惰性删除:expireIfNeeded 函数

在 Redis 中,惰性删除是由 db.c 文件中的 expireIfNeeded 函数实现的,核心代码如下:

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断 key 是否过期
    if (!keyIsExpired(db,key)) return 0;
    ....
    /* 删除过期键 */
    ....
    // 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
    return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                         dbSyncDelete(db,key);
}

这段代码做的事情很清晰:

  1. 先调用 keyIsExpired 判断 key 是否过期,如果没过期就直接返回 0,不做任何处理。
  2. 如果过期了,再根据配置文件中的 lazyfree_lazy_expire 配置决定删除方式:
    • 配置为 1:异步删除(dbAsyncDelete),把实际释放内存的工作交给后台线程,避免主线程阻塞。
    • 配置为 0:同步删除(dbSyncDelete),主线程直接释放内存。

需要注意的是,无论是同步还是异步删除,对客户端的表现是一致的——key 已经不存在了,后续访问会返回 nil。异步删除更多是为了在删除大 key 时避免主线程卡顿而设计的优化,并不影响惰性删除本身的语义。

3.2 定期删除:activeExpireCycle 函数

定期删除的实现位于 expire.c 文件下的 activeExpireCycle 函数中。这里有两个关键配置:

  • 执行频率:通过 Redis 配置文件 redis.conf 中的 hz 参数控制,默认值是 hz 10,也就是每 10 秒执行一次(严格来说 hz 表示每秒执行后台任务的次数,定期删除会按这个周期被调度)。
  • 每轮随机抽取的数量:由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义,写死在代码中,数值是 20。也就是说每次会随机抽取 20 个设置了过期时间的 key 进行检查。

3.3 不止"抽 20 个"那么简单:循环删除 + 双重限制

但定期删除并不是"抽 20 个、删完就走"这么简单。如果只是机械地每轮抽 20 个,当数据库里有大量过期 key 时,光靠这个频率根本清理不过来,内存里会长期堆积大量过期数据。

所以 Redis 设计了一套循环删除 + 双重限制的策略,核心逻辑是:

  1. 随机抽取 20 个 key 进行判断,如果过期就删除,并统计本轮删除的数量 expired
  2. 判断本轮抽取的过期数量是否大于抽取数量的 25%(即 expired > 20 / 4 = 5)。
    • 如果大于 25%:说明此时数据库中很可能有大量 key 都过期了,需要继续清理,于是再进行一轮抽取和删除。
    • 如果小于等于 25%:说明过期 key 已经不多了,本轮检查结束。
  3. 同时对这个循环删除操作加上一个时间限制:当循环删除的累计时间超过了 25ms,就强制退出,不再进行下一轮循环,即使过期 key 还是超过 25% 也要结束。

这个流程如下图所示:

定期删除周期触发

本轮: 随机抽取 20 个 key
expired = 0

逐个判断是否过期
过期的删除并 expired++

累计耗时 > 25ms ?

强制退出本轮检查

expired > 20/4 ?

说明过期 key 较多
继续下一轮

本轮检查结束

3.4 为什么要设计成循环删除

这里有几个细节值得展开讲一下。

为什么要用 25% 作为阈值?

因为我们是对过期字典做随机抽样,如果抽出来的样本里过期 key 的比例很高(超过 25%),就可以合理推断整个数据库中过期 key 的比例也很高。这时候单轮清理已经不够了,必须多清几轮,否则内存浪费会持续累积。反过来说,如果抽样比例已经低于 25%,说明过期 key 不多了,再继续清理性价比就很低,不如等下一轮周期再来。

为什么要加 25ms 的时间限制?

这个限制是为了防止"非常倒霉"的情况——如果运气实在太差,每一轮抽样都刚好抽到大量过期 key,循环就会一直跑下去,把主线程卡死,这显然违背了定期删除"不影响主业务"的设计初衷。所以加了一个硬上限:不管过期 key 还有多少,只要累计耗时超过 25ms,就必须让出 CPU

这套设计的核心思想是:通过循环删除尽快清理大量过期 key,但通过双重限制(比例阈值 + 时间上限)确保不会过度占用 CPU,从而在内存浪费和资源占用之间取得平衡。这样即使出现大量 key 同时过期的情况,内存浪费过多的现象也不会维持很久。

3.5 定期删除的伪代码

把上面的逻辑用伪代码总结一下:

do {
    // 已过期的数量
    expired = 0;
    // 随机抽取的数量
    num = 20;

    while (num--) {
        // 1. 从过期字典中随机抽取 1 个 key
        // 2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
    }

    // 超过时间限制则退出
    if (timelimit_exit) return;

    /* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4);

四、从过期删除到内存淘汰:内存是有上限的

讲到这里,过期删除策略已经能把"过期 key 不会长期滞留"这件事做得比较好了。但这只是问题的一面。

Redis 是存储在内存中的,而内存是有限且昂贵的资源。为了防止 Redis 无限制地吃内存导致服务器宕机,Redis 允许为实例设置一个内存上限

# redis.conf
maxmemory <bytes>

只有在 Redis 的运行内存达到了我们设置的最大运行内存时,才会触发内存淘汰策略。不同位数的操作系统,maxmemory 的默认值是不同的:

  • 64 位系统:默认值是 0,意味着没有上限,Redis 会一直吃内存,直到系统崩溃无法运行为止。
  • 32 位系统:默认值是 3GB,因为 32 位的机器最大只支持 4GB 内存,系统本身也需要一定的内存,所以默认设置成 3GB 是一个比较合理的数字。

显然在生产环境中,64 位机器也不能放任 maxmemory = 0,必须显式设置一个合理的上限。那么当内存真的达到上限之后,又有新的写命令进来时,Redis 该怎么处理呢?这就引出了内存淘汰策略


五、8 种内存淘汰策略

Redis 一共提供了 8 种内存淘汰策略。按照"是否淘汰数据"以及"在哪个范围内淘汰",可以分成三大类,如下图所示:

8 种内存淘汰策略

不淘汰数据

在所有数据中淘汰

在设置了过期时间的数据中淘汰

noeviction
Redis 3.0 后默认

allkeys-random
随机淘汰

allkeys-lru
淘汰最久未使用

allkeys-lfu
Redis 4.0 新增
淘汰最少使用

volatile-random
随机淘汰

volatile-ttl
优先淘汰更早过期

volatile-lru
Redis 3.0 前默认
淘汰最久未使用

volatile-lfu
Redis 4.0 新增
淘汰最少使用

5.1 不淘汰数据:noeviction

noeviction 是 Redis 3.0 之后的默认内存淘汰策略。它的行为是:

  • 当运行内存超过最大设置内存时,不淘汰任何数据
  • 这时如果有新的数据写入,会报错通知禁止写入
  • 但是如果没有数据写入的话,只是单纯的查询或者删除操作,还是可以正常工作。

这种策略适合"数据绝对不能丢"的场景,比如把 Redis 当主数据库使用时。代价就是一旦写请求打满内存,业务就会直接收到错误,需要业务侧做好降级和重试。

5.2 在所有数据中淘汰:allkeys-*

这类策略不区分 key 是否设置了过期时间,整个键空间里的所有 key 都可能被淘汰。一共有三种:

  • allkeys-random:随机淘汰任意键值。
  • allkeys-lru:淘汰整个键值中最久未使用的键值。
  • allkeys-lfu(Redis 4.0 后新增):淘汰整个键值中最少使用的键值。

这类策略适合 Redis 作为纯缓存使用的场景——缓存里的数据丢了无所谓,反正可以从数据库回填,所以可以在全部数据里挑一个最该淘汰的删掉。

5.3 在设置了过期时间的数据中淘汰:volatile-*

这类策略只在设置了过期时间的 key 里挑淘汰对象,没设置过期时间的 key(通常是"持久数据")不会被淘汰。一共有四种:

  • volatile-random:随机淘汰设置了过期时间的任意键值。
  • volatile-ttl:优先淘汰更早过期的键值(TTL 越短越先被淘汰)。
  • volatile-lru(Redis 3.0 之前默认):淘汰所有设置了过期时间的键值中,最久未使用的键值。
  • volatile-lfu(Redis 4.0 后新增):淘汰所有设置了过期时间的键值中,最少使用的键值。

对比全部键值淘汰,这一类多了一个 volatile-ttl,因为只有在带 TTL 的数据里才有"更早过期"这个概念。这种策略适合"一部分数据是持久数据、一部分是临时缓存"的混合场景——持久数据保留,只在临时数据里挑淘汰对象。

5.4 策略对比表

策略 淘汰范围 选择依据 适用场景
noeviction 不淘汰 数据不能丢,写满即报错
allkeys-random 全部 key 随机 纯缓存,无访问偏好
allkeys-lru 全部 key 最久未使用 纯缓存,有明显热点
allkeys-lfu 全部 key 最少使用 纯缓存,需抗缓存污染
volatile-random 带 TTL 的 key 随机 混合存储,缓存部分可丢
volatile-ttl 带 TTL 的 key TTL 最短 混合存储,优先清快过期的
volatile-lru 带 TTL 的 key 最久未使用 混合存储,有明显热点
volatile-lfu 带 TTL 的 key 最少使用 混合存储,需抗缓存污染

这其中的 LRU 和 LFU 是比较难理解的,Redis 对它们的实现和教科书里的经典实现并不完全一样。下面我会详细说明这两个算法在 Redis 中的具体实现。


六、LRU 算法:Redis 的近似实现

6.1 传统 LRU 算法及其问题

LRU 全名为 Least Recently Used,翻译成"最近最少使用"。传统的 LRU 算法就是用一个双向链表 + 哈希表实现的:

  • 当访问或者新增某个键值时,把它放到链表的表头。
  • 需要淘汰时,直接删除链表尾部的元素(最久未使用)。

这也是 LeetCode Hot 100 中的一道经典题,我也发布了对应的题解,有兴趣的可以去看看:

但是这种传统算法在 Redis 这种高性能缓存场景下是有问题的:

  • 空间开销大:需要用链表管理所有的缓存数据,每个节点都要维护前后指针,这会带来额外的空间开销。Redis 里可能有上千万个 key,每个 key 都挂指针是不可接受的开销。
  • 性能开销大:当有数据被访问时,需要在链表上把该数据移动到头端。如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

6.2 Redis 的近似 LRU

所以 Redis 没有采用传统的链表式 LRU,而是在 Redis 的对象结构体里面添加了一个字段,用于记录最近的访问时间:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS;  /* 24 bits,用于记录对象的访问信息 */
    int refcount;
    void *ptr;
} robj;

这个内存开销远比链表小,因为没有指针域,只是多一个 24 bits 的字段而已。然后进行内存淘汰的时候,Redis 会随机选取若干个键值(默认 5 个,可以通过 maxmemory-samples 配置),然后删除其中最久未使用(lru 字段值最小)的那个。

近似 LRU 的整体流程如下:

内存使用超过 maxmemory
新写入命令到达

从键空间随机抽样 N 个 key
默认 N = 5

比较这 N 个 key 的 lru 字段
找出 lru 值最小的 key

淘汰该 key 释放内存

执行新的写入命令

6.3 近似 LRU 的问题:缓存污染

但是这个算法还是没有解决缓存污染问题。

缓存污染问题是指:缓存了大量无价值的数据,导致真正的高热点、高价值数据没有空间缓存了。这些数据保留了很长时间,但是访问次数非常低。

一个典型场景是:应用一次性读取了大量数据进行初始化,但是只使用这一次,后续这些大量且无价值的数据就一直留在缓存里面,非常浪费内存。

在近似 LRU 下,这些数据被访问过一次后,lru 字段就被更新成最新时间,于是它们就和真正的热点数据一样"年轻",很难被随机抽样淘汰掉。为了解决这个问题,Redis 4.0 引入了 LFU 算法。


七、LFU 算法:综合访问频次和时间衰减

7.1 LFU 的核心思想

LFU 全名为 Least Frequently Used,翻译为"最近最不常用"。它是根据数据的访问次数进行删除的,核心思想是:

如果该数据过去被访问多次,那么未来被访问的频率也更高。

所以 LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了"偶尔被访问一次之后,数据留存在缓存中很长一段时间"的问题,相比于 LRU 算法也更合理一些。

7.2 24 bits 字段的两种用法

前面提到,Redis 对象头中有一个 24 bits 的 lru 字段。在 LRU 算法下和 LFU 算法下,这个字段的使用方式并不相同:

redisObject.lru
24 bits

LRU 模式

LFU 模式

24 bits 全部用于记录
key 的访问时间戳

高 16 bits: ldt
Last Decrement Time
记录访问时间戳

低 8 bits: logc
Logistic Counter
记录访问频次

  • LRU 模式下:24 bits 全部用来记录 key 的访问时间戳。Redis 根据这个值比较哪个 key 最久未被访问,从而淘汰最久未使用的 key。
  • LFU 模式下:24 bits 被分成两段来存储:
    • 高 16 bits 存储 ldt(Last Decrement Time):用来记录 key 的访问时间戳。
    • 低 8 bits 存储 logc(Logistic Counter):用来记录 key 的访问频次。它的值越小表示使用频率越低,越容易被淘汰。每个新加入的 key 的 logc 初始值为 5。

7.3 logc 的概率递增机制

这里有一个非常巧妙的设计:当 key 被访问的时候,logc 的值并不会直接 +1,而是按概率递增

递增概率为:

P = 1 / (logc * lfu_log_factor + 1)

其中 lfu_log_factor 是可配置参数,默认值为 10。也就是说:

  • logc 越大,递增概率越低。
  • 访问得越频繁,增长越慢。

这里补充一个源码细节:Redis 在计算概率时,实际用的是 baseval = logc - LFU_INIT_VALLFU_INIT_VAL = 5),当 logc <= 5baseval 取 0,此时 P = 1,必然递增;当 logc > 5 后才开始按上面的公式概率递增。所以新 key 的前几次访问会稳定增长,之后才进入概率衰减阶段。

这样可以保证 8 bits 的计数器不会被快速用完(最大值 255),同时又能近似表达百万级的访问频次:

logc 值 大致对应的实际访问次数
5(初始值) ~100 次
10 ~10K 次
20 ~1M 次
255 饱和状态

这种"对数 + 概率"的递增方式,让 8 bits 的字段能覆盖非常宽的访问频次范围,是非常精巧的工程设计。

7.4 logc 的时间衰减机制

但是只记录访问频次还不够。如果 logc 反映的纯粹是历史累计访问次数,那么一个曾经是热点、现在很久没被访问的 key,它的 logc 值依然会很高,永远轮不到被淘汰——这又退化成了另一种"缓存污染"。

所以 logc 还需要反映和时间的关系:长时间没被访问过的 key,它的 logc 应该要衰减,让那些"综合了长久未访问且访问频率低"两种因素的 key 在下次淘汰中被优先选择。

Redis 采取了一种时间衰减机制:每次更新或者读取对应的 logc 时,先计算当前系统时间和 ldt 的时间差,根据 lfu_decay_time(默认 1 分钟)计算应衰减的值:

decrement = elapsed_minutes / lfu_decay_time

然后从 logc 中减去该值(logc 不为 0 的情况下),并更新 ldt 为当前时间。

举个例子:假设 lfu_decay_time = 1,某个 key 已经 10 分钟没被访问过了,那么 decrement = 10 / 1 = 10,下次访问这个 key 时,它的 logc 会先被减去 10,然后再考虑是否递增。

7.5 LFU 完整流程

把"访问时计算衰减 → 更新 logc → 概率递增 → 更新 ldt"这套逻辑画成流程图:

递增成功

递增失败

某个 key 被访问

计算当前时间与 ldt 的差值
得到 elapsed_minutes

decrement = elapsed_minutes / lfu_decay_time
默认 lfu_decay_time = 1

logc = max(0, logc - decrement)

按概率判断是否递增 logc
P = 1 / (logc*lfu_log_factor+1)

logc = min(255, logc + 1)

logc 保持不变

更新 ldt 为当前时间

结束

注:衰减之后 logc 越小,下次淘汰时越容易被选中。

7.6 为什么 LFU 比 LRU 更合理

回到前面那个缓存污染场景:应用一次性读取了大量数据进行初始化,只使用这一次。在 LRU 下,这些数据被访问一次后 lru 字段就被刷新,会和真正的热点数据一样"年轻";但在 LFU 下:

  • 这些数据被访问一次后,logc 最多从初始值 5 递增到 6(首次访问时 baseval = 0P = 1 必然递增一次),之后再被访问时递增概率就急剧下降(logc = 6baseval = 1,概率仅 1/11)。由于只被访问这一次,它们的 logc 会停留在 6 附近,远低于真正的热点数据。
  • 而真正的热点数据会被反复访问,logc 会持续递增到 10、20 甚至更高。
  • 同时由于时间衰减机制,即使这些一次性访问的数据长期留在内存里,它们的 logc 也会随着时间慢慢衰减。

所以在下次内存淘汰时,这些"只访问过一次的低价值数据"会因为 logc 值最低而被优先淘汰,从而把宝贵的内存让给真正的热点数据。这就是 LFU 相比 LRU 的核心优势。


八、总结

回到开篇的两个问题,本文的答案可以归纳如下:

1. key 设置了过期时间真的会到期就删吗?

不会。Redis 采用的是惰性删除 + 定期删除的组合策略:

  • 惰性删除由 expireIfNeeded 函数实现,每次访问 key 时顺便检查是否过期,过期则删除,可以同步也可以异步。
  • 定期删除由 activeExpireCycle 函数实现,默认每 10 秒触发一次(由 hz 控制),每次随机抽取 20 个 key 检查,如果过期比例超过 25% 就循环清理,但累计耗时超过 25ms 就强制退出。

这套设计在"CPU 开销"和"内存占用"之间取得了平衡。

2. 内存满了怎么办?

当 Redis 运行内存达到 maxmemory 上限时,会根据配置的淘汰策略选择 key 进行淘汰。Redis 提供了 8 种策略:

  • 不淘汰:noeviction
  • 全部数据中淘汰:allkeys-random / allkeys-lru / allkeys-lfu
  • 带 TTL 的数据中淘汰:volatile-random / volatile-ttl / volatile-lru / volatile-lfu

其中 LRU 和 LFU 在 Redis 中都不是教科书式的实现,而是基于 redisObject.lru 这个 24 bits 字段的近似实现:

  • 近似 LRU:用 24 bits 记录访问时间戳,淘汰时随机抽样若干个 key,淘汰最久未访问的。简单但无法抗缓存污染。
  • LFU:把 24 bits 拆成高 16 bits 的 ldt(时间戳)+ 低 8 bits 的 logc(访问频次),logc 按概率递增避免溢出、按时间衰减保证"长久未访问的低频 key"会被优先淘汰,是综合了频次和时间两个因素的更合理方案。

理解了过期删除和内存淘汰这两条主线,再加上前面几篇讲过的持久化、主从哨兵、Cluster,Redis 单机层面的核心机制就已经基本闭环了。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐