2026年7月12日 · 6 分钟阅读
Redis 内存管理:过期删除策略与内存淘汰算法
深入剖析 Redis 的 TTL 存储方式、惰性删除与定期删除的协作机制,以及 8 种内存淘汰策略的原理,包括 LRU 和 LFU 在 Redis 中的工程实现细节。
Redis 是内存数据库,数据存在内存里。但内存是有成本的,所以需要两套机制来控制内存的使用:
- 过期删除:数据到了 TTL,就该被清除。但”到了”不等于”立即删”,什么时候删是个设计问题。
- 内存淘汰:内存满了,新数据写不进来怎么办?必须选一些 key 淘汰掉。
TTL 的存储方式
Redis 是一个 key-value 数据库,但并不是所有 key 都有过期时间。因此在 RedisDB 中维护了两个 Dict:
dict:所有 key-value 的映射。expires:记录了有 TTL 的 key 及其过期时间(毫秒级时间戳)。
dict: {"name" → "极客时间", "session:1" → {...}, "cache:key" → "value"}
expires: {"session:1" → 1700000000000, "cache:key" → 1700000100000}
当访问 session:1 时,Redis 先去 expires 中查一下它的过期时间。如果当前时间超过了那个时间戳,就认为 key 过期了。
过期删除策略
惰性删除
每次访问 key 时检查是否过期,过期则删除,再返回 nil。
def lookupKey(db, key):
if key in db.expires and current_time > db.expires[key]:
deleteKey(db, key) # 过期了,删掉
return None
return db.dict[key]
优点:消耗 CPU 最少——只删除被访问到的过期 key。 缺点:如果某个过期 key 再也不被访问了,它就一直占着内存,这就是内存泄漏。
定期删除(周期删除)
为了解决惰性删除的漏删问题,Redis 还有一个定期删除任务,以周期性抽样的方式主动清理过期 key。
实现方式有两种模式运行:
SLOW 模式
在 serverCron() 定时任务中执行,频率由 server.hz 控制(默认 10,即每秒 10 次):
- 逐步遍历所有 RedisDB。
- 每个 DB 遍历 bucket,随机抽取 20 个 key 判断是否过期。
- 删掉过期的。
- 如果超过 25% 的 key 过期(即抽样中过期比例 > 10%),则重复步骤 2-3。
- 总耗时不超过执行周期的 25%(默认 100ms 的周期,即 ≤ 25ms)。
FAST 模式
在 beforesleep() 事件循环前执行:
- 两次 FAST 模式间隔不低于 2ms。
- 同样是随机抽样 20 个 key。
- 总耗时不超过 1ms。
- 如果过期 key 比例小于 10% 则不执行。
两种模式协作:SLOW 保证有兜底,FAST 尽量在请求到来前清理。
总结
| 策略 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 | 访问 key 时 | CPU 开销小 | 过期 key 可能一直残留 |
| 定期删除(SLOW) | 每 100ms | 主动清理,兜底 | 占用固定 CPU 预算 |
| 定期删除(FAST) | 事件循环前 | 请求前清理 | 清理不完的留到下次 |
内存淘汰策略
过期删除解决的是”数据到期了怎么删”,内存淘汰解决的是”内存满了怎么办”。
Redis 在每次执行命令前检查内存使用,如果超过 maxmemory 限制,就触发内存淘汰。
8 种淘汰策略
| 策略 | 范围 | 算法 | 说明 |
|---|---|---|---|
noeviction | — | — | 不淘汰,内存满时拒绝写入(默认策略) |
allkeys-lru | 所有 key | LRU | 淘汰最近最少使用的 key |
allkeys-lfu | 所有 key | LFU | 淘汰访问频率最低的 key |
allkeys-random | 所有 key | 随机 | 随机淘汰 key |
volatile-lru | 有 TTL 的 key | LRU | 淘汰设置了过期时间中最久未使用的 |
volatile-lfu | 有 TTL 的 key | LFU | 淘汰设置了过期时间中频率最低的 |
volatile-random | 有 TTL 的 key | 随机 | 从 expires dict 中随机淘汰 |
volatile-ttl | 有 TTL 的 key | TTL | 淘汰剩余 TTL 最小的 key(最快过期) |
使用建议:
- 缓存场景推荐
allkeys-lru或allkeys-lfu。 - 需要保证某些 key 永不被淘汰时,用
volatile-*,并对不想淘汰的 key 不设 TTL。
LRU 和 LFU 的实现细节
面试常问的就是这两个,但很多人不清楚它们在 Redis 中的具体实现。
LRU(Least Recently Used)
LRU 核心思想:淘汰最长时间未被访问的 key。
Redis 的 LRU 实现:近似 LRU,不是标准的全链表 LRU。
RedisObject 中有一个 24 位的 lru 字段:
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // LRU: 最后一次访问的时间戳(秒级)
int refcount;
void *ptr;
};
淘汰时:
- 随机从
maxmemory-samples(默认 5)个 key 中采样。 - 淘汰采样中
当前时间 - lru最大的那个(即最久未访问的)。 - 采样数量越大,淘汰结果越接近标准 LRU,但性能消耗也越大。
标准 LRU 需要用链表维护所有 key 的访问顺序,对内存不友好。近似 LRU 用很小的代价就达到了接近 LRU 的效果,在实际 production 中足够用了。
LFU(Least Frequently Used)
LFU 核心思想:淘汰访问频率最低的 key。
LFU 复用同一个 lru 字段(24 位),但拆分成两部分:
- 高 16 位:上次递减时间(分钟级时间戳)。
- 低 8 位:逻辑访问次数(counter,最大值 255)。
访问次数计数
LFU 的”访问次数”不是一个简单的计数器,它用了概率递增:
每次访问 key 时:
1. 生成一个 0~1 之间的随机数 R
2. 计算 P = 1 / (旧次数 × lfu_log_factor + 1)
3. 如果 R < P,counter + 1(最多 255)
lfu_log_factor 默认是 10。这意味着:
| counter 值 | 需要的大致访问次数 |
|---|---|
| 0 → 1 | 1 次 |
| 1 → 2 | 10 次 |
| 10 → 11 | 100 次 |
| 100 → 101 | 1000 次 |
高频 key 的 counter 会增长得越来越慢,不会被短期爆发式访问挤占长期热 key 的位置。
访问次数衰减
如果 counter 只增不减,冷 key 只要在一段时间内被频繁访问过就无法被淘汰了。所以 LFU 还有衰减机制:
- 每过
lfu-decay-time分钟(默认 1 分钟),counter 减 1。 - 若分布式系统内对应的分钟数大于 255,减至 0。
这样,一个曾经很热的 key 经过足够长的冷却时间后,访问频率最终会归零。
最佳实践
-
maxmemory 不要设到物理内存上限,留出给操作系统、fork 子进程、AOF 缓冲区等。建议 maxmemory 设到机器内存的 50%~70%。
-
缓存场景用 allkeys-lru——覆盖最完善,最接近 LRU 语义,且对所有 key 一视同仁。
-
有明确的冷热数据区分时用 volatile-lru,给每个 key 设一个合理的 TTL,淘汰只发生在过期 key 内部,不影响持久化 key。
-
LFU 适合访问模式有明显长尾效应的场景,比如某些 key 长期高频访问(热帖),某些 key 偶尔访问(老帖子)。LRU 倾向于淘汰这些”长期高频但最近没有被访问”的热 key,而 LFU 能更好地保留它们。
-
永远不要让 maxmemory-policy 保持默认的 noeviction——生产环境内存满了会直接拒绝写入,导致大面积报错。
总结
- Redis 通过 dict + expires 双 Dict 分离常规数据和过期时间。
- 过期删除是惰性 + 定期双策略:惰性保证访问时必删,定期保证没人访问也会删。
- 内存淘汰有 8 种策略,理解 LRU 和 LFU 的工程实现(近似采样、概率递增、衰减机制)才是面试真正的分水岭。
- 两者协作的根本目的:让 Redis 在有限内存下保持高性能,不让内存耗尽或异常数据占据过多空间。