跳到主要内容

Redis 过期删除与内存淘汰策略总结

一、Key 过期与删除策略

1.1 如何为 Key 设置过期时间

常用的几种方式(以 key 为例):

  • 相对时间:
    • expire key nn 秒后过期
    • pexpire key nn 毫秒后过期
  • 绝对时间戳:
    • expireat key ts:在秒级时间戳 ts 之后过期
    • pexpireat key ts:在毫秒级时间戳 ts 之后过期
  • 写入时直接设置过期:
    • set key value ex n:秒级过期
    • set key value px n:毫秒级过期
    • setex key n value:等价于 set + ex

辅助命令:

  • 查看剩余存活时间:ttl key
  • 取消过期(改为永久):persist key

1.2 Redis 如何判断 Key 是否过期

Redis 为每个数据库维护两个字典:

  • dict:真正存放键值对
  • expires:存放键的过期时间(过期字典)

expires 中:

  • key:指向键对象的指针
  • value:long long 类型时间戳,表示过期时刻

当访问某个 key 时,大致流程是:

  1. 先看该 key 是否出现在 expires 中:
    • 不在:说明没设置过期,直接返回数据
    • 在:取出它的过期时间,与当前系统时间对比
  2. 如果当前时间已超过过期时间,则判定为过期

只判断是否过期还不够,还要解决“何时真正删除”这个问题。

1.3 三种经典过期删除策略

常见的三种思路:

1)定时删除

  • 设置过期时间的同时,注册一个定时任务,时间一到立刻删除 key。
  • 优点:过期键能尽快释放内存,对内存非常友好。
  • 缺点:如果过期键很多,会频繁触发删除任务,占用大量 CPU,影响性能。

2)惰性删除

  • 不主动删除;只有当客户端访问某个 key 时,才检查是否过期,若已过期就顺便删除。
  • 优点:只在访问时做检查,对 CPU 最友好。
  • 缺点:若很久没人访问某些已过期 key,它们会一直占用内存,浪费内存。

3)定期删除

  • 定时抽样检查部分 key,发现过期就删除。
  • 优点:在 CPU 消耗和内存占用之间做折中。
  • 难点:抽样频率、时长不好调:
    • 太勤快→类似定时删除,耗 CPU
    • 太偷懒→类似纯惰性删除,浪费内存

1.4 Redis 实际采用的策略:惰性删除 + 定期删除

Redis 没有单独使用某一种,而是组合使用:

惰性删除:在访问/修改 key 时调用 expireIfNeeded:惰性删除:在访问/修改 key 时调用 expireIfNeeded

  • 未过期:正常返回
  • 已过期:删除 key
    • 支持同步删除或异步删除,可通过 lazyfree-lazy-expire 配置

定期删除:后台定时任务 activeExpireCycle:

  • 默认每秒执行若干轮(hz 配置,默认 10)
  • 每轮从 expires 中随机抽取固定数量(默认 20)key:
    • 检查是否过期,过期就删除
    • 若本轮过期比例 > 25%(例如 20 个中超过 5 个过期),继续抽样下一轮
    • 整体删除过程有时间上限(默认不超过约 25ms),防止阻塞太久

总结:

  • 惰性删除保证“访问时不过期”
  • 定期删除保证“就算没人访问,过期数据也会逐步被清理”
  • 两者结合,在 CPU 与内存之间达成平衡

二、内存淘汰策略(maxmemory 触发后的行为)

过期删除解决的是“已经过期的数据”,但如果大量永久 key 或过期时间很长的 key 塞满了内存怎么办?这时轮到“内存淘汰策略”出场。

2.1 最大内存 maxmemory 设置

redis.conf 中:

  • maxmemory <bytes>:设置最大可用内存
    • 64 位默认值为 0:不限制,只要系统内存够用
    • 32 位默认约 3GB:防止超过进程可用空间而崩溃

只有当实际使用内存超过 maxmemory 时,才会触发内存淘汰逻辑。

2.2 内存淘汰触发流程

大致步骤:

  1. 客户端发起写入命令(如 sethset 等)
  2. Redis 检查当前内存是否超过 maxmemory
  3. 如果超过,则根据配置的淘汰策略,挑选一批 key 删除
  4. 删除后继续尝试写入:
    • 能腾出足够空间:写入成功,并可触发 evicted 等通知事件
    • 腾不出空间且策略为 noeviction:写入失败,返回 OOM 错误

2.3 八种内存淘汰策略概览

