主题
第 5 章 过期与内存淘汰
学习目标:把「Key 怎么过期」和「内存满了 Redis 怎么挑 Key 踢」这两件事彻底搞清楚。看完之后能解释「为什么 Redis 不维护一张完整 LRU 链表」「allkeys-lru 和 volatile-lru 选哪个」「为什么从节点不主动删过期 Key」这些大厂高频追问。
5.1 概览:内存吃紧时 Redis 怎么办
Redis 是纯内存数据库,内存就是它的命根子。当数据越塞越多,迟早有两种情况会发生:
- 某些 Key 到期了——业务设了
EXPIRE,时间一到这些 Key 就该消失。 - 内存写满了——
maxmemory上限被触达,再写入就要么报错要么挑老的踢出。
这两件事看起来都是「删 Key」,但触发时机、删除目标、执行策略完全不同:
┌─────────────────────────────────────┐
│ Redis 内存治理两条线 │
└────────────┬────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ ① 过期机制 │ │ ② 内存淘汰策略 │
│ Expiration │ │ Eviction │
├──────────────────────┤ ├──────────────────────┤
│ 触发:用户设了 TTL │ │ 触发:used_memory │
│ 且时间到了 │ │ ≥ maxmemory │
├──────────────────────┤ ├──────────────────────┤
│ 目标:仅带 TTL 的 Key │ │ 目标:按策略选 Key │
├──────────────────────┤ ├──────────────────────┤
│ 策略:惰性 + 定期 │ │ 策略:8 种可配 │
│ (没有定时!) │ │ noeviction / lru … │
└──────────────────────┘ └──────────────────────┘🍱 生活类比:把 Redis 想象成一台冰箱。
- 过期机制 = 牛奶盒上的「保质期」:到期了就该扔,但你不会守着冰箱看时间,而是「拿出来喝的时候顺手看一眼」(惰性)+「每天巡一次冰箱」(定期)。
- 内存淘汰 = 冰箱塞不下了:要往里塞新菜,得先决定扔掉哪盘旧菜,是按「最近一次吃的时间」(LRU)还是「最常吃的频次」(LFU)。
本章我们就把这两条线一一拆开。
5.2 Key 过期机制
5.2.1 三种删除策略:定时、惰性、定期
讲过期机制之前,先把课本上的「三种删除策略」摆出来:
┌───────────────────┬────────────────────────────────┬───────────────┬───────────┐
│ 策略 │ 工作方式 │ 优点 │ 缺点 │
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ① 定时删除 │ 每个 Key 设 TTL 时挂一个定时器, │ 内存友好 │ CPU 爆炸 │
│ (Timer-based) │ 时间到立刻 DEL │ 立刻释放 │ 定时器开销大│
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ② 惰性删除 │ 访问 Key 时才检查是否过期, │ CPU 最省 │ 内存浪费 │
│ (Lazy) │ 过期了顺手 DEL │ │ 不访问就不删│
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ③ 定期删除 │ 后台每秒跑 N 次,每次随机抽样 │ CPU/内存折中 │ 不及时 │
│ (Active) │ 检查并删过期 Key │ │ 仍有浪费 │
└───────────────────┴────────────────────────────────┴───────────────┴───────────┘⚠️ 网上一个超常见的误传:很多文章会说「Redis 同时使用了三种删除策略」。
真相:Redis 从来没有实现「定时删除(Timer-based)」!源码里压根就没有为每个过期 Key 挂定时器。Redis 实际只用了惰性删除 + 定期删除这两种组合。
为什么不用定时?想象一下 100 万个 Key 都设了 TTL,那就是 100 万个定时器排队,CPU 直接被定时器调度搞崩。
所以,Redis 的真实组合是:
┌──────────────────────────────────────┐
│ Redis 真实的删除策略 │
├──────────────────────────────────────┤
│ │
│ 访问时 → 惰性删除 (expireIfNeeded) │
│ + │
│ 后台 hz=10 → 定期删除 (activeExpire) │
│ │
│ ❌ 没有「定时删除」 │
│ │
└──────────────────────────────────────┘5.2.2 惰性删除(Lazy Expiration)
🥛 生活类比:你打开冰箱拿牛奶喝,瞄一眼保质期才发现过期了——这才把它扔掉。如果你不去拿,它就永远躺在冰箱里。
Redis 的所有数据访问命令(GET / HGET / LPOP / ...)在真正读写数据之前,都会先调一个函数:
c
// Redis 源码 src/expire.c(简化版)
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db, key)) return 0; // 没过期 → 直接返回
if (server.masterhost != NULL) return 1; // 从节点不主动删,等主节点 DEL 同步
// 主节点:真删
server.stat_expiredkeys++; // expired_keys 计数 +1
propagateExpire(db, key, server.lazyfree_lazy_expire); // 写入 AOF / 同步从
notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db->id);
return server.lazyfree_lazy_expire ?
dbAsyncDelete(db, key) : // 异步删
dbSyncDelete(db, key); // 同步删
}调用时机:
用户命令
│
▼
┌─────────────────────────┐
│ lookupKeyRead/Write() │
│ ├─ expireIfNeeded() ─┼─→ 过期?→ DEL it,返回 nil
│ └─ 真正读/写 │
└─────────────────────────┘用 redis-cli 实测一下:
bash
127.0.0.1:6379> SET k1 hello EX 2 # 设个 2 秒就过期
OK
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:0
# ⏳ 等待 5 秒...
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:0 # 没访问 → 没触发惰性删除
127.0.0.1:6379> GET k1 # 访问的瞬间,惰性删除被触发
(nil)
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:1 # 计数 +1优缺点:
✅ 优点:CPU 几乎零开销,删除均摊到正常请求中
❌ 缺点:如果 Key 从此再无人访问,它会"僵尸般"占用内存
↑ 所以必须配合「定期删除」兜底5.2.3 定期删除(Active Expiration)
惰性删除有个致命缺陷:冷数据永远不会被清理。比如你给一批日志 Key 设了 1 天 TTL,但这些 Key 写完之后再也没人 GET 过,那它们就躺在内存里直到 Redis 重启。
为了兜住这个漏洞,Redis 还有一个后台任务,每秒跑 hz 次(默认 10 次,可调),主动扫描带 TTL 的 Key:
c
// Redis 源码 src/expire.c —— activeExpireCycle 简化逻辑
void activeExpireCycle(int type) {
// 每个 db 循环
for (db_idx in dbs) {
do {
// 1. 从 db->expires 字典里随机抽 20 个 key
sample = randomSample(db->expires, ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP);
// 默认 ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP = 20
// 2. 检查并删除已过期
int expired = 0;
for (k in sample) {
if (isExpired(k)) { dbDelete(k); expired++; }
}
// 3. 如果一轮里过期比例 ≥ 25%,再来一轮
} while (expired > sample.size * 0.25);
// 4. 单次总耗时不超过 25 ms(避免阻塞)
}
}用图把流程画出来:
每秒 hz=10 次(默认每 100ms 一次)
│
▼
┌──────────────────────────────────┐
│ 从 db->expires 哈希表抽 20 个 Key │
└──────────────┬───────────────────┘
│
┌──────────┴──────────┐
▼ ▼
没过期 → 跳过 过期了 → DEL
│ │
└──────────┬──────────┘
▼
┌──────────────────────────────────┐
│ 统计:本轮过期比例 = 过期数 / 20 │
└──────────────┬───────────────────┘
│
≥ 25%? ◇
┌────────┴────────┐
是 否
│ │
▼ ▼
再来一轮(继续抽) 本次结束
(直到 25ms 总耗时上限)💡 为什么是 25% 这个阈值? 这是 Redis 作者基于经验值定的——如果一批抽样里超过 1/4 都过期了,说明这个 db 的过期 Key 还很多,趁热打铁;否则就放过这一轮。
几个关键参数(写在 redis.conf 里):
bash
hz 10 # 后台任务频率,每秒 10 次
# 调高(如 100)→ 过期更及时但 CPU 占用高
# 一般保持默认 10 即可
active-expire-effort 1 # Redis 6+ 新增,1~10
# 越大 → 单次过期扫描越积极(最多 10)实测「定期删除」生效:
bash
# 写 1000 个 1 秒过期的 Key
127.0.0.1:6379> EVAL "for i=1,1000 do redis.call('SET','tmp:'..i,'v','EX',1) end" 0
127.0.0.1:6379> DBSIZE
(integer) 1000
# 不去访问任何一个 Key,等 30 秒
127.0.0.1:6379> DBSIZE # 30 秒后再看
(integer) 0 # 已经被定期删除清完了
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:1000 # 全都计入5.2.4 主从复制下的过期:从节点不主动删
这是个大厂超爱问的点:
❓ 问:主从架构下,一个带 TTL 的 Key 在从节点上会不会自动过期?
✅ 答:不会。从节点不会主动删除任何过期 Key,必须等主节点先删,再通过复制同步
DEL命令到从节点。
为什么这么设计?
┌─────────── 反例:如果从节点也主动删 ───────────┐
│ │
主节点 从节点
───── ──────
t=0: SET k v EX 10 ─── 同步 ──→ SET k v EX 10
t=8: 在主节点上看到 GET k → "v"
(主节点 OK,因为还没到 10 秒)
↑
t=11: GET k → (nil) (主节点惰性删除)
┆
┆ 但因主从有 ms 级时钟漂移,
┆ 从节点在 t=9.95 时本地时间已 ≥ 10s
┆ → 假如从节点主动删了,
┆ → 主节点上 GET 还能读到,从节点上读不到
┆ → 主从数据不一致!
└────────────────────────────────────────────┘为了避免主从数据不一致,Redis 强制:
┌──────────────────────────────────────────────────┐
│ 从节点的过期处理规则: │
│ │
│ ① 从节点本地不主动检测过期 → 即使本地时间过了也保留 │
│ ② 等主节点删了 Key 之后,会向所有从节点广播 DEL │
│ ③ 客户端读从节点时,旧版本会返回脏数据 │
│ —— 3.2 之后改为「读到过期 Key 直接返回 nil」 │
│ (但内存还没释放,等主节点 DEL 同步过来) │
└──────────────────────────────────────────────────┘面试加分点:3.2 起,从节点上的读操作即使发现 Key 在本地已经过期,也只会返回 nil而不会真的删除——这样既保证用户感知正确,又保证主从数据一致性的最终归属权完全在主节点手里。
5.3 内存淘汰策略(8 种)
5.3.0 触发时机
每次客户端写入命令前
│
▼
┌──────────────────────────────┐
│ used_memory ≥ maxmemory ? │
└──────────────┬───────────────┘
│
否 ──→ 直接执行写入
│
是
▼
┌──────────────────────────────┐
│ 按 maxmemory-policy 淘汰 │
│ 腾出空间后再执行写入 │
└──────────────────────────────┘⚠️ 注意:
maxmemory0 表示无上限(默认)。生产环境必须设,否则一旦内存吃满,操作系统会 OOM Kill 整个 Redis 进程。
5.3.1 noeviction(默认)
┌──────────────────────────────────────────┐
│ noeviction:内存满了直接拒绝写入 │
│ │
│ 写入命令 → (error) OOM command not │
│ allowed when used memory │
│ > 'maxmemory'. │
│ │
│ 读命令 / DEL 仍然正常 │
└──────────────────────────────────────────┘适用场景:数据不能丢的纯持久化场景(不应该用 Redis,但偶尔会有人这么用)。绝大多数场景下不推荐——你的应用会因为写报错而崩溃。
5.3.2 volatile-lru / allkeys-lru
volatile-lru → 仅在「设了 TTL 的 Key」中淘汰最久未访问的
allkeys-lru → 在所有 Key 中淘汰最久未访问的📚 LRU = Least Recently Used,「最近最久未使用」。
访问时间轴(左边老,右边新)
老 ─────────────────────────→ 新
K1 K3 K2 K5 K4
↑ ↑
最久未访问 最近访问
↑ 淘汰它5.3.3 volatile-lfu / allkeys-lfu
volatile-lfu → 仅在「设了 TTL 的 Key」中淘汰访问频次最低的
allkeys-lfu → 在所有 Key 中淘汰访问频次最低的📊 LFU = Least Frequently Used,「最近最少使用次数」。
访问频次统计
K1: ▇▇▇▇▇▇▇ (7 次)
K2: ▇ (1 次) ← 淘汰它(虽然刚刚才访问,但总频次最低)
K3: ▇▇▇▇▇ (5 次)
K4: ▇▇ (2 次)5.3.4 volatile-random / allkeys-random
volatile-random → 仅在「设了 TTL 的 Key」中随机踢
allkeys-random → 所有 Key 里随机踢最简单粗暴。适用于业务对「淘汰精度」无所谓的场景,比如完全均匀分布的访问(理论上很少见)。
5.3.5 volatile-ttl
volatile-ttl → 在「设了 TTL 的 Key」中,挑「剩余存活时间最短」的踢 K1: TTL=300s
K2: TTL=10s ← 淘汰它(反正马上就要死了)
K3: TTL=600s
K4: 没设 TTL ← 不参与5.3.6 8 种策略全景图
┌─────────────────────┬───────────────────┬─────────────────────────┐
│ 策略名 │ 作用范围 │ 挑选规则 │
├─────────────────────┼───────────────────┼─────────────────────────┤
│ noeviction │ —— │ 不淘汰,写入报 OOM │
│ allkeys-lru │ 所有 Key │ 最近最久未使用 │
│ volatile-lru │ 仅 TTL Key │ 最近最久未使用 │
│ allkeys-lfu │ 所有 Key │ 访问频次最低 │
│ volatile-lfu │ 仅 TTL Key │ 访问频次最低 │
│ allkeys-random │ 所有 Key │ 随机 │
│ volatile-random │ 仅 TTL Key │ 随机 │
│ volatile-ttl │ 仅 TTL Key │ 剩余 TTL 最小 │
└─────────────────────┴───────────────────┴─────────────────────────┘5.3.7 决策树:到底选哪个?
业务用 Redis 的目的?
│
┌───────────────┼───────────────┐
│ │
纯缓存 缓存 + 持久数据混合
(丢了能从 DB 重建) (部分 Key 不能丢)
│ │
▼ ▼
在意访问频率分布吗? 所有「可淘汰 Key」都设 TTL
│ │
┌────────┴────────┐ ▼
是 否 选 volatile-* 系列
│ │ │
▼ ▼ │
有明显热点? 近期访问 ≈ 总频率 ▼
│ │ 在意频率?
┌─┴─┐ │ │
是 否 ▼ ┌──┴──┐
│ │ allkeys-lru 是 否
▼ ▼ │ │
allkeys-lfu ▼ ▼
(推荐!) volatile-lfu volatile-lru
特殊场景:
- 访问完全均匀 → allkeys-random(极少见)
- 想优先清快过期的 → volatile-ttl
- 数据绝对不能丢 → noeviction(但要监控内存!)🎯 80% 的缓存场景推荐:
allkeys-lru或allkeys-lfu。 后者在「热点 Key 长期高频」时表现更稳定。
5.4 LRU 近似实现
5.4.1 真实 LRU 链表的内存代价
学校里教 LRU,老师都会画一张「双向链表」:
头部(最近访问) 尾部(最久未用)
│ │
▼ ▼
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ K1 │⇄│ K3 │⇄│ K7 │⇄│ K2 │⇄│ K9 │⇄│ K5 │⇄│ K4 │ ← 淘汰
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘每次访问 K3 → 把 K3 摘出来移到头部,O(1) 操作。淘汰 → 直接从尾部摘,O(1) 操作。完美。
但这个完美方案在 Redis 里是个奢侈品:
代价分析(假设 Redis 里有 1 亿个 Key):
- 每个 Key 要多 2 个指针(prev, next)
- 64 位机器:2 × 8 = 16 字节 / Key
- 1 亿 × 16 字节 = 1.6 GB 纯指针开销!
- 链表节点本身还要至少 32~48 字节的元数据而且,每次访问都要做链表节点的「摘除 + 头插」,在多线程或多核场景下链表头部就是热点锁,极易成为瓶颈。
5.4.2 Redis 的近似 LRU
Redis 的方案:给每个 redisObject 加一个 24 bit 的 lru 字段,记录这个 Key 最后一次被访问时的「时钟」。淘汰时不维护链表,而是:
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // ★ LRU 时钟(24 bit ≈ 194 天循环)
int refcount;
void *ptr;
};访问时:把 obj->lru 设为当前 server 的 LRU clock(每秒更新一次)。
淘汰时:
随机采样池(默认 maxmemory-samples = 5)
│
▼
┌─────────────────────────────────────┐
│ 从 db->dict(或 db->expires) │
│ 随机抽 5 个 Key │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 根据 lru 字段算「空闲时间」 │
│ idle = LRU_clock - obj->lru │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 把「空闲时间最长的」放进淘汰池 │
│ (池子大小 16,按 idle 排序) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 淘汰池满了?→ 弹出 idle 最大的 Key 删 │
│ 没满?→ 下一轮再抽 5 个继续填 │
└─────────────────────────────────────┘画一下采样池的演化:
假设 db 里有 K1~K10,真实 idle 时长(数字越大越久未访问):
K1=100, K2=300, K3=50, K4=800, K5=200, K6=600, K7=20, K8=400, K9=900, K10=10
┌─────────────── 第一次采样池(容量 16) ───────────────┐
│ 随机抽 5 个:K3(50) K7(20) K2(300) K8(400) K4(800) │
│ 排序后池子(按 idle 升序): │
│ K7(20) K3(50) K2(300) K8(400) K4(800) │
└────────────────────────────────────────────────────────┘
│
▼ 第二次采样
┌─────────────── 池子继续填(保持 idle 排序) ───────────┐
│ 又随机抽 5 个:K1(100) K5(200) K6(600) K9(900) K10(10)│
│ 合并入池后(仍升序,最多 16 个): │
│ K10(10) K7(20) K3(50) K1(100) K5(200) K2(300) │
│ K8(400) K6(600) K4(800) K9(900) │
└────────────────────────────────────────────────────────┘
│
▼ 淘汰
┌─────────────── 弹出 idle 最大的 ───────────────────────┐
│ K9(900) ← 被踢出! │
└────────────────────────────────────────────────────────┘采样数 = 精度 vs CPU 的取舍:
bash
# redis.conf 配置
maxmemory-samples 5 # 默认 5:CPU 友好,精度约 90%
# 10:精度 ~99%,CPU 多花一点
# 3:CPU 最省,精度下降💡 Redis 作者 antirez 实测过:采样 5 已经能逼近真实 LRU 95%+ 的精度,10 几乎和真实 LRU 无法区分。所以默认 5 是个「省 CPU + 够用」的甜点。
5.4.3 「采样池」是 3.0 后的优化
3.0 之前 Redis 是「随机抽 N 个直接淘汰最老的」,效果不够好。3.0 引入了淘汰池(eviction pool)——一个全局的、按 idle 时长排序的固定大小(16)的池子。每次采样新抽出的 Key 都尝试合并进池,淘汰时只挑池里最老的。这个改进让 Redis 的近似 LRU 几乎等同于真实 LRU。
5.5 LFU 近似实现
5.5.1 LRU vs LFU 的本质区别
时间轴 ──────────────────────────────────→
K1: ▇ ▇ ▇ (3 次, 最近访问)
K2: ▇▇▇▇▇▇▇▇▇▇ (10 次, 但很久前)
K3: ▇ (1 次, 最近访问)
┌──────────────────────────────────────────┐
│ LRU 视角:「最近一次什么时候用?」 │
│ → K2 最久没碰,淘汰 K2 │
│ │
│ LFU 视角:「累计访问了多少次?」 │
│ → K3 只访问 1 次,淘汰 K3 │
└──────────────────────────────────────────┘典型场景对比:
| 场景 | LRU 表现 | LFU 表现 |
|---|---|---|
| 持续高频热点 | ✅ 好 | ✅ 更好(不会被偶发冷数据冲掉) |
| 突发流量(短时段大量一次性访问) | ❌ 把真热点冲走 | ✅ 不受影响 |
| 周期性变化(早晚活跃用户不同) | ✅ 跟得上 | ❌ 历史频次会拖累新热点 |
5.5.2 8 bit 计数器:怎么估算 ~100 万次访问?
把每个 Key 都搞个 32 bit 计数器太占内存。Redis 复用了 LRU 的那 24 bit 字段,拆成两半:
Redis 4.0 LFU 模式下 redisObject.lru 字段(24 bit 复用):
┌──────────────────┬──────────────────────────────┐
│ 16 bit │ 8 bit │
│ ldt(上次衰减时间)│ counter(访问频次估计) │
│ 分钟为单位 │ 0~255 │
└──────────────────┴──────────────────────────────┘8 bit 最大 255,哪能表示 100 万访问?答案是对数计数器(Morris 概率计数)。
算法:每次访问 Key,不一定让 counter +1,而是有概率才加:
c
// 来自 src/evict.c(简化)
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = rand()/RAND_MAX; // 随机数 [0,1)
double baseval = counter - LFU_INIT_VAL; // 减去初始值 5
if (baseval < 0) baseval = 0;
double p = 1.0 / (baseval * server.lfu_log_factor + 1);
if (r < p) counter++;
return counter;
}直观理解:counter 越大,下一次 +1 的概率越小。这样:
- counter = 0 时:几乎每次访问都 +1
- counter = 100 时:每一千次访问才加 1
- counter = 200 时:每百万次访问才加 1
实测数值(lfu-log-factor=10,默认):
访问次数 counter 估计值
─────── ─────────
10 1
100 5
1,000 10
10,000 18
100,000 142
1,000,000 255 (饱和)8 bit 就这样表达了 6 个数量级的访问频次。
5.5.3 概率衰减:让历史不要拖累现在
光涨不降的话,热门一阵子的 Key 就永远是热门了。Redis 引入时间衰减:
每次访问 Key 时:
Δt = 当前分钟 - 上次衰减分钟(ldt 字段)
decrement = Δt / lfu-decay-time # decay-time 默认 1 分钟
counter = max(0, counter - decrement)
ldt = 当前分钟 示例(lfu-decay-time=1):
K1 初始 counter=100, ldt=10:00
K1 在 10:30 时检查:Δt = 30 分钟 → counter -= 30 → counter=70
这样过了 100 分钟没访问,counter 自然归零,K1 就成了候选淘汰目标两个关键参数:
bash
lfu-log-factor 10 # 计数器增长速度(越大涨越慢,能区分更高的频次范围)
# 默认 10,可调 0~∞
lfu-decay-time 1 # 衰减周期(分钟)
# 0 表示永不衰减
# 默认 1,可调5.5.4 调试用:OBJECT FREQ
bash
# 必须先开 LFU 策略
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu
127.0.0.1:6379> SET k1 v
127.0.0.1:6379> GET k1
127.0.0.1:6379> GET k1
127.0.0.1:6379> GET k1
127.0.0.1:6379> OBJECT FREQ k1
(integer) 6 # 初始 5 + 几次 GET 涨上来的⚠️
OBJECT FREQ只在 LFU 策略下能用,其他策略会报错。
5.6 maxmemory 配置实战
5.6.1 配置示例
bash
# redis.conf
maxmemory 4gb # 上限 4GB
maxmemory-policy allkeys-lru # 推荐缓存场景
maxmemory-samples 5 # LRU/LFU 采样数
# LFU 模式才生效
lfu-log-factor 10
lfu-decay-time 1也可以运行时动态设置(重启失效):
bash
127.0.0.1:6379> CONFIG SET maxmemory 4gb
OK
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu
OK5.6.2 怎么估算合理的 maxmemory
┌──────────────────────────────────────────────────────┐
│ 服务器总物理内存 │
├────────────┬────────────┬───────────────────────────┤
│ 操作系统 │ 其他进程 │ Redis 进程 │
│ ~10% │ ~10% │ ~80% │
└────────────┴────────────┴────────────┬──────────────┘
│
┌───────────────────────┴────────────────────┐
│ │
maxmemory fork 期间额外内存
(读写正常用) (RDB/AOF 重写时复制页)
~70% ~30%(最坏情况翻倍!)经验公式:
maxmemory ≈ (机器物理内存 × 0.6) ~ (机器物理内存 × 0.7)🚨 为什么要留 30%?Redis 的 RDB / AOF 重写靠
fork(2)实现 COW(Copy-On-Write)。如果父进程在 fork 期间被大量写入,修改过的内存页会被全部复制一份——最坏情况下,子进程的内存几乎等于父进程当时的全部 used_memory。如果不留余量,机器直接 OOM Kill。
5.6.3 监控关键指标
bash
127.0.0.1:6379> INFO memory
used_memory:1234567890 # 已用字节数
used_memory_human:1.15G
used_memory_peak_human:1.20G # 历史峰值
used_memory_rss:1300000000 # 操作系统视角的 RSS(含碎片)
mem_fragmentation_ratio:1.05 # rss / used_memory,>1.5 就该重启
maxmemory:4294967296
maxmemory_policy:allkeys-lru
127.0.0.1:6379> INFO stats
expired_keys:1024 # 累计被过期删除的 Key
evicted_keys:5012 # 累计被淘汰策略踢出的 Key
keyspace_hits:1000000 # 命中次数
keyspace_misses:5000 # 未命中次数 → 算命中率报警建议:
┌──────────────────────────┬──────────────────────────────┐
│ 指标 │ 报警条件 │
├──────────────────────────┼──────────────────────────────┤
│ used_memory / maxmemory │ > 80% │
│ evicted_keys / 秒 │ > 1000(持续触发淘汰,热数据被冲走)│
│ mem_fragmentation_ratio │ > 1.5(碎片严重) │
│ keyspace_misses 突增 │ 缓存命中率下降 │
└──────────────────────────┴──────────────────────────────┘5.7 实操:跑一遍配套代码 + demo
实战代码见 05_expire_eviction/code/:
01_expire_strategies.py:观察惰性删除与定期删除的实际行为02_lru_simulator.py:手写采样 LRU 与真 LRU 的命中率对比03_eviction_demo.py:通过CONFIG SET maxmemory触发 8 种淘汰策略
浏览器演示见 05_expire_eviction/demo.html:
- ① 过期机制可视化:键空间网格 + 倒计时,对比惰性 vs 定期
- ② LRU vs LFU 对比:相同访问序列,两种算法的淘汰差异动画
- ③ 8 种淘汰策略沙盒:交互式选择策略,观察踢出哪个 Key
5.8 本章小结
┌────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├────────────────────────────────────────────────────────┤
│ │
│ ① 过期机制 = 惰性删除 + 定期删除 │
│ ❌ Redis 没有「定时删除」(网传的误区) │
│ │
│ ② 惰性删除:访问 Key 时调 expireIfNeeded │
│ 定期删除:hz=10 次/s,每次抽 20 个,过期≥25% 再来一轮 │
│ │
│ ③ 主从复制:从节点不主动删,等主节点 DEL 同步 │
│ —— 3.2+ 从节点读到过期 Key 直接返回 nil │
│ │
│ ④ 内存淘汰策略 8 种: │
│ noeviction / {allkeys,volatile} × {lru,lfu,random} │
│ + volatile-ttl │
│ │
│ ⑤ Redis 用「采样近似 LRU」:每个 obj 24 bit lru 字段 │
│ 淘汰时随机抽 5 个 + 维护 16 容量的淘汰池 │
│ │
│ ⑥ LFU = Morris 概率计数器(8 bit 估算 6 个数量级) │
│ + 时间衰减(lfu-decay-time) │
│ │
│ ⑦ maxmemory 留 30% 给 fork(COW 最坏翻倍) │
│ │
│ ⑧ 必看监控:evicted_keys, expired_keys, fragmentation │
│ │
└────────────────────────────────────────────────────────┘5.9 面试高频题
Q1:Redis 怎么删除过期 Key?为什么用「惰性 + 定期」组合?
考察点:过期机制的整体设计哲学。
标准答案:
Redis 实际只用了两种删除策略:
- 惰性删除(Lazy):客户端访问 Key 时,Redis 在
lookupKeyRead/Write中调用expireIfNeeded,发现过期就 DEL 并返回 nil。优点是 CPU 几乎零开销;缺点是冷 Key 永远不删。 - 定期删除(Active):后台
serverCron任务每秒触发hz次(默认 10 次),每次从db->expires字典里随机抽 20 个 Key 检查过期,如果一轮中过期比例 ≥ 25% 就再来一轮,单次最长跑 25 ms。优点是兜底冷 Key;缺点是仍有内存延迟释放。
为什么不用「定时删除」:
- 给每个带 TTL 的 Key 挂一个定时器,会把 CPU 耗在定时器调度上。
- Redis 是单线程模型,CPU 资源极其宝贵,把 CPU 留给业务请求更划算。
加分项:
- 提到
hz参数和active-expire-effort(6.0+)可调过期回收力度。 - 主从架构下从节点不主动删除,避免主从数据不一致。
易错点:
- 千万别说「Redis 用了三种删除策略」——这是网上传烂了的误区,源码里压根没有定时删除。
Q2:8 种内存淘汰策略分别什么时候用?
考察点:场景匹配能力。
标准答案(按业务场景分类):
┌─────────────────────┬──────────────────────────────────┐
│ 策略 │ 典型场景 │
├─────────────────────┼──────────────────────────────────┤
│ noeviction │ 数据绝对不能丢,宁可写报错 │
│ allkeys-lru │ 通用缓存(最常用!80% 场景的默认) │
│ allkeys-lfu │ 有持续高频热点(比如商品详情、首页)│
│ allkeys-random │ 访问完全均匀(少见) │
│ volatile-lru │ 缓存 + 持久数据混存,缓存全设 TTL │
│ volatile-lfu │ 同上,且热点稳定 │
│ volatile-random │ 同上,但不在意精度 │
│ volatile-ttl │ 设了 TTL 的临时数据,优先清快过期的 │
└─────────────────────┴──────────────────────────────────┘加分项:
allkeys-lfu在突发流量场景下不会被「秒杀型一次性访问」冲掉真热点。volatile-*的隐性约束:所有「能淘汰的 Key」必须设 TTL,否则全是「不可淘汰」会触发 OOM 写报错。- Redis 4.0 才引入 LFU,在此之前生产基本都是
allkeys-lru。
易错点:
volatile-lru不会动没设 TTL 的 Key——如果你的「持久数据」没设 TTL 而想用 LRU 淘汰,应该选allkeys-lru。
Q3:LRU 和 LFU 的区别?Redis 怎么近似实现 LRU 的?
考察点:算法理解 + 工程实现取舍。
标准答案:
算法本质区别:
- LRU 关注「最近一次访问的时间」,把最久没碰的踢出。
- LFU 关注「累计访问的频次」,把总频次最低的踢出。
LFU 优于 LRU 的场景:突发一次性访问(比如恶意爬虫扫一遍冷数据)会把 LRU 的真热点全部冲走,LFU 不会,因为偶发访问的 counter 涨不上来。
Redis 的近似 LRU 实现:
- 每个 redisObject 有 24 bit 的
lru字段,记录最后一次访问时的全局 LRU clock(每秒更新)。 - 不维护任何链表,淘汰时随机抽
maxmemory-samples(默认 5)个 Key。 - 把这些 Key 按「空闲时长」放进一个全局淘汰池(容量 16,按 idle 升序)。
- 淘汰池满 → 弹出 idle 最大的 Key 删掉,腾出空间。
精度:实测采样 5 已经能达到真实 LRU 95%+ 的命中率,采样 10 几乎与真实 LRU 无法区分。
加分项:
- 提到 3.0 引入的「淘汰池」是关键优化,3.0 之前是「直接踢抽样里最老的」,效果差很多。
- LFU 的实现细节:8 bit 计数器 + Morris 概率对数计数 + 时间衰减(
lfu-decay-time)。 - LFU 模式下 24 bit 字段拆为「16 bit ldt + 8 bit counter」。
易错点:
- 别说 Redis 维护了 LRU 链表——它没有,链表内存代价太大。
Q4:为什么 Redis 不维护一个真正的 LRU 链表?
考察点:对内存与 CPU 取舍的理解。
标准答案:
- 内存代价巨大:双向链表每个节点至少 2 个指针,64 位机上 16 字节/Key。1 亿 Key = 1.6 GB 纯指针开销。
- 维护代价高:每次访问都要把节点摘除并重新插到头部,多线程下链表头是热点,单线程下也要 O(1) 但常数不小。
- 精度收益不显著:实测采样 5~10 个就已经能达到真实 LRU 95~99% 的精度,工程上完全够用。
- Redis 是性能优先的内存数据库:「省内存」和「短关键路径」比「算法理论最优」更重要。
加分项:
- 提到 antirez 在博客里专门撰文解释过这个设计取舍。
- 真正的 LRU 链表在多核 / 并发场景下会成为瓶颈,采样法天然无锁。
Q5:主从复制下,从节点会主动删除过期 Key 吗?
考察点:主从数据一致性细节。
标准答案:
不会。从节点不会主动检测和删除任何过期 Key,必须等待主节点删除后通过复制流广播 DEL 命令同步过来。
为什么这么设计:
- 主从之间存在毫秒级时钟漂移,如果各自独立判断过期,会出现「主节点上 Key 还有效,从节点上已经被删」的不一致。
- 一旦客户端读从节点拿到 nil,但读主节点拿到值,业务逻辑就错乱了。
Redis 3.2 之前的坑:
- 那时从节点读到本地已过期的 Key 会返回脏数据(还没等到主节点 DEL 同步过来)。
- 3.2 起改为「读到过期 Key 直接返回 nil」——既保证用户感知正确,又不真删(删除权完全归主节点)。
加分项:
replica-read-only yes(默认开启)禁止在从节点写数据,否则会出现更严重的不一致。- 主节点删除 Key 时会写入
DEL到 AOF 和 replication backlog,从节点严格按主节点节奏走。
易错点:
- 不要说「从节点也会按 hz 跑定期删除」——它不会对带 TTL 的 Key 跑
activeExpireCycle。
Q6:缓存场景该用 allkeys-lru 还是 volatile-lru?为什么?
考察点:策略选择的工程判断。
标准答案:
绝大多数纯缓存场景推荐 allkeys-lru。原因:
- 简单一致:缓存就是「随时可重建」的数据,没必要给每个 Key 都设 TTL,让 LRU 自动管理冷热即可。
- 避免 OOM 写报错:用
volatile-lru时,如果你忘了给某个 Key 设 TTL,它就成了「不可淘汰」。当所有可淘汰 Key 都被踢光后,新写入会触发 OOM 报错。这个坑我们排查过线上事故。 - LRU 本身就解决「过期」问题:冷数据自然被踢,热数据留着——TTL 在缓存场景反而是冗余。
什么时候用 volatile-lru:
- Redis 实例同时存「缓存」和「业务持久数据」(比如 Session、配置)。
- 持久数据不能被淘汰,缓存可以。
- 此时给所有缓存设 TTL,淘汰范围限定在
volatile-*。
加分项:
- 实际更推荐:两套数据物理隔离(不同 Redis 实例 / 不同 db)。混存容易出错。
- 进一步追问 LFU vs LRU:有稳定高频热点选 LFU,否则 LRU 就够。
易错点:
- 用
volatile-lru但忘了所有 Key 都设 TTL,一上线就 OOM 写报错。
📌 下一章预告:第 6 章我们看 Redis 的「持久化」——RDB 快照、AOF 写后日志、4.0 的混合持久化。看完之后能解释「为什么 BGSAVE 用 fork」「AOF 重写到底重写了什么」。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
Ch5 配套代码 1 / 3 —— 过期机制实测
演示:
1. 惰性删除:写入 TTL=2s 的 Key,sleep 3s 后不主动触发,
直到 GET 才发现过期被删,expired_keys 才 +1
2. 定期删除:批量写入大量短 TTL Key,不去访问,观察后台
activeExpireCycle 自动清理(DBSIZE 自然下降)
"""
import time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def get_expired_count() -> int:
return int(r.info("stats")["expired_keys"])
def demo_lazy_expiration() -> None:
section("Demo 1: 惰性删除 —— 不访问就不触发")
key = "demo:lazy:k1"
r.delete(key)
base = get_expired_count()
r.set(key, "hello", ex=2)
print(f" 写入 {key} TTL=2s,当前 expired_keys = {base}")
print(" ⏳ sleep 3s,期间不访问 Key...")
time.sleep(3)
after_sleep = get_expired_count()
print(f" sleep 后 expired_keys = {after_sleep}(无变化 → 惰性删除未触发)")
val = r.get(key)
print(f" GET {key} → {val!r}")
after_get = get_expired_count()
print(f" GET 之后 expired_keys = {after_get}({'+1' if after_get > after_sleep else '无变化'} → 惰性删除被触发)")
def demo_active_expiration(n: int = 2000) -> None:
section(f"Demo 2: 定期删除 —— 后台自动清理 {n} 个短 TTL Key")
base = get_expired_count()
base_dbsize = r.dbsize()
pipe = r.pipeline(transaction=False)
for i in range(n):
pipe.set(f"demo:active:k{i}", "v", ex=1)
pipe.execute()
print(f" 写入 {n} 个 TTL=1s 的 Key,当前 DBSIZE={r.dbsize()}, expired={get_expired_count() - base}")
print(" ⏳ 不访问任何 Key,等定期任务慢慢清...\n")
print(f" {'秒':>4} | {'DBSIZE':>8} | {'本轮新增 expired':>18}")
print(" " + "-" * 40)
last_expired = get_expired_count()
for sec in range(1, 21):
time.sleep(1)
cur_expired = get_expired_count()
cur_size = r.dbsize()
print(f" {sec:>4} | {cur_size:>8} | {cur_expired - last_expired:>18}")
last_expired = cur_expired
if cur_size == base_dbsize:
print(f" ✅ 全部清理完毕(共 {cur_expired - base} 个被定期删除)")
break
for i in range(n):
r.delete(f"demo:active:k{i}")
if __name__ == "__main__":
try:
demo_lazy_expiration()
demo_active_expiration()
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")python
"""
Ch5 配套代码 2 / 3 —— 采样 LRU vs 真 LRU 命中率对比
模拟一个有 1000 个 Key 的工作集,按 80/20 法则访问(80% 请求集中在 20% 热点 Key),
缓存容量 200,分别用:
- 真 LRU(OrderedDict)
- 采样 LRU(每次淘汰随机抽 N 个,挑最久未访问的)
比较两者命中率差异。复现 Redis 作者博客中的「采样 5 ≈ 真 LRU」结论。
"""
import random
import time
from collections import OrderedDict
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
class TrueLRU:
def __init__(self, capacity: int):
self.cap = capacity
self.data: "OrderedDict[str, int]" = OrderedDict()
self.hits = 0
self.misses = 0
def access(self, key: str) -> None:
if key in self.data:
self.data.move_to_end(key)
self.hits += 1
else:
self.misses += 1
self.data[key] = 1
if len(self.data) > self.cap:
self.data.popitem(last=False)
class SampledLRU:
"""模拟 Redis 的近似 LRU:access 时只更新时间戳,淘汰时随机采样。"""
def __init__(self, capacity: int, samples: int):
self.cap = capacity
self.samples = samples
self.data: dict[str, int] = {}
self.tick = 0
self.hits = 0
self.misses = 0
def access(self, key: str) -> None:
self.tick += 1
if key in self.data:
self.data[key] = self.tick
self.hits += 1
else:
self.misses += 1
if len(self.data) >= self.cap:
pool = random.sample(list(self.data.keys()), min(self.samples, len(self.data)))
victim = min(pool, key=lambda k: self.data[k])
del self.data[victim]
self.data[key] = self.tick
def make_workload(n_requests: int = 50_000, n_keys: int = 1000) -> list[str]:
"""80% 请求落在 20% 的 Key 上"""
hot = [f"hot:{i}" for i in range(n_keys // 5)]
cold = [f"cold:{i}" for i in range(n_keys - len(hot))]
seq = []
for _ in range(n_requests):
if random.random() < 0.8:
seq.append(random.choice(hot))
else:
seq.append(random.choice(cold))
return seq
def hit_rate(c) -> float:
total = c.hits + c.misses
return c.hits / total if total else 0.0
def run_simulation() -> None:
print("=" * 60)
print("采样 LRU vs 真 LRU 命中率对比(80/20 工作集)")
print("=" * 60)
workload = make_workload(n_requests=50_000, n_keys=1000)
capacity = 200
true_lru = TrueLRU(capacity)
for k in workload:
true_lru.access(k)
print(f"\n 缓存容量={capacity}, 工作集={1000}, 请求数={len(workload)}\n")
print(f" {'方案':<22} {'命中率':>10} {'相对真LRU':>14}")
print(" " + "-" * 50)
base = hit_rate(true_lru)
print(f" {'真 LRU (基准)':<22} {base*100:>9.2f}% {'100.00%':>14}")
for samples in [3, 5, 10, 20]:
s_lru = SampledLRU(capacity, samples=samples)
for k in workload:
s_lru.access(k)
rate = hit_rate(s_lru)
print(f" {'采样 LRU (N=' + str(samples) + ')':<22} {rate*100:>9.2f}% {rate/base*100:>13.2f}%")
print("\n 💡 结论:采样 5 已经能逼近真 LRU 95%+ 精度,10 几乎无法区分。")
print(" 这就是 Redis 默认 maxmemory-samples=5 的原因。")
def verify_redis_alive() -> None:
"""顺便确认本机 Redis 可达,避免误以为脚本完全是离线的。"""
pong = r.ping()
print(f"\n [check] Redis ping → {pong}(脚本本身是离线模拟,不写真 Redis)")
if __name__ == "__main__":
try:
random.seed(42)
verify_redis_alive()
t0 = time.time()
run_simulation()
print(f"\n 耗时 {time.time() - t0:.2f}s")
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")python
"""
Ch5 配套代码 3 / 3 —— 8 种淘汰策略实测
通过 CONFIG SET 把 maxmemory 调小,写入超量数据,观察不同策略下哪些 Key 被踢出。
对比 4 种典型策略:noeviction / allkeys-lru / allkeys-lfu / volatile-ttl。
"""
import time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
VALUE = "x" * 1024
PREFIX = "evict:"
N_KEYS = 200
KEEP_HOT = ("evict:hot:1", "evict:hot:2", "evict:hot:3")
ORIGINAL = {}
def section(t: str) -> None:
print("\n" + "=" * 60)
print(t)
print("=" * 60)
def save_original_config() -> None:
ORIGINAL["maxmemory"] = r.config_get("maxmemory")["maxmemory"]
ORIGINAL["maxmemory-policy"] = r.config_get("maxmemory-policy")["maxmemory-policy"]
def restore_config() -> None:
r.config_set("maxmemory", ORIGINAL.get("maxmemory", "0"))
r.config_set("maxmemory-policy", ORIGINAL.get("maxmemory-policy", "noeviction"))
def cleanup() -> None:
keys = r.keys(PREFIX + "*")
if keys:
r.delete(*keys)
def fill_and_observe(policy: str, with_ttl: bool = False) -> None:
cleanup()
r.config_set("maxmemory", 0)
r.config_set("maxmemory-policy", policy)
for i in range(20):
key = f"{PREFIX}cold:{i}"
if with_ttl:
r.set(key, VALUE, ex=60)
else:
r.set(key, VALUE)
for hot in KEEP_HOT:
r.set(hot, VALUE)
for _ in range(50):
r.get(hot)
used = int(r.info("memory")["used_memory"])
target = used + 100 * 1024
r.config_set("maxmemory", target)
print(f" 策略={policy:<18} 容量上限={target} bytes (~{target//1024}KB)")
evicted_before = int(r.info("stats")["evicted_keys"])
fail_count = 0
for i in range(N_KEYS):
try:
key = f"{PREFIX}new:{i}"
if with_ttl:
r.set(key, VALUE, ex=120)
else:
r.set(key, VALUE)
except redis.ResponseError as e:
fail_count += 1
if fail_count <= 1:
print(f" ⚠ 写入报错(OOM 写报错预期出现 in noeviction): {e}")
time.sleep(0.1)
evicted_after = int(r.info("stats")["evicted_keys"])
cold_remain = sum(1 for i in range(20) if r.exists(f"{PREFIX}cold:{i}"))
hot_remain = sum(1 for k in KEEP_HOT if r.exists(k))
new_remain = sum(1 for i in range(N_KEYS) if r.exists(f"{PREFIX}new:{i}"))
print(f" 淘汰数 = {evicted_after - evicted_before:>4} | "
f"冷数据剩 {cold_remain:>2}/20 | 热点剩 {hot_remain}/3 | 新写入剩 {new_remain}/{N_KEYS} | OOM 报错 {fail_count}")
def main() -> None:
section("8 种淘汰策略对比(重点看 4 种)")
save_original_config()
try:
print("\n [测试 1] noeviction:内存满时直接拒写")
fill_and_observe("noeviction", with_ttl=False)
print("\n [测试 2] allkeys-lru:踢最久未访问,热点应保留")
fill_and_observe("allkeys-lru", with_ttl=False)
print("\n [测试 3] allkeys-lfu:踢访问频次低的,热点应保留")
fill_and_observe("allkeys-lfu", with_ttl=False)
print("\n [测试 4] volatile-ttl:仅淘汰带 TTL 且 TTL 最小的")
fill_and_observe("volatile-ttl", with_ttl=True)
print("\n 💡 观察要点:")
print(" - noeviction 下「新写入剩 N」会很少(因为大量被拒)")
print(" - allkeys-lru / lfu 下「热点剩 3/3」(被反复 GET 过)")
print(" - volatile-ttl 下淘汰的是 TTL 较短的,hot 没设 TTL 不参与")
finally:
cleanup()
restore_config()
print("\n 已恢复 maxmemory 与 maxmemory-policy 原始值")
if __name__ == "__main__":
try:
main()
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")01_expire_strategies.py ↗ · 02_lru_simulator.py ↗ · 03_eviction_demo.py ↗