考察点: 惰性删除、定期删除、LRU、LFU、maxmemory-policy
🎬 开场:一个关于"冰箱管理"的故事
想象你的冰箱 🧊:
场景1(不管过期食物):
牛奶过期了 → 还在冰箱里
酸奶过期了 → 还在冰箱里
面包过期了 → 还在冰箱里
结果:冰箱塞满了,新买的放不下!😱
场景2(定时检查):
每天晚上:检查所有食物,扔掉过期的
优点:冰箱干净
缺点:每天都要花时间检查
场景3(用时检查):
拿牛奶时:看看过期了没,过期就扔
拿酸奶时:看看过期了没,过期就扔
优点:按需检查,效率高
缺点:不拿的东西就一直放着
Redis的过期策略 = 场景2+场景3的结合! 🎯
第一部分:过期时间设置 ⏰
1.1 设置过期时间的命令
# 方式1:EXPIRE(秒)
127.0.0.1:6379> SET key1 "value1"
127.0.0.1:6379> EXPIRE key1 60 # 60秒后过期
(integer) 1
# 方式2:PEXPIRE(毫秒)
127.0.0.1:6379> PEXPIRE key1 60000 # 60000毫秒后过期
# 方式3:EXPIREAT(Unix时间戳)
127.0.0.1:6379> EXPIREAT key1 1704067200 # 指定过期时间点
# 方式4:PEXPIREAT(毫秒时间戳)
127.0.0.1:6379> PEXPIREAT key1 1704067200000
# 方式5:SET命令直接设置
127.0.0.1:6379> SET key1 "value1" EX 60 # 60秒
127.0.0.1:6379> SET key2 "value2" PX 60000 # 60000毫秒
127.0.0.1:6379> SETEX key3 60 "value3" # 等价于SET+EXPIRE
1.2 查看和删除过期时间
# 查看剩余时间(秒)
127.0.0.1:6379> TTL key1
(integer) 45 # 还剩45秒
# 查看剩余时间(毫秒)
127.0.0.1:6379> PTTL key1
(integer) 45000
# 返回值含义:
# -1:key存在,但没有设置过期时间
# -2:key不存在
# 正整数:剩余秒数/毫秒数
# 移除过期时间(让key永不过期)
127.0.0.1:6379> PERSIST key1
(integer) 1
127.0.0.1:6379> TTL key1
(integer) -1 # 永不过期
1.3 过期时间的存储
Redis内部数据结构:
redisDb {
dict *dict; // 所有key-value
dict *expires; // 过期字典(只存有过期时间的key)
}
示例:
dict:
key1 → "value1"
key2 → "value2"
key3 → "value3"
expires:
key1 → 1704067200000 (Unix时间戳,毫秒)
key3 → 1704070800000
说明:key2没有过期时间,所以不在expires中
第二部分:过期删除策略 🗑️
2.1 三种删除策略
策略1:定时删除(主动)
实现方式:
为每个key创建一个定时器,到期后立即删除
┌──────────────────┐
│ key1: "value1" │ ───→ 定时器(60秒后删除)
├──────────────────┤ ↓
│ key2: "value2" │ ───→ 定时器(120秒后删除)
└──────────────────┘ ↓
时间到!立即删除
优点:
- ✅ 内存友好:过期key立即释放
- ✅ 精确:到期立刻删除
缺点:
- ❌ CPU不友好:大量定时器消耗CPU
- ❌ 实现复杂:需要维护大量定时器
Redis是否采用? ❌ 没有!(成本太高)
策略2:惰性删除(被动)
实现方式:
不主动删除,访问key时检查是否过期
流程:
客户端请求 → GET key1
↓
检查key1是否过期?
├─ 没过期 → 返回value
└─ 已过期 → 删除key → 返回nil
代码示例:
def get_key(key):
# 检查key是否存在
if key not in db.dict:
return None
# 检查是否过期
if key in db.expires:
expire_time = db.expires[key]
if current_time() > expire_time:
# 已过期,删除
del db.dict[key]
del db.expires[key]
return None
# 返回值
return db.dict[key]
优点:
- ✅ CPU友好:只在访问时检查
- ✅ 实现简单
缺点:
- ❌ 内存不友好:不访问的key永远不删除(内存泄漏)
Redis是否采用? ✅ 部分采用!
策略3:定期删除(主动)
实现方式:
每隔一段时间,随机抽取一批key检查过期
流程(每秒执行10次):
1. 随机选择20个带过期时间的key
2. 删除其中已过期的key
3. 如果过期key比例 > 25%,重复步骤1
4. 单次执行时间不超过25ms
伪代码:
def delete_expired_keys():
# 每100ms执行一次
while True:
# 1. 从expires字典随机选20个key
keys = random_sample(db.expires, 20)
expired_count = 0
for key in keys:
if is_expired(key):
del db.dict[key]
del db.expires[key]
expired_count += 1
# 2. 如果过期比例 > 25%,继续清理
if expired_count / 20 < 0.25:
break
# 3. 单次最多执行25ms
if elapsed_time() > 25:
break
time.sleep(0.1) # 100ms后再执行
优点:
- ✅ 平衡CPU和内存
- ✅ 限制删除操作时间(不阻塞太久)
缺点:
- ⚠️ 可能有部分过期key不能及时删除
Redis是否采用? ✅ 采用!
2.2 Redis的过期策略(惰性+定期)
Redis采用:惰性删除 + 定期删除
┌─────────────────────────────────────┐
│ Redis过期删除策略 │
├─────────────────────────────────────┤
│ 1. 惰性删除(访问时检查) │
│ - GET/SET等命令触发 │
│ - 发现过期立即删除 │
│ │
│ 2. 定期删除(后台定时任务) │
│ - 每秒执行10次 │
│ - 每次随机检查20个key │
│ - 过期比例>25%继续检查 │
│ - 单次最多25ms │
└─────────────────────────────────────┘
流程图:
客户端请求 GET key1
↓
[惰性删除检查]
├─ 已过期 → 删除 → 返回nil
└─ 未过期 → 返回value
同时,后台每100ms:
[定期删除任务]
↓
随机抽取20个key
↓
删除已过期的key
↓
过期比例 > 25%?
├─ 是 → 继续抽取删除
└─ 否 → 等待下次执行
2.3 过期删除的实现细节
快模式和慢模式
// 快模式(FAST):每次执行1ms,频率高
#define ACTIVE_EXPIRE_CYCLE_FAST 1
// 慢模式(SLOW):每次执行25ms,频率低
#define ACTIVE_EXPIRE_CYCLE_SLOW 0
// 触发时机:
// 快模式:beforeSleep(每次事件循环前)
// 慢模式:serverCron(每100ms)
采样数量
#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 // 每次采样20个
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 // 可接受的过期比例10%
第三部分:内存淘汰策略 🚨
3.1 为什么需要内存淘汰?
场景:
Redis最大内存:1GB
当前使用:990MB
新写入数据:20MB
问题:内存不够了!怎么办?
解决方案:
1. 拒绝写入(报错)
2. 删除一些数据(内存淘汰)
3.2 内存淘汰的触发
# redis.conf 配置
maxmemory 1gb # 最大内存限制
# 当内存使用超过maxmemory时,触发淘汰
触发时机:
每次执行命令前:
1. 检查内存使用量
2. 如果 used_memory > maxmemory
3. 触发内存淘汰
4. 释放足够内存后,再执行命令
3.3 八种内存淘汰策略
# redis.conf
maxmemory-policy noeviction # 默认策略
| 策略 | 含义 | 适用场景 |
|---|---|---|
| noeviction | 不淘汰,写入报错 | 纯缓存,不能丢数据 |
| allkeys-lru | 在所有key中,删除最少使用的 | 通用缓存(推荐⭐) |
| allkeys-lfu | 在所有key中,删除访问频率最低的 | 热点缓存 |
| allkeys-random | 在所有key中,随机删除 | 不关心数据重要性 |
| volatile-lru | 在有过期时间的key中,删除最少使用的 | 部分key可删 |
| volatile-lfu | 在有过期时间的key中,删除访问频率最低的 | 热点+过期 |
| volatile-random | 在有过期时间的key中,随机删除 | 有过期时间即可删 |
| volatile-ttl | 在有过期时间的key中,删除TTL最短的 | 优先删快过期的 |
策略1:noeviction(默认)
127.0.0.1:6379> CONFIG SET maxmemory 1mb
127.0.0.1:6379> SET key1 "..." (1MB数据)
OK
127.0.0.1:6379> SET key2 "..."
(error) OOM command not allowed when used memory > 'maxmemory'.
# 拒绝写入,返回错误
适用场景:
- 数据不能丢失(如计数器)
- 应用层处理内存不足问题
策略2:allkeys-lru(推荐⭐⭐⭐⭐⭐)
LRU = Least Recently Used(最近最少使用)
原理:
删除最长时间没有访问的key
示例:
key1: 1小时前访问
key2: 1分钟前访问
key3: 10秒前访问
key4: 刚刚访问
删除顺序:key1 → key2 → key3 → key4
适用场景:
- 通用缓存系统
- 访问频率有明显差异
- 最常用的策略!
策略3:allkeys-lfu(Redis 4.0+)
LFU = Least Frequently Used(最不经常使用)
原理:
删除访问次数最少的key
示例:
key1: 访问1000次
key2: 访问100次
key3: 访问10次
key4: 访问1次
删除顺序:key4 → key3 → key2 → key1
适用场景:
- 热点数据缓存
- 访问频率比访问时间更重要
策略4:volatile-ttl
原理:
优先删除TTL最短的key(即将过期的)
示例:
key1: TTL 10秒
key2: TTL 60秒
key3: TTL 600秒
key4: 无过期时间(跳过)
删除顺序:key1 → key2 → key3
3.4 LRU算法的实现
传统LRU(Redis不用)
传统LRU:双向链表 + HashMap
结构:
HashMap: key → Node
LinkedList:
Head ←→ [最近访问] ←→ [较新] ←→ ... ←→ [最久] ←→ Tail
访问key:
1. 从HashMap找到Node
2. 移动Node到链表头部
3. O(1)时间复杂度
淘汰:
1. 删除链表尾部Node
2. O(1)时间复杂度
缺点:
- 每个key需要额外24字节(指针)
- 每次访问都要移动节点
Redis的近似LRU
Redis优化:
不维护精确LRU,而是近似LRU
实现:
1. 每个redisObject有24位的lru字段(记录访问时间)
2. 淘汰时,随机采样N个key(默认5个)
3. 删除lru值最小的key
4. 重复直到内存足够
优点:
- 无额外内存开销
- 性能高
- 近似度足够好(采样越多越精确)
配置:
# redis.conf
maxmemory-samples 5 # 采样数量(默认5,建议5-10)
# 采样越多,越接近真实LRU,但CPU开销越大
# 5个采样已经很接近真实LRU了
效果对比:
传统LRU: 100%准确
Redis采样5个: 95%准确
Redis采样10个: 98%准确
性能差距:
传统LRU:每次访问都要移动链表节点
Redis LRU:只在淘汰时采样,平时无开销
3.5 LFU算法的实现
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // LRU模式:记录访问时间
// LFU模式:高16位访问时间,低8位访问次数
} robj;
LFU模式:
┌────────────────────┬──────────────┐
│ 16位: 上次访问时间 │ 8位: 访问次数 │
└────────────────────┴──────────────┘
访问次数递增规则:
- 不是每次访问就+1(会溢出)
- 使用概率递增:访问次数越大,递增概率越小
伪代码:
if random() < 1.0 / (counter * lfu_log_factor):
counter += 1
# lfu_log_factor越大,递增越慢
配置:
# redis.conf
lfu-log-factor 10 # 访问次数递增速度(默认10)
lfu-decay-time 1 # 访问次数衰减时间(默认1分钟)
第四部分:实战场景 💼
4.1 缓存场景的选择
场景1:通用Web缓存
# 推荐:allkeys-lru
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 5
# 原因:
# - 访问有时间局部性(最近访问的可能再访问)
# - 所有key都可以删除
# - LRU效果最好
场景2:热点商品缓存(电商)
# 推荐:allkeys-lfu
maxmemory 4gb
maxmemory-policy allkeys-lfu
lfu-log-factor 10
# 原因:
# - 爆款商品频繁访问
# - 长尾商品偶尔访问
# - LFU保证热点数据不被删
场景3:会话缓存(Session)
# 推荐:volatile-ttl
maxmemory 1gb
maxmemory-policy volatile-ttl
# 原因:
# - 每个session都有过期时间
# - 优先删除快过期的session
# - 保证活跃用户的session不被删
场景4:计数器系统
# 推荐:noeviction
maxmemory 512mb
maxmemory-policy noeviction
# 原因:
# - 计数数据不能丢失
# - 宁可报错,也不能删数据
# - 应用层监控内存,及时扩容
4.2 监控内存使用
# 查看内存信息
127.0.0.1:6379> INFO memory
# 关键指标:
used_memory: 1048576000 # 已用内存(字节)
used_memory_human: 1000.00M # 已用内存(人类可读)
used_memory_rss: 1100000000 # 操作系统分配的内存
used_memory_peak: 1200000000 # 历史最大内存
maxmemory: 2147483648 # 最大内存限制
maxmemory_policy: allkeys-lru # 淘汰策略
evicted_keys: 12345 # 已淘汰的key数量
4.3 性能优化
# 1. 设置合理的maxmemory
# 建议:物理内存的75%
# 例如:8GB物理内存 → maxmemory 6gb
# 2. 选择合适的淘汰策略
# 通用场景:allkeys-lru
# 热点数据:allkeys-lfu
# 有过期时间:volatile-ttl
# 3. 调整采样数量
maxmemory-samples 10 # 提高采样数,更精确
# 4. 监控告警
# - used_memory > maxmemory * 0.8:警告
# - evicted_keys持续增长:扩容
# - 慢查询增多:可能在频繁淘汰
第五部分:常见问题 ❓
问题1:过期key什么时候删除?
答:
1. 访问时立即删除(惰性)
2. 后台定期删除(每秒10次)
3. 内存不足时淘汰(如果在淘汰范围内)
综合:
- 热点key:访问时删除,几乎立即
- 冷门key:可能延迟几秒到几分钟
- 长期不访问的key:如果内存够用,可能一直不删
问题2:大量key同时过期会怎样?
问题:
# 场景:秒杀活动,10万个key同时过期
for i in range(100000):
SETEX key_{i} 3600 "value" # 都是1小时后过期
# 1小时后...
# 大量key同时过期
# 定期删除任务疯狂运行
# Redis卡顿!
解决方案:
# 1. 过期时间加随机值
import random
expire_time = 3600 + random.randint(0, 300) # 3600-3900秒
redis.setex(key, expire_time, value)
# 2. 分批设置过期时间
for i in range(100000):
expire_time = 3600 + (i % 300) # 分散在5分钟内
redis.setex(f"key_{i}", expire_time, "value")
问题3:为什么配置了过期时间,内存还是满了?
原因:
1. 定期删除只是随机采样,不是全部检查
2. 冷门key可能长期不被访问,不会惰性删除
3. 如果没设置maxmemory,不会触发淘汰
解决方案:
# 1. 设置maxmemory
maxmemory 2gb
# 2. 设置淘汰策略
maxmemory-policy allkeys-lru
# 3. 定期检查内存
127.0.0.1:6379> INFO memory
问题4:LRU vs LFU怎么选?
对比:
| 场景 | LRU | LFU |
|---|---|---|
| 持续热点(如首页) | 一般 | 优秀 ⭐ |
| 突发热点(如热搜) | 优秀 ⭐ | 一般 |
| 周期性访问(如报表) | 优秀 ⭐ | 一般 |
| 平均分布 | 类似 | 类似 |
经验:
- 大部分场景:LRU(简单有效)
- 明确的热点数据:LFU(如商品详情页)
🎓 总结:过期与淘汰决策树
[Redis内存管理]
|
┌──────────┴──────────┐
↓ ↓
[过期删除] [内存淘汰]
| |
惰性+定期 达到maxmemory触发
| |
↓ ↓
访问时删除 根据策略淘汰key
每秒10次采样 |
┌──────┴──────┐
↓ ↓
有过期时间 所有key
| |
volatile-* allkeys-*
记忆口诀 🎵
过期删除两策略,
惰性定期来配合。
访问时刻即检查,
后台定时也清扫。
内存不足要淘汰,
八种策略任你选。
通用缓存用LRU,
热点数据选LFU。
有期可删volatile,
全部可删allkeys。
最近最少LRU删,
访问频率LFU筛。
noeviction拒写入,
random随机来删除。
TTL最短先淘汰,
各有千秋看场景!
最佳实践清单 ✅
☑ 设置maxmemory(物理内存的75%)
☑ 选择合适的淘汰策略(默认allkeys-lru)
☑ 过期时间加随机值(避免同时过期)
☑ 监控evicted_keys(淘汰数量)
☑ 监控expired_keys(过期删除数量)
☑ 避免大量key同时过期
☑ 增加maxmemory-samples提高精度
☑ 定期查看INFO memory
☑ 设置内存告警(>80%)
☑ 业务高峰前预热缓存
面试要点 ⭐
- 过期策略:惰性删除(访问时)+定期删除(每秒10次)
- 定期删除细节:随机采样20个,过期比例>25%继续,最多25ms
- 内存淘汰时机:used_memory > maxmemory
- 8种淘汰策略:noeviction、allkeys-lru/lfu/random、volatile-lru/lfu/random/ttl
- LRU实现:近似LRU,随机采样N个key,删除lru值最小的
- LFU实现:16位时间+8位计数,概率递增+时间衰减
- 推荐策略:通用场景用allkeys-lru,热点数据用allkeys-lfu
最后总结:
Redis的过期和淘汰就像冰箱管理员 🧊:
- 过期删除 = 定期检查+用时检查
- 内存淘汰 = 冰箱满了,扔掉不常用的
- LRU = 扔掉最久没吃的食物
- LFU = 扔掉最不爱吃的食物
记住:空间有限,策略无限! 🎯
加油,Redis运维大师!💪