41.5 淘汰策略与 TTL

一句话

淘汰策略回答“内存满了先扔谁”,TTL 回答“谁到点必须走”——两者是两个维度,必须一起配:只靠淘汰会留下“再也没人访问但一直占内存”的 key,只靠 TTL 会在过期瞬间把压力全打回数据库。

六种淘汰策略

策略 留下谁 实现成本 适合的访问分布 主要缺点
LRU(最近最少使用) 最近被访问过的 中(哈希 + 双向链表) 有局部性、访问模式会变 一次全表扫描会把热数据冲掉
LFU(最不经常使用) 历史访问次数最多的 中高(计数 + 频次桶) 稳定的热点(Zipf) 新 key 冷启动难(需要“新key加分”机制)
CLOCK(时钟) LRU 的近似 低(引用位 + 环形扫描) 同 LRU,内存受限时 精度略差
FIFO 最早进来的 最低 几乎不适合缓存 会把还在热的 key 按进入顺序扔掉
随机 随机 最低 无明显分布 命中率明显低于 LRU/LFU
TTL-only(不淘汰,靠过期) 谁到点谁走 低 数据有明确生命周期 未过期就会把内存撑爆

🔑 淘汰策略的效果完全取决于访问分布。在均匀分布下,所有策略命中率都接近 容量/总数;在 Zipf 分布下,LFU 跑到 72.8%、LRU 67.3%,而 FIFO 与随机只有 62.8% / 63.0%——下面实测。

代码:Zipf 请求流下的四种策略

package main

import (
	"container/list"
	"fmt"
	"math"
	"math/rand"
	"sort"
)

const (
	nKeys    = 10000
	nReqs    = 200000
	capacity = 1000
	alpha    = 1.0 // Zipf 指数
)

// zipf 请求流:key 0 最热,概率 ∝ 1/(k+1)^alpha
func makeRequests() []int {
	cum := make([]float64, nKeys)
	sum := 0.0
	for k := 0; k < nKeys; k++ {
		sum += 1 / math.Pow(float64(k+1), alpha)
		cum[k] = sum
	}
	r := rand.New(rand.NewSource(42))
	out := make([]int, nReqs)
	for i := range out {
		u := r.Float64() * sum
		idx := sort.SearchFloat64s(cum, u)
		if idx >= nKeys {
			idx = nKeys - 1
		}
		out[i] = idx
	}
	return out
}

type cache interface {
	get(k int) bool
	put(k int)
	evictions() int
}

// ---------- LRU ----------
type lru struct {
	cap  int
	ll   *list.List
	m    map[int]*list.Element
	evic int
}

func newLRU(c int) *lru { return &lru{cap: c, ll: list.New(), m: map[int]*list.Element{}} }

func (c *lru) get(k int) bool {
	if e, ok := c.m[k]; ok {
		c.ll.MoveToFront(e)
		return true
	}
	return false
}

func (c *lru) put(k int) {
	if e, ok := c.m[k]; ok {
		c.ll.MoveToFront(e)
		return
	}
	if c.ll.Len() >= c.cap {
		back := c.ll.Back()
		c.ll.Remove(back)
		delete(c.m, back.Value.(int))
		c.evic++
	}
	c.m[k] = c.ll.PushFront(k)
}

func (c *lru) evictions() int { return c.evic }

// ---------- FIFO ----------
type fifo struct {
	cap  int
	q    []int
	m    map[int]bool
	evic int
}

func newFIFO(c int) *fifo { return &fifo{cap: c, m: map[int]bool{}} }
func (c *fifo) get(k int) bool { return c.m[k] }
func (c *fifo) put(k int) {
	if c.m[k] {
		return
	}
	if len(c.q) >= c.cap {
		old := c.q[0]
		c.q = c.q[1:]
		delete(c.m, old)
		c.evic++
	}
	c.q = append(c.q, k)
	c.m[k] = true
}
func (c *fifo) evictions() int { return c.evic }

// ---------- 随机 ----------
type rnd struct {
	cap  int
	keys []int
	m    map[int]bool
	r    *rand.Rand
	evic int
}

