⏰ Redis过期策略和内存淘汰:垃圾分类大师

28 阅读13分钟

考察点: 惰性删除、定期删除、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次                   │
│    - 每次随机检查20key             │
│    - 过期比例>25%继续检查            │
│    - 单次最多25ms                   │
└─────────────────────────────────────┘

流程图:

客户端请求 GET key1
    ↓
[惰性删除检查]
    ├─ 已过期 → 删除 → 返回nil
    └─ 未过期 → 返回value

同时,后台每100ms:
[定期删除任务]
    ↓
随机抽取20key
    ↓
删除已过期的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 10key2: TTL 60key3: TTL 600key4: 无过期时间(跳过)

删除顺序: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怎么选?

对比:

场景LRULFU
持续热点(如首页)一般优秀
突发热点(如热搜)优秀一般
周期性访问(如报表)优秀一般
平均分布类似类似

经验:

  • 大部分场景: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%)
☑ 业务高峰前预热缓存

面试要点 ⭐

  1. 过期策略:惰性删除(访问时)+定期删除(每秒10次)
  2. 定期删除细节:随机采样20个,过期比例>25%继续,最多25ms
  3. 内存淘汰时机:used_memory > maxmemory
  4. 8种淘汰策略:noeviction、allkeys-lru/lfu/random、volatile-lru/lfu/random/ttl
  5. LRU实现:近似LRU,随机采样N个key,删除lru值最小的
  6. LFU实现:16位时间+8位计数,概率递增+时间衰减
  7. 推荐策略:通用场景用allkeys-lru,热点数据用allkeys-lfu

最后总结:

Redis的过期和淘汰就像冰箱管理员 🧊:

  • 过期删除 = 定期检查+用时检查
  • 内存淘汰 = 冰箱满了,扔掉不常用的
  • LRU = 扔掉最久没吃的食物
  • LFU = 扔掉最不爱吃的食物

记住:空间有限,策略无限! 🎯

加油,Redis运维大师!💪