从是否“只考虑设置了 TTL 的 key”来划分:

  1. 只在有 TTL 的 key 范围内淘汰(volatile-*
  2. 在所有 key 范围内淘汰(allkeys-*
  3. 完全不淘汰(noeviction

具体 8 种策略如下。

2.3.1 仅对有 TTL 的 key 淘汰(4 种)

  • volatile-lru
    • 在“设置了过期时间的 key”中,按照最近最少使用(LRU)原则淘汰
  • volatile-lfu
    • 在“设置了过期时间的 key”中,按访问频率最低(LFU)淘汰
  • volatile-random
    • 在“设置了过期时间的 key”中随机淘汰
  • volatile-ttl
    • 在“设置了过期时间的 key”中,优先淘汰剩余寿命最短的 key

2.3.2 对所有 key 淘汰(3 种)

  • allkeys-lru
    • 在所有 key 中按 LRU 淘汰
  • allkeys-lfu
    • 在所有 key 中按 LFU 淘汰
  • allkeys-random
    • 在所有 key 中随机淘汰

2.3.3 不淘汰(1 种)

  • noeviction
    • 不删除任何 key
    • 当内存不足时,新的写入直接返回错误(OOM),读和删仍然正常

2.4 策略选择建议

根据业务特点选择:

  • 典型缓存场景(数据都可被淘汰):
    • 推荐:allkeys-lruallkeys-lfu
    • 避免写满内存导致实例不可用
  • 同时存在“长期保存数据 + 缓存数据”:
    • 一部分 key 没有 TTL,需要尽量保留
    • 推荐:volatile-lruvolatile-lfu
    • 只在设置了过期时间的 key 中做淘汰,尽量不动“永久数据”
  • 极端严格的数据保护:
    • 推荐:noeviction
    • 前提是:做好监控和扩容,不然写满后会频繁报错

2.5 查看和修改当前策略

  • 查看当前策略:
config get maxmemory-policy
  • 临时修改(重启后失效):
config set maxmemory-policy allkeys-lru
  • 永久修改:在 redis.conf 中设置:
maxmemory-policy allkeys-lru

三、LRU 与 LFU:原理与 Redis 实现

Redis 4.0 之后引入 LFU,是为了弥补 LRU 在某些场景下的不足(例如缓存污染)。

3.1 LRU:最近最少使用

概念:淘汰近期最少被访问的数据,假设“最近用过的将来还会用”。

传统实现:

  • 使用双向链表维护所有缓存对象:
    • 新访问/更新的元素移动到链表头
    • 淘汰时从链表尾部删除
  • 问题:
    • 需要为所有对象维护链表指针,增加内存开销
    • 每次访问都要移动节点,访问量大时会产生较高的 CPU 开销

3.2 Redis 的近似 LRU 实现

Redis 为了在“效果”和“开销”之间折中:

  • 在对象头中增加一个 24 位字段 lru,记录“最后一次访问时间”
  • 当需要淘汰时,不是遍历所有 key,而是:
    • 随机抽样固定数量键(默认 5,可配置)
    • 在这批样本中找到最久没访问的那个淘汰
  • 优点:
    • 不需要维护全局链表,节省内存
    • 访问时只更新字段,不涉及链表移动,开销小
  • 缺点:
    • 不是完全精确的 LRU,而是“近似 LRU”,但对大多数缓存场景足够好

3.3 LRU 的问题:缓存污染

典型问题:

  • 某一次业务批量扫描大量冷数据(只访问一次)
  • 在 LRU 策略下,这些“一次性访问”的数据会被认为是“最近访问过”,反而把真正热点数据挤出缓存
  • 访问结束后,这些冷数据会在缓存中停留很久,造成缓存污染

为解决这个问题,引入 LFU。

3.4 LFU:最近最不常用

核心思路:既看“是否访问过”,更看“访问频率”。

  • 假设:过去访问频繁的 key,未来也更可能被访问
  • 淘汰:优先淘汰“访问频率最低”的 key,而不是“最久没访问”的 key

3.5 Redis 中的 LFU 实现细节

在 LFU 模式下,Redis 仍然使用对象头中的 24 位 lru 字段,但进行了拆分:

  • 高 16 位:ldt(Last Decrement Time,记录上次频率衰减的时间)
  • 低 8 位:logc(Logistic Counter,访问频率计数)

含义:

  • logc 越小,表示该 key 使用频率越低,被淘汰概率越大
  • 新建 key 的 logc 一般从较小值起步(例如 5)

访问 key 时主要做两件事:

  1. 频率衰减(decay)
    • 根据当前时间与 ldt 的差值,决定衰减多少
    • 时间间隔越大,衰减越多
    • 目的:让“很久以前曾经热过”的 key 随时间逐渐不再被视为热点
  2. 频率增加(increment)
    • 不是简单 logc++,而是按概率增加:
      • logc 越大,想再变得更大就越难
      • 防止单个极热 key 在长时间内把计数冲得太高,影响整体效果

相关配置项(在 redis.conf 中):

  • lfu-decay-time(默认 1 分钟)
    • 控制衰减速度,值越大衰减越慢
  • lfu-log-factor
    • 控制频率增长速度,值越大,想让 logc 再上涨就更难

通过调整这两个参数,可以微调 LFU 的“记性”和“遗忘速度”。

四、小结

  1. 过期删除:
    • Redis 使用“惰性删除 + 定期删除”的组合:
      • 惰性删除:访问时顺手检查并清理
      • 定期删除:后台定时抽样清理
    • 目标:在 CPU 开销和内存占用之间取得平衡
  2. 内存淘汰:
    • 当实际内存超过 maxmemory 时触发
    • 共有 8 种策略:
      • volatile-*:只在有 TTL 的 key 中淘汰
      • allkeys-*:在所有 key 中淘汰
      • noeviction:不淘汰,只报错
    • 缓存场景优先推荐 allkeys-lru / allkeys-lfu
  3. LRU vs LFU:
    • LRU:根据“最近是否访问”淘汰,简单高效,但容易缓存污染
    • LFU:根据“访问频率”淘汰,更能体现真正热点
    • Redis 实现的都是“近似版本”,通过采样和紧凑计数字段在效果与性能之间做了折中