目录

Redis 淘汰策略源码分析:LRU vs LFU vs TTL 的工程权衡

教科书上的 LRU 用双向链表 + HashMap 实现。但 Redis 为什么用"近似 LRU"?这篇文章深入 Redis 源码,分析各种淘汰策略的工程权衡。

一、Redis 内存淘汰策略

1.1 配置选项

1
2
3
# redis.conf
maxmemory 1gb
maxmemory-policy allkeys-lru

可选策略:

策略范围算法
noeviction-拒绝写入
allkeys-lru所有 key近似 LRU
volatile-lru有过期时间的 key近似 LRU
allkeys-lfu所有 key近似 LFU
volatile-lfu有过期时间的 key近似 LFU
allkeys-random所有 key随机
volatile-random有过期时间的 key随机
volatile-ttl有过期时间的 key按 TTL 淘汰

1.2 为什么不用精确 LRU?

教科书 LRU

1
2
3
4
5
6
7
8
9
type LRUCache struct {
    capacity int
    cache    map[string]*Node
    head     *Node  // 最近使用
    tail     *Node  // 最久未用
}

// 每次访问,将节点移到头部
// 每次淘汰,删除尾部节点

问题

  1. 内存开销:每个 key 需要额外 2 个指针(prev/next),64 位系统 = 16 字节/key
  2. 并发开销:每次读操作都要修改链表,需要加锁
  3. CPU 缓存不友好:链表节点在内存中不连续

Redis 有数百万甚至上亿 key,这些开销不可接受。

二、Redis 近似 LRU 实现

2.1 核心思想

Redis 的做法:

  1. 不维护全局链表
  2. 每个 key 记录最后访问时间戳(24 bits = 3 字节)
  3. 淘汰时随机采样,选最老的淘汰

2.2 源码分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/evict.c
int freeMemoryIfNeeded(void) {
    // 当内存超限时调用
    
    while (mem_used > maxmemory) {
        // 采样 N 个 key
        struct evictionPoolEntry pool[EVPOOL_SIZE];
        
        for (int i = 0; i < server.maxmemory_samples; i++) {
            // 随机选一个 key
            dictEntry *de = dictGetRandomKey(dict);
            
            // 获取访问时间
            unsigned long idle = estimateObjectIdleTime(o);
            
            // 放入排序池
            evictionPoolPopulate(pool, de, idle);
        }
        
        // 淘汰池中最老的
        bestkey = pool[0].key;
        deleteKey(bestkey);
    }
}

2.3 时间戳存储

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/object.c
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS;  // 24 bits
    int refcount;
    void *ptr;
} robj;

// 更新访问时间
void touchObject(robj *o) {
    o->lru = LRU_CLOCK();  // 当前时间的低 24 位
}

// 估算空闲时间
unsigned long estimateObjectIdleTime(robj *o) {
    unsigned long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return lruclock - o->lru;
    } else {
        // 时钟回绕
        return (lruclock + (1 << LRU_BITS)) - o->lru;
    }
}

精度:24 bits 表示秒数,约 194 天回绕一次,足够了。

2.4 采样数量的影响

1
2
# 默认采样 5 个
maxmemory-samples 5
采样数命中率CPU 开销
1~80%极低
5~93%
10~97%
全量100%不可接受

工程权衡:采样 5-10 个已经足够接近精确 LRU。

三、LFU:基于访问频率淘汰

3.1 LRU 的问题

假设缓存容量 3:

1
2
3
4
5
6
7
8
9
访问序列: A A A A B B C D E
LRU 状态:
  [A]
  [A] (访问 A 4 次)
  [B, A]
  [B, A] (访问 B 2 次)
  [C, B, A]
  [D, C, B]  ← A 被淘汰!
  [E, D, C]

A 被访问了 4 次,却因为"最近"没访问就被淘汰。这在很多场景下不合理。

3.2 Redis LFU 实现

Redis 4.0 引入 LFU,复用了 lru 字段的 24 bits:

1
2
3
4
+--------+--------+
| 16 bits | 8 bits |
| 时间戳  | 计数器  |
+--------+--------+

计数器衰减

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 每次访问时
unsigned long LFUDecrAndReturn(robj *o) {
    // 获取上次访问时间
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    
    // 计算时间差(分钟)
    unsigned long minutes = LFUTimeElapsed(ldt);
    
    // 每 N 分钟衰减一次
    if (minutes > 0) {
        unsigned long decay = minutes / lfu_decay_time;
        if (counter > decay) {
            counter -= decay;
        } else {
            counter = 0;
        }
    }
    
    return counter;
}

