教科书上的 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 // 最久未用
}
// 每次访问,将节点移到头部
// 每次淘汰,删除尾部节点
|
问题:
- 内存开销:每个 key 需要额外 2 个指针(prev/next),64 位系统 = 16 字节/key
- 并发开销:每次读操作都要修改链表,需要加锁
- CPU 缓存不友好:链表节点在内存中不连续
Redis 有数百万甚至上亿 key,这些开销不可接受。
二、Redis 近似 LRU 实现
2.1 核心思想
Redis 的做法:
- 不维护全局链表
- 每个 key 记录最后访问时间戳(24 bits = 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-random | 50% | 52% | 51% |
| allkeys-lru | 65% | 78% | 60% |
| allkeys-lfu | 62% | 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 + 概率增长 | 对数压缩 |
| 删除 | 惰性 + 定期 | 平衡延迟和内存 |
核心收获:
- 生产级系统往往用"近似算法"换取效率
- 采样足以达到接近最优的效果
- 内存优化无处不在(24 bits 而不是 64 bits)
- LFU 更适合热点明显的场景