func newRand(c int) *rnd {
	return &rnd{cap: c, m: map[int]bool{}, r: rand.New(rand.NewSource(7))}
}
func (c *rnd) get(k int) bool { return c.m[k] }
func (c *rnd) put(k int) {
	if c.m[k] {
		return
	}
	if len(c.keys) >= c.cap {
		i := c.r.Intn(len(c.keys))
		delete(c.m, c.keys[i])
		c.keys[i] = c.keys[len(c.keys)-1]
		c.keys = c.keys[:len(c.keys)-1]
		c.evic++
	}
	c.keys = append(c.keys, k)
	c.m[k] = true
}
func (c *rnd) evictions() int { return c.evic }

// ---------- LFU(每次淘汰扫描计数最小的,容量小时可接受) ----------
type lfu struct {
	cap  int
	cnt  map[int]int
	evic int
}

func newLFU(c int) *lfu { return &lfu{cap: c, cnt: map[int]int{}} }
func (c *lfu) get(k int) bool {
	if _, ok := c.cnt[k]; ok {
		c.cnt[k]++
		return true
	}
	return false
}
func (c *lfu) put(k int) {
	if _, ok := c.cnt[k]; ok {
		c.cnt[k]++
		return
	}
	if len(c.cnt) >= c.cap {
		minK, minV := -1, math.MaxInt
		for kk, vv := range c.cnt {
			if vv < minV {
				minK, minV = kk, vv
			}
		}
		delete(c.cnt, minK)
		c.evic++
	}
	c.cnt[k] = 1
}
func (c *lfu) evictions() int { return c.evic }