计数器增长(概率性)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 不是每次访问都 +1,而是概率性增加
unsigned long LFULogIncr(unsigned long counter) {
    if (counter == 255) return 255;  // 上限
    
    double r = (double)rand() / RAND_MAX;
    double p = 1.0 / ((counter - LFU_INIT_VAL) * lfu_log_factor + 1);
    
    if (r < p) {
        counter++;
    }
    
    return counter;
}

为什么概率增长?

  • 计数器只有 8 bits,最大 255
  • 热点 key 可能被访问数百万次
  • 概率增长实现了"对数计数",counter=10 约等于访问 1000 次

3.3 LFU 配置

1
2
3
4
5
# 衰减时间(分钟)
lfu-decay-time 1

# 对数因子
lfu-log-factor 10

四、实战对比

4.1 测试方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func benchmark(policy string) {
    client := redis.NewClient(&redis.Options{...})
    
    // 预热:写入 100 万个 key
    for i := 0; i < 1_000_000; i++ {
        client.Set(ctx, fmt.Sprintf("key:%d", i), "value", 0)
    }
    
    // 模拟真实访问(Zipf 分布:少数 key 被频繁访问)
    zipf := rand.NewZipf(rand.NewSource(42), 1.1, 10, 1_000_000)
    
    hits := 0
    for i := 0; i < 1_000_000; i++ {
        key := fmt.Sprintf("key:%d", zipf.Uint64())
        if client.Get(ctx, key).Err() == nil {
            hits++
        }
    }
    
    fmt.Printf("%s: hit rate = %.2f%%\n", policy, float64(hits)/10000)
}

4.2 结果分析

策略均匀访问Zipf 分布突发热点
allkeys-random50%52%51%
allkeys-lru65%78%60%
allkeys-lfu62%85%82%

结论

  • 访问均匀 → LRU 略好
  • 热点数据 → LFU 明显更好
  • 突发热点 → LFU 能保护已有热点

五、源码级优化细节

5.1 惰性删除 vs 定期删除

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 惰性删除:访问时检查
robj *lookupKeyRead(redisDb *db, robj *key) {
    expireIfNeeded(db, key);  // 先检查过期
    return lookupKey(db, key);
}

// 定期删除:后台定时任务
void activeExpireCycle(int type) {
    for (int j = 0; j < dbs; j++) {
        // 每次随机采样 20 个
        // 删除已过期的
        // 如果过期比例 > 25%,继续循环
    }
}

5.2 内存回收策略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 不是立即释放,而是异步释放
void freeObjAsync(robj *o) {
    size_t size = objectComputeSize(o);
    
    if (size > LAZYFREE_THRESHOLD) {
        // 大对象:放入后台队列
        bioCreateLazyFreeJob(lazyfreeFreeObject, 1, o);
    } else {
        // 小对象:立即释放
        decrRefCount(o);
    }
}

六、最佳实践

6.1 策略选择

1
2
3
4
5
6
7
8
if 数据访问模式 == "均匀":
    use "allkeys-lru"
elif 数据访问模式 == "热点数据":
    use "allkeys-lfu"
elif 数据有明确过期时间:
    use "volatile-ttl"
else:
    use "allkeys-lru"  # 默认选择

6.2 监控指标

1
2
3
4
5
6
7
8
# 查看淘汰统计
redis-cli info stats | grep evicted
# evicted_keys:12345

# 查看内存使用
redis-cli info memory
# used_memory:1073741824
# maxmemory:2147483648

6.3 预防大量淘汰

1
2
3
4
5
6
# 设置 maxmemory 时留 10-20% 余量
maxmemory 1.8gb  # 如果机器有 2gb

# 监控告警
if memory_usage > 80%:
    alert()

七、总结

概念Redis 实现原因
LRU近似 LRU(采样)避免链表开销
时间戳24 bits 存储节省内存
LFU 计数8 bits + 概率增长对数压缩
删除惰性 + 定期平衡延迟和内存

核心收获

  1. 生产级系统往往用"近似算法"换取效率
  2. 采样足以达到接近最优的效果
  3. 内存优化无处不在(24 bits 而不是 64 bits)
  4. LFU 更适合热点明显的场景