func main() {
	reqs := makeRequests()
	fmt.Printf("=== Zipf(α=%.1f):%d 个 key,%d 次请求,容量 %d ===\n\n", alpha, nKeys, nReqs, capacity)
	fmt.Printf("%-8s %-10s %-10s\n", "策略", "命中率", "淘汰次数")

	for _, tc := range []struct {
		name string
		mk   func() cache
	}{
		{"LRU", func() cache { return newLRU(capacity) }},
		{"LFU", func() cache { return newLFU(capacity) }},
		{"FIFO", func() cache { return newFIFO(capacity) }},
		{"随机", func() cache { return newRand(capacity) }},
	} {
		c := tc.mk()
		hits := 0
		for _, k := range reqs {
			if c.get(k) {
				hits++
			} else {
				c.put(k)
			}
		}
		fmt.Printf("%-8s %-10s %-10d\n", tc.name,
			fmt.Sprintf("%.1f%%", float64(hits)*100/nReqs), c.evictions())
	}

	// 容量翻倍后的收益(判断“加内存”值不值)
	for _, cap2 := range []int{2000, 4000} {
		c := newLRU(cap2)
		hits := 0
		for _, k := range reqs {
			if c.get(k) {
				hits++
			} else {
				c.put(k)
			}
		}
		fmt.Printf("容量 %-6d 时 LRU 命中率 %.1f%%\n", cap2, float64(hits)*100/nReqs)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== Zipf(α=1.0):10000 个 key,200000 次请求,容量 1000 ===

策略       命中率        淘汰次数      
LRU      67.3%      64450     
LFU      72.8%      53459     
FIFO     62.8%      73304     
随机       63.0%      73066     
容量 2000   时 LRU 命中率 76.4%
容量 4000   时 LRU 命中率 85.7%

读数:

策略 结论
LFU vs LRU 72.8% vs 67.3%,差 5.5 个百分点;差值来自“历史频次”对稳定热点的预测能力
FIFO / 随机 62.8% / 63.0%,比 LFU 低约 10 个百分点——换策略是零成本的收益
容量 1000 → 4000 命中率 67.3% → 85.7%,回源比例 32.7% → 14.3%;4 倍内存换回源减半,值不值看数据库压力

🔑 选型顺序:先用 LFU(或 LRU + 频次保护)吃 Zipf 红利,再用 TTL 控制陈旧,最后才考虑加内存。换策略是改一行配置,加内存是 4 倍账单——顺序别反。

TTL 的四个数

参数 定得太小 定得太大
TTL 回源放大、命中率下降、库压力上升(41.3) 数据陈旧,用户看到过期信息
TTL 抖动(jitter) 不加抖动 → 同批 key 同时过期 → 雪崩 抖动过大 → 过期时间不可预测,难排查
过期字典开销 — 每条 TTL 约 36 字节(本机 Redis 实测,见 41.9),1 亿 key 就是 3.6 GB
最大内存 太小 → 频繁淘汰,热数据被误杀 太大 → OOM 时连日志都写不进去

TTL 建议值:按“业务能容忍多旧”定,再按“回源能否承受”校验;两者冲突时,优先保证回源能承受(缓存首先是保护数据库的)。

maxmemory-policy 怎么选

策略 行为 适用
noeviction 内存满时写操作报错 不希望丢任何数据(此时缓存不该这么用)
allkeys-lru 所有 key 里按 LRU 淘汰 通用缓存默认值
allkeys-lfu 所有 key 里按频次淘汰 热点稳定的业务(商品详情、配置)
volatile-lru 只淘汰设了 TTL 的 key 缓存与“不能丢的数据”混布同一实例
volatile-ttl 优先淘汰即将过期的 临时数据为主

⚠️ volatile-* 的风险:如果所有 key 都没设 TTL,内存满时行为退化成 noeviction(写失败)。混布“缓存 + 队列/会话”的实例要特别小心这一点。

反模式

反模式 后果
不设 maxmemory,靠机器内存兜 OOM 时 Redis 被内核杀掉,缓存整层消失(41.3)
用 FIFO 当缓存策略 命中率低十几个百分点,等于白买内存
全量 key 同一时刻过期 雪崩,回源打穿数据库
TTL 只有 1–2 秒的“防脏”用法 命中率极低,缓存退化成限流器
缓存与不可丢数据混布,用 allkeys-* 淘汰把队列/会话数据删掉,业务异常
只看命中率,不看回源 QPS 命中率 95% 但基数大时,回源仍可能打穿库

自检

Q1 为什么在 Zipf 分布下,LFU 通常略优于 LRU,但在“突发新热点”时反而更差?

▸ 答案:Zipf 是稳定的长尾分布,历史频次能预测未来,LFU 因此占优。但 LFU 的计数是历史累积的:一个新的热点(如突发新闻、秒杀商品)刚出现时计数为 1,会被已经积累了几万次的老 key 挤掉,导致新热点频繁被淘汰(cache pollution)。工程上的解法是“新 key 加权/定期衰减/窗口计数”(如 TinyLFU 的准入机制),而不是纯 LFU。

Q2 TTL 与淘汰策略解决的是同一个问题吗?

▸ 答案:不是。TTL 解决正确性(数据不能无限陈旧),淘汰解决容量(内存有限时留谁)。只配 TTL 不配淘汰:容量满且没有 key 过期时写入失败或 OOM;只配淘汰不配 TTL:已经没人访问但“看起来还热”(历史计数高)的 key 会长期占内存,且数据可能无限陈旧。两者必须一起配,且 TTL 要加抖动避免雪崩。

Q3 缓存命中率从 90% 提到 95%,回源压力降了多少?为什么这个数字比“命中率”本身更重要?

▸ 答案:回源比例从 10% 降到 5%,回源 QPS 降一半(不是降 5 个百分点)。命中率是“比例”,回源压力是“绝对值”,后者才决定数据库会不会被打穿。同样,命中率从 99% 降到 98% 看起来只差 1 个点,但回源压力翻倍——所以监控要看“回源 QPS 与 P99”,而不是只看命中率。

Q4 一个实例同时存缓存与延迟队列(不可丢),该选哪个 maxmemory-policy?

▸ 答案:volatile-lru(或 volatile-lfu)——只淘汰设了 TTL 的 key(缓存),没设 TTL 的队列数据不会被淘汰;同时要保证所有缓存 key 都设 TTL,否则内存满时退化成 noeviction,写缓存开始报错。更稳的做法是物理隔离:缓存与队列用不同实例/不同集群,从根上避免“淘汰策略要同时满足两种语义”。

关联