Redis-06 过期删除与内存淘汰策略
1. 过期时间是怎么存的
1.1 过期字典 expires
Redis 的每个数据库(redisDb)里有两个核心字典:
typedef struct redisDb {
dict *dict; // 键空间:所有的 key → value
dict *expires; // 过期字典:设置了 TTL 的 key → 过期时间戳(long long,毫秒)
dict *blocking_keys; // 被 BLPOP 等阻塞的 key
dict *ready_keys; // 已就绪可唤醒阻塞客户端的 key
dict *watched_keys; // 被 WATCH 监视的 key
int id;
long long avg_ttl; // 平均 TTL 估算值(供 INFO 展示)
...
} redisDb;
关键点:
- 过期时间单独存在
expires字典里,不是存在 robj 里。这样没有 TTL 的 key 不占额外空间; expires的 key 与dict的 key 是同一个 SDS 对象(指针共享,不是拷贝两份),所以只多了一个 dictEntry 的开销(约 32 字节);- value 存的是绝对的 Unix 毫秒时间戳(不是剩余秒数)。这意味着:
TTL命令是用「过期时间戳 - 当前时间」实时算出来的;- 服务器时钟被向前调整会导致大批 key 提前过期(线上真实事故来源,NTP 校时要小心);
- RDB/AOF 里保存的也是绝对时间戳,所以从备份恢复时已过期的 key 会直接被丢弃。
127.0.0.1:6379> SET k v EX 100
127.0.0.1:6379> TTL k
(integer) 100
127.0.0.1:6379> PEXPIRETIME k # 7.0+ 直接看绝对时间戳
(integer) 1785657600123
1.2 设置与查看
EXPIRE key 60 [NX|XX|GT|LT] # 秒(6.2+ 支持条件参数)
PEXPIRE key 60000 # 毫秒
EXPIREAT key <秒时间戳>
PEXPIREAT key <毫秒时间戳>
SET key val EX 60 # 写入时一并设置(推荐,一条命令原子完成)
GETEX key EX 60 # 6.2+ 读取的同时刷新过期时间
TTL key # 剩余秒;-1 = 无过期时间;-2 = key 不存在
PTTL key # 剩余毫秒
PERSIST key # 移除过期时间
EXPIRETIME key # 7.0+ 绝对过期时间戳(秒)
内部统一:不管你用 EXPIRE 还是 EXPIREAT,Redis 内部都会转换成 PEXPIREAT(绝对毫秒时间戳)再存入,并且传播给从节点和 AOF 的也是 PEXPIREAT。
这个转换非常重要:如果主节点把 EXPIRE key 60 原样传给从节点,因为命令到达从节点有网络延迟,从节点算出的过期时刻会比主节点晚一点,导致主从数据不一致。转成绝对时间戳就没有这个问题。
1.3 哪些操作会影响 TTL
第 2 篇提过,这里系统化总结:
| 操作 | TTL 是否保留 |
|---|---|
INCR/DECR/APPEND/SETRANGE |
保留 |
LPUSH/RPUSH/LPOP/LREM 等 list 操作 |
保留 |
HSET/HDEL/HINCRBY 等 hash 操作 |
保留 |
SADD/SREM/ZADD/ZINCRBY |
保留 |
SET |
清除(除非加 KEEPTTL) |
GETSET |
清除 |
SETEX/SET ... EX |
设置为新值 |
PERSIST |
移除 |
RENAME oldkey newkey |
newkey 继承 oldkey 的 TTL(原 newkey 的 TTL 被覆盖) |
DEL 后重新创建 |
无 TTL(新 key) |
COPY src dst |
不复制 TTL(除非用 COPY ... REPLACE,仍不带 TTL) |
RESTORE key ttl data |
按参数指定 |
对整个 key 的写入(如 SET、GETSET) |
清除 |
| 对 key 内部元素的写入 | 保留 |
规律:「替换整个 value」清除 TTL,「修改 value 内部」保留 TTL。
1.4 7.4+ 的 hash 字段级过期
7.4 之前,TTL 只能加在整个 key 上。7.4 引入了字段级过期(第 2 篇提过):
HSET session:1001 token abc device ios
HEXPIRE session:1001 3600 FIELDS 1 token # 只让 token 字段 1 小时后过期
HTTL session:1001 FIELDS 2 token device # 1) 3600 2) -1(device 无 TTL)
HPERSIST session:1001 FIELDS 1 token
实现上,Redis 为带字段过期的 hash 引入了新的编码 listpackex(listpack 加 TTL 信息)和在 hashtable 编码下的额外 TTL 元数据,并用一个全局的基数树(radix tree)索引按到期时间排序,让主动过期能高效地找到最近要过期的字段。
2. 三种过期删除策略
理论上有三种做法,Redis 采用了其中两种的组合。
2.1 定时删除(timer deletion)
做法:给每个带 TTL 的 key 创建一个定时器,到点立刻删除。
- 优点:内存最友好,key 一过期立即释放;
- 缺点:CPU 开销极大。有 100 万个带 TTL 的 key 就要维护 100 万个定时器;而且删除动作发生在主线程,如果同一时刻有大量 key 到期,会造成明显的延迟毛刺。更麻烦的是 Redis 的时间事件用无序链表实现(查找最近事件是 O(N)),根本承受不了海量定时器。
Redis 没有采用。
2.2 惰性删除(lazy expiration)
做法:不主动检查,只在访问某个 key 时才判断它是否过期,过期就删掉并返回 nil。
- 优点:CPU 最友好,完全不做无用的检查;
- 缺点:内存不友好。一个过期的 key 如果再也没人访问,就会永远占着内存(内存泄漏)。
Redis 采用了这一种。 实现在 expireIfNeeded():
// 简化逻辑,每次通过 lookupKeyRead/lookupKeyWrite 访问 key 时都会调用
int expireIfNeeded(redisDb *db, robj *key, int flags) {
if (!keyIsExpired(db, key)) return 0; // 没过期,正常返回
// 【关键】如果自己是从节点,不删除,只返回"逻辑上已过期"
if (server.masterhost != NULL) {
return 1; // 等主节点发 DEL 过来才真删
}
// 主节点:真正删除
deleteExpiredKeyAndPropagate(db, key);
// ├─ 根据 lazyfree-lazy-expire 决定 dbAsyncDelete 还是 dbSyncDelete
// ├─ 向所有从节点和 AOF 传播一个显式的 DEL / UNLINK 命令
// ├─ 发送 keyspace notification("expired" 事件)
// └─ server.stat_expiredkeys++
return 1;
}
2.3 定期删除(active expiration)
做法:周期性地随机抽查一部分 key,删掉其中过期的。
- 优点:CPU 和内存的折中——通过控制每次检查的时长和频率,把 CPU 开销限制在可接受范围,同时能逐步回收那些"没人访问的过期 key";
- 缺点:是概率性的,不保证过期 key 被立刻删除。参数不好调(太频繁伤 CPU,太稀疏内存回收慢)。
Redis 也采用了这一种。
2.4 Redis 的组合方案
Redis = 惰性删除 + 定期删除
两者互补:
- 惰性删除保证「你读到的数据一定不是过期的」(正确性);
- 定期删除保证「过期 key 最终会被回收」(内存不会无限泄漏);
- 如果两者都没及时清理导致内存打满,还有第三道防线:内存淘汰策略(第 4 节)。
3. 定期删除的实现细节
activeExpireCycle() 是过期删除的核心函数,非常值得细看——它是"如何在单线程里做后台工作而不影响前台"的经典范例。
3.1 两种执行模式
| 模式 | 触发点 | 时间上限 | 目的 |
|---|---|---|---|
| SLOW | serverCron(每 1000/hz ms,默认 100ms) |
CPU 时间占比 25%(默认 25ms) | 主力回收 |
| FAST | 每次事件循环的 beforeSleep |
1ms,且两次之间至少间隔 2ms | 快速补充,降低过期延迟 |
hz 10 # serverCron 每秒执行 10 次(每 100ms 一次)
dynamic-hz yes # 4.0+ 根据客户端数量动态调整 hz(客户端多时提高频率)
activeexpire yes # 是否开启主动过期(DEBUG SET-ACTIVE-EXPIRE 0 可关,仅调试用)
SLOW 模式的时间预算计算:
// ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC = 25
timelimit = 1000000 * ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC / server.hz / 100;
// hz=10 时:1000000 * 25 / 10 / 100 = 25000 微秒 = 25ms
即最多用掉 25% 的 CPU 时间做过期删除。这是一个硬性上限,保证过期删除永远不会把实例拖死。
3.2 算法流程
for 每个数据库 db in 16 个 db:
do:
1. 如果 db->expires 为空,跳到下一个 db
2. 从 db->expires 中随机抽取 ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP(20)个 key
(用 dictGetSomeKeys,从随机桶开始连续采样,不是严格均匀随机)
3. 逐个检查,过期的就删除,统计 expired 个数
4. 每检查 16 次就看一下已用时间是否超过 timelimit,超了立即退出
5. 如果本轮过期比例 expired/20 > 25%,说明过期 key 还很多,
【继续循环再抽一批】;否则跳到下一个 db
while (expired > 20/4)
三个关键设计:
(1)为什么是"过期比例 > 25% 就继续"
这是一个自适应机制:
- 如果随机抽 20 个只有 1~4 个过期(<=25%),说明过期 key 占比不高,剩下的交给惰性删除就行,没必要继续耗 CPU;
- 如果抽 20 个有 15 个过期(75%),说明积压了大量过期 key,值得多做几轮。
这样在"过期 key 很少"时几乎不消耗 CPU,在"过期 key 爆发"时能快速追上。
(2)为什么每 16 次检查一次时间
调用 getMonotonicUs() 获取时间本身有开销(虽然很小)。每 16 次检查一次是"及时性"和"开销"的折中——最坏情况下超时 16 个 key 的检查时间,可忽略。
(3)随机采样而非遍历
dictGetSomeKeys 从哈希表的随机位置开始,连续扫几个桶收集 key。这样是 O(1) 的(不依赖字典总大小),但采样不是严格均匀的——同一个桶里的 key 会被一起采到。这对过期删除来说无所谓(我们只要"大致均匀")。
3.3 7.0 的优化:过期字典的桶级采样
7.0 改进了采样算法(activeExpireCycleTryExpire 相关):不再是纯随机抽 key,而是逐个遍历哈希桶,把桶里所有 key 都检查一遍。因为一个桶通常只有 1~2 个 key,这样开销相近但覆盖更均匀,避免了老算法中"某些 key 长期没被采样到"的问题。
同时引入了「记录上次扫描到哪个桶」的游标,保证轮询公平。
3.4 定期删除的局限
即使有定期删除,仍然可能出现「大量过期 key 占用内存」的情况:
- 过期 key 集中在某个庞大的 expires 字典里,采样命中率低:假设有 1000 万个 key 带 TTL,其中只有 5 万过期(0.5%),随机抽 20 个大概率一个都碰不到,
expired/20 <= 25%直接退出。这 5 万个过期 key 就要等下次侥幸被采样到,或者被访问时惰性删除; - 时间预算被打满:如果一次有几百万 key 同时过期(比如大批缓存用了同一个 TTL),25ms 的预算根本删不完,会持续多个周期,期间内存居高不下;
- 从节点不主动删除(见第 5 节)。
实践建议:给批量写入的缓存 key 加上随机 TTL 抖动(如 3600 + rand(300)),既能避免缓存雪崩,也能让过期删除的压力均摊。
3.5 观察过期删除
127.0.0.1:6379> INFO stats
expired_keys:12345 # 累计过期删除的 key 数
expired_stale_perc:0.12 # 估算的"仍存在的过期 key"占比
expired_time_cap_reached_count:3 # 因时间预算耗尽而中断的次数(大于 0 说明过期压力大)
127.0.0.1:6379> INFO keyspace
db0:keys=1000000,expires=800000,avg_ttl=3600000
# ↑ 总 key 数 ↑ 带 TTL 的 key 数 ↑ 平均剩余 TTL(毫秒,估算值)
expired_time_cap_reached_count 持续增长是个警告信号:说明每个周期的 25ms 都用满了还没删完,需要检查是否有大批 key 同时过期。
4. 内存淘汰策略
过期删除处理的是"有 TTL 且已过期"的 key。但如果内存已经打满,而没有过期 key 可删(比如所有 key 都是永久的,或者 TTL 都还没到),怎么办?这就要靠内存淘汰(eviction)。
4.1 触发时机
淘汰检查发生在每条命令执行之前(processCommand → performEvictions):
1. 计算当前已用内存 mem_used
= used_memory - 从节点输出缓冲区 - AOF 缓冲区
(这两部分不算,因为它们不是"数据",而且可能瞬间很大)
2. 如果 mem_used <= maxmemory,直接放行
3. 超了 → 按 maxmemory-policy 淘汰 key,直到内存降到 maxmemory 以下
├─ noeviction:不淘汰,直接给写命令返回 OOM 错误
└─ 其他策略:循环选 key 删除,每删一个就检查是否已降下来
4. 如果淘汰后仍然降不下来(比如只有一个巨大的 key),写命令返回 OOM 错误
关键点:淘汰是同步在主线程做的,发生在命令执行前。所以如果内存长期贴着 maxmemory,每条写命令都要先做淘汰计算,会明显拉高延迟。开启 lazyfree-lazy-eviction yes 能让内存释放异步化,缓解这个问题。
4.2 八种策略
maxmemory 4gb
maxmemory-policy allkeys-lru
| 策略 | 候选范围 | 淘汰依据 | 说明 |
|---|---|---|---|
| noeviction | - | 不淘汰 | 默认值。写命令返回 OOM command not allowed...,读命令仍可用 |
| allkeys-lru | 所有 key | 最近最少使用 | 最常用的纯缓存策略 |
| allkeys-lfu | 所有 key | 使用频率最低 | 4.0+,热点数据明显时优于 LRU |
| allkeys-random | 所有 key | 随机 | 访问模式完全均匀时可用,实际很少 |
| volatile-lru | 仅带 TTL 的 key | 最近最少使用 | 缓存与持久数据混存时用 |
| volatile-lfu | 仅带 TTL 的 key | 使用频率最低 | 4.0+ |
| volatile-random | 仅带 TTL 的 key | 随机 | |
| volatile-ttl | 仅带 TTL 的 key | 剩余 TTL 最短的优先 | 相当于"提前过期" |
volatile- 系列的重大陷阱*:如果没有任何 key 设置了 TTL,volatile 策略就找不到可淘汰的 key,行为退化成 noeviction——写命令直接报 OOM。这是线上事故的常见原因:运维设了 volatile-lru 觉得很安全,但业务代码写缓存时忘了加 EX,结果内存满了直接不可写。
4.3 选型建议
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 纯缓存(数据丢了能从 DB 重建) | allkeys-lru 或 allkeys-lfu |
保证服务永远可写,自动淘汰冷数据 |
| 有明显热点(少数 key 访问极频繁) | allkeys-lfu |
LRU 会被"偶尔的全表扫描"污染,LFU 不会 |
| 缓存 + 持久数据混存 | volatile-lru |
只淘汰缓存(带 TTL 的),保护持久数据 |
| 纯数据库用途(数据不可丢) | noeviction |
宁可写失败也不能丢数据,配合监控告警 |
| 有明确的时效性排序 | volatile-ttl |
比如都是有时效的验证码/会话 |
| 访问完全均匀(罕见) | allkeys-random |
开销最小 |
最重要的实践:如果用 volatile-* 系列,必须确保所有缓存 key 都设了 TTL,并且监控 INFO keyspace 的 expires 数量。混存场景更稳妥的做法是物理隔离——缓存和持久数据用不同的实例。
4.4 OOM 的表现
127.0.0.1:6379> CONFIG SET maxmemory 1mb
127.0.0.1:6379> CONFIG SET maxmemory-policy noeviction
127.0.0.1:6379> SET k <一个很大的值>
(error) OOM command not allowed when used memory > 'maxmemory'.
注意:
- 只有"可能增加内存"的命令被拒绝(
SET、LPUSH、ZADD、INCR等,标记了use-memory的命令); - 读命令仍然正常(
GET、LRANGE); DEL、UNLINK、EXPIRE也允许(它们减少内存);- Lua 脚本:如果脚本被声明为可能写入,整个脚本会被拒绝。
4.5 监控淘汰
127.0.0.1:6379> INFO stats
evicted_keys:98765 # 累计被淘汰的 key 数 ← 最关键指标
evicted_clients:0 # 7.0+ 因内存压力被断开的客户端数
127.0.0.1:6379> INFO memory
used_memory_human:3.85G
maxmemory_human:4.00G
maxmemory_policy:allkeys-lru
mem_fragmentation_ratio:1.12
evicted_keys 持续快速增长说明内存严重不足,正在大量淘汰数据。后果是缓存命中率下降(keyspace_hits/(hits+misses)),进而 DB 压力上升。这时候要么扩容内存,要么排查是否有大 key / 内存泄漏 / 该过期的没过期。
5. 近似 LRU 的实现
5.1 标准 LRU 为什么不能用
教科书里的 LRU 用 哈希表 + 双向链表实现:
访问一个元素 → 把它移到链表头部
淘汰 → 删除链表尾部的元素
Redis 不用它,两个原因:
(1)内存开销大
每个 key 都要额外存 prev 和 next 两个指针 = 16 字节。对于一个有 1 亿 key 的实例,就是 1.6GB 纯粹的元数据开销。对内存数据库来说完全不可接受。
(2)维护链表本身有成本
每次读操作都要修改链表(移动节点),这让读操作产生了写内存的副作用——破坏了 CPU 缓存局部性,还会在 fork 期间触发额外的 COW(读操作导致内存页被复制,这非常糟糕)。
5.2 Redis 的做法:随机采样 + 淘汰池
Redis 的近似 LRU(approximated LRU):
(1)每个 robj 用 24 bit 记录访问时间
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // ← LRU 模式下:最后一次访问的时间戳(秒级精度)
int refcount;
void *ptr;
} robj;
- 只占 24 bit,而且是 robj 里原本就有的空隙(结构体对齐产生的空间),零额外内存开销;
- 精度是秒,24 bit 能表示
2^24 = 16777216秒 ≈ 194 天,超过就回绕(Redis 有专门的处理逻辑处理回绕情况); - 每次访问 key 时(
lookupKey)更新这个字段。
为什么不直接调 time():那是个系统调用,每次访问 key 都调太慢。Redis 维护了一个全局 LRU 时钟 server.lruclock,由 serverCron 每 100ms 更新一次,读取时直接用这个缓存值(LRU_CLOCK() 宏)。
(2)淘汰时随机采样
// maxmemory-samples 默认 5
for (i = 0; i < maxmemory_samples; i++) {
key = 从候选字典里随机取一个 key;
idle = 估算它的空闲时间 (当前 lruclock - key 的 lru 字段);
把 (key, idle) 放入淘汰池;
}
淘汰池里 idle 最大的那个 key 被删除;
(3)3.0 引入的淘汰池(eviction pool)
2.8 及之前的算法很朴素:每次随机采 5 个,淘汰其中最旧的。这样的问题是每次采样的信息都被丢弃了——采到一个"次旧"的 key 也不记住,下次重新采。
3.0 引入了一个大小为 16 的淘汰池(EVPOOL_SIZE),是个按 idle 时间排序的数组:
1. 每次淘汰时,随机采样 maxmemory-samples 个 key
2. 把它们插入淘汰池(保持按 idle 升序排列)
3. 如果池满了,只有 idle 比池中最小值更大的 key 才能挤进来
4. 从池尾(idle 最大)取一个 key 淘汰,并从池中移除
5. 池在多次淘汰之间【保留】,所以历次采样的"好候选"会积累下来
这个改进让近似 LRU 的效果显著接近真实 LRU。Redis 官方文档里有一张著名的对比图:
浅灰点 = 被淘汰的 key 深灰点 = 未被淘汰 黑点 = 最近访问过的 key
理论 LRU Redis 3.0 (samples=10) Redis 3.0 (samples=5) Redis 2.8 (samples=5)
完美分层 非常接近 比较接近 明显误差
结论:3.0 的 samples=10 已经和真实 LRU 几乎没有区别,samples=5 也足够好用。
(4)maxmemory-samples 调优
maxmemory-samples 5 # 默认
| 值 | 精度 | CPU |
|---|---|---|
| 3 | 明显误差 | 最省 |
| 5 | 够用(默认) | 低 |
| 10 | 几乎等于真实 LRU | 约高 1.5 倍 |
| 20+ | 提升微乎其微 | 明显更高 |
官方建议:默认 5 适合绝大多数场景;对淘汰精度特别敏感(比如缓存命中率至关重要)时可以调到 10。
5.3 查看空闲时间
127.0.0.1:6379> OBJECT IDLETIME mykey
(integer) 320 # 空闲 320 秒
注意:OBJECT IDLETIME 本身不会更新 lru 字段(否则就没法查了)。但 GET、TYPE 等命令都会更新。
6. LFU 的实现
6.1 LRU 的缺陷
LRU 只看"最后一次访问时间",会被偶发的批量访问污染。
经典场景:
一个缓存里有 10 个热点 key,每秒被访问几千次。
凌晨 3 点跑了一个数据分析任务,全表扫描读了 100 万个冷 key。
LRU 的结果:这 100 万个冷 key 的"最后访问时间"都是刚刚,
而 10 个热点 key 因为那一瞬间没被访问,反而显得"更旧",
→ 热点 key 被淘汰,冷 key 占满内存
→ 之后业务请求全部缓存未命中,DB 被打爆
这叫 缓存污染(cache pollution) 或 sequential flooding。
6.2 LFU 的思路
LFU(Least Frequently Used):淘汰访问频率最低的 key。上面场景里,冷 key 各自只被访问了 1 次,频率极低,会被优先淘汰,热点 key 安然无恙。
Redis 4.0 引入 allkeys-lfu / volatile-lfu。
6.3 实现:8 bit 计数器 + 16 bit 时间
LFU 模式下,robj 的 24 bit lru 字段被拆成两部分:
+------------------------------------+----------------+
| 16 bit: ldt (Last Decrement Time) | 8 bit: counter |
+------------------------------------+----------------+
上次递减的时间(分钟级精度) 对数访问计数器
ldt(16 bit):上次衰减的时间,单位是分钟,2^16 = 65536分钟 ≈ 45.5 天后回绕;counter(8 bit):访问频率计数器,范围 0~255。
关键设计一:对数递增(logarithmic counter)
8 bit 最大只能到 255,如果每次访问 +1,一个热点 key 几秒钟就打满 255,之后所有热点 key 都是 255 就没有区分度了。
Redis 用概率性的对数递增(LFULogIncr):
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255; // 已满
double r = (double)rand() / RAND_MAX; // [0,1) 随机数
double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL = 5
if (baseval < 0) baseval = 0;
// counter 越大,p 越小 → 越难继续增长
double p = 1.0 / (baseval * server.lfu_log_factor + 1);
if (r < p) counter++;
return counter;
}
即:counter 越大,再 +1 的概率越小。lfu-log-factor 控制曲线的陡峭程度。
官方给出的对照表(不同 lfu-log-factor 下,counter 达到某值所需的访问次数):
| factor | counter=100 | counter=200 | counter=255 |
|---|---|---|---|
| 0 | 104 | 255 | 255 |
| 1 | 18 K | 49 K | 255 K |
| 10(默认) | 10 K | 271 K | 1 M |
| 100 | 134 K | 2.7 M | 10 M |
用默认 factor=10:访问 1 万次 counter 到 100,访问 100 万次才到 255。这样在 0~255 的空间里能区分出跨 6 个数量级的访问频率。
关键设计二:时间衰减(decay)
只有递增的话,一个"曾经很热、现在已冷"的 key 会永远保持高 counter,永远不被淘汰。所以必须让 counter 随时间衰减。
lfu-decay-time 1 # 每经过 N 分钟,counter 减 1(默认 1)
衰减是惰性的(LFUDecrAndReturn):不用定时任务遍历,而是在读取 counter 时根据 ldt 计算应该衰减多少:
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8;
unsigned long counter = o->lru & 255;
// 距上次衰减过了多少个 lfu_decay_time 周期
unsigned long num_periods = server.lfu_decay_time ?
LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
lfu-decay-time 0 表示永不衰减(只用纯频率),一般不建议。
新 key 的初始值
新创建的 key,counter 初始化为 LFU_INIT_VAL = 5(不是 0)。
为什么?如果初始为 0,一个刚写入的新 key 立刻就成了"最该淘汰的",可能在被第二次访问之前就被删掉了,导致新数据永远进不了缓存。给个 5 的"新手保护",让它有机会积累访问。
同时 5 也不能太大,否则大量新 key 会挤掉真正的热 key。
6.4 查看访问频率
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu
127.0.0.1:6379> SET k v
127.0.0.1:6379> OBJECT FREQ k
(integer) 5 # 初始值 LFU_INIT_VAL
127.0.0.1:6379> GET k
127.0.0.1:6379> GET k
127.0.0.1:6379> OBJECT FREQ k
(integer) 6 # 概率性递增,可能还是 5
注意 OBJECT FREQ 只在 LFU 策略下可用,LRU 策略下会报错 An LFU maxmemory policy is not selected。反之 OBJECT IDLETIME 只在非 LFU 策略下可用。
redis-cli --hotkeys 就是靠 OBJECT FREQ 实现的,所以它要求先把策略设为 LFU。
6.5 LFU 配置总结
maxmemory-policy allkeys-lfu
lfu-log-factor 10 # 对数递增因子,越大则 counter 增长越慢、能区分的频率范围越广
lfu-decay-time 1 # 每 N 分钟衰减 1,0 表示不衰减
调优思路:
- 访问频率差异巨大(有的 key 每秒几万次,有的每天几次)→ 调大
lfu-log-factor(如 100),扩大可区分范围; - 访问频率都不高(最热的 key 也就每分钟几十次)→ 调小
lfu-log-factor(如 1~5),提高区分度; - 热点变化快(热点每几分钟就换)→ 调小
lfu-decay-time(如 1),让旧热点快速降温; - 热点稳定(长期就是那些 key 热)→ 调大
lfu-decay-time(如 10~60)。
6.6 LRU vs LFU 对比
| 维度 | LRU | LFU |
|---|---|---|
| 依据 | 最后访问时间 | 访问频率 |
| 抗批量扫描污染 | 差(会被冲垮) | 好 |
| 对"突发新热点"的响应 | 快(访问一次就变最新) | 慢(需要积累 counter) |
| 元数据 | 24 bit 时间戳 | 16 bit 时间 + 8 bit 计数 |
| 额外配置 | maxmemory-samples |
lfu-log-factor、lfu-decay-time |
| 适用 | 访问模式随时间平移(时效性数据) | 存在稳定热点(大多数缓存场景) |
一般结论:大多数缓存场景 LFU 优于 LRU,特别是有定时批处理任务的系统。但如果业务的访问模式是"最近的数据最热"(如新闻、朋友圈时间线),LRU 反而更贴合。
注意:淘汰算法本身也用随机采样 + 淘汰池(和 LRU 一样),只是排序依据从 idle 时间换成了 counter。所以 LFU 也是"近似"的。
7. 主从环境下的过期语义
这是面试高频陷阱题,也是实际踩坑的高发区。
7.1 从节点不主动删除过期 key
规则:只有主节点会真正删除过期 key。从节点等主节点发来的 DEL/UNLINK 命令。
主节点:
key 过期(惰性或定期发现)
→ 删除自己的 key
→ 【显式生成一条 DEL 命令】传播给所有从节点和 AOF
从节点:
收到 DEL → 删除自己的 key
为什么这么设计?
因为如果从节点自己删除过期 key,就会出现主从数据不一致:
- 从节点的时钟可能与主节点有偏差,删除时机不同;
- 更严重的是违反了"从节点只被动接受主节点数据变更"的复制模型——一旦从节点能自己改数据,主从之间的数据就可能永久分叉(比如从节点删了,但主节点的定期删除还没扫到,此时主节点上这个 key 又被
PERSIST了,从节点却已经没有它了)。
统一由主节点决定并显式传播 DEL,让复制流成为唯一的数据变更来源,保证了主从的最终一致性。
7.2 从节点读到过期 key 会返回什么
关键点:从节点虽然不删除,但读取时会判断逻辑上是否过期,过期就返回 nil。
在 expireIfNeeded 里:
if (server.masterhost != NULL) {
return 1; // 返回"已过期"但不删除,调用方据此返回 nil
}
所以从客户端视角看,从节点上读一个已过期的 key 会正确地返回 nil,不会读到脏数据。
只是这个 key 仍然占着从节点的内存,直到主节点的 DEL 传过来。
7.3 3.2 之前的 BUG
Redis 3.2 之前这里有个真实的 bug:从节点在读取时不做过期判断,直接返回值。所以在主节点还没来得及发 DEL 的窗口期内,从节点会返回已过期的数据。
如果你的业务用了读写分离(写主读从),在老版本上就可能读到过期的缓存。3.2 修复了这一点。这是"为什么要升级 Redis 版本"的一个具体理由。
7.4 从节点如何维护自己的 expires
从节点也维护 expires 字典(因为它要能判断逻辑过期),数据来自:
- 全量同步时从 RDB 加载(RDB 里存了绝对时间戳,加载时已过期的 key 会被跳过——注意从节点加载时不跳过,主节点加载时跳过,因为从节点必须和主节点保持完全一致);
- 复制流里的
PEXPIREAT命令。
主节点传播的永远是 PEXPIREAT(绝对时间戳)而不是 EXPIRE(相对秒数),避免了网络延迟造成的过期时刻偏差。
7.5 相关的两个坑
坑一:从节点的内存可能高于主节点
因为从节点上堆积着"逻辑已过期但还没收到 DEL"的 key。如果主节点的定期删除压力大(大量 key 同时过期,25ms 预算删不完),从节点的这部分内存会明显偏高。
坑二:主从切换后大量 key 集中过期
从节点被提升为主节点后,它开始承担主动过期的职责。如果之前积压了大量过期 key,切换瞬间会有一波集中删除,造成延迟毛刺和内存陡降。
7.6 Lua 脚本里的过期
为了保证脚本在主从上的执行结果一致,Redis 有个特殊规则:
在 Lua 脚本执行期间,时间是"冻结"的——脚本开始执行时的时间被记录下来,脚本内所有的过期判断都用这个时间。这样即使脚本执行了几百毫秒,也不会出现"脚本前半段 key 还在、后半段就过期了"的情况,保证了脚本在主从节点上的确定性。
同理,脚本里 TIME 命令在 5.0 之前是被禁止的(因为不确定),5.0 之后允许了但脚本会以"effects replication"(传播实际的写效果而不是脚本本身)的方式复制。
8. 生产实践与排查
8.1 完整的内存治理配置
# 1. 必须设置 maxmemory(物理内存的 60%~70%)
maxmemory 4gb
# 2. 缓存场景用 allkeys-lfu(有明显热点)或 allkeys-lru
maxmemory-policy allkeys-lfu
maxmemory-samples 5
lfu-log-factor 10
lfu-decay-time 1
# 3. 全部开启惰性释放,避免删除/淘汰大 key 阻塞主线程
lazyfree-lazy-eviction yes # 淘汰时异步释放
lazyfree-lazy-expire yes # 过期删除时异步释放
lazyfree-lazy-server-del yes # 隐式删除(rename 覆盖等)异步
lazyfree-lazy-user-del yes # 让 DEL 表现得像 UNLINK
lazyfree-lazy-user-flush yes # 7.0+ 让 FLUSHALL/FLUSHDB 默认异步
replica-lazy-flush yes # 从节点全量同步前清空数据时异步
# 4. 过期删除的频率
hz 10
dynamic-hz yes
8.2 排查"内存不降"
# 1. 确认是否有大量过期 key 未回收
INFO keyspace # 比较 keys 和 expires
INFO stats # expired_keys 是否在增长、expired_time_cap_reached_count
# 2. 确认是否有大 key
redis-cli --bigkeys
redis-cli --memkeys
MEMORY USAGE <key>
# 3. 确认碎片率
INFO memory
# mem_fragmentation_ratio > 1.5 → 碎片严重,考虑开启自动碎片整理
# mem_fragmentation_ratio < 1 → 用了 swap,非常危险!
# 4. 确认内存都花在哪
MEMORY DOCTOR # Redis 自己的诊断建议
MEMORY STATS # 详细分解:数据、复制缓冲、AOF 缓冲、客户端缓冲、lua 等
# 5. 确认是否有 key 忘了设 TTL(常见泄漏源)
redis-cli --scan --pattern 'cache:*' | head -100 | while read k; do
echo "$k $(redis-cli TTL "$k")"
done | grep -- '-1'
MEMORY STATS 的关键字段:
peak.allocated 历史峰值
total.allocated 当前总分配
startup.allocated 启动时的基础开销
replication.backlog 复制积压缓冲区
clients.slaves 从节点客户端缓冲区总和
clients.normal 普通客户端缓冲区总和
aof.buffer AOF 缓冲区
dbXXX.overhead.hashtable.main 主字典的元数据开销
dbXXX.overhead.hashtable.expires 过期字典的元数据开销 ← 带 TTL 的 key 多时这里很大
overhead.total 所有非数据开销总和
keys.count key 总数
keys.bytes-per-key 平均每个 key 占多少字节
dataset.bytes 纯数据占用
dataset.percentage 数据占总内存的比例
fragmentation 碎片率
8.3 内存碎片整理
# 4.0+ 自动碎片整理(需要 jemalloc)
activedefrag yes
# 碎片达到多少字节才开始整理
active-defrag-ignore-bytes 100mb
# 碎片率达到多少百分比才开始
active-defrag-threshold-lower 10
# 碎片率达到多少时用最大力度
active-defrag-threshold-upper 100
# CPU 占用下限/上限(整理是在主线程做的,要限制)
active-defrag-cycle-min 5
active-defrag-cycle-max 75
# 手动触发一次(4.0+)
MEMORY PURGE # 让 jemalloc 把空闲页归还给操作系统
CONFIG SET activedefrag yes # 动态开启
注意:碎片整理是在主线程里做的(它要移动对象并更新所有指向它的指针,必须原子),所以会消耗 CPU 并可能造成延迟。active-defrag-cycle-max 75 表示最多用 75% 的 CPU 时间——这已经很激进了,只在碎片率极高时才会到这个力度。
8.4 避免大批 key 同时过期
// 差:所有缓存都是精确 3600 秒
rdb.Set(ctx, key, val, time.Hour)
// 好:加随机抖动,把过期时间打散
ttl := time.Hour + time.Duration(rand.Intn(300))*time.Second
rdb.Set(ctx, key, val, ttl)
好处有两个:
- 避免缓存雪崩(第 14 篇)——大量 key 同时失效导致请求全部打到 DB;
- 均摊过期删除压力——避免某个 100ms 周期内有几百万 key 要删,把 25ms 的预算撑爆。
9. 高频面试题
Q1:Redis 的过期删除策略有哪些?Redis 用了哪些?
理论上三种:
- 定时删除:每个 key 一个定时器,到点立即删。内存最优但CPU 开销巨大(百万 key 就是百万定时器),且大量 key 同时到期会造成延迟毛刺。Redis 没采用;
- 惰性删除:只在访问 key 时才检查并删除。CPU 最优但内存不友好——没人访问的过期 key 永远占内存。Redis 采用;
- 定期删除:周期性随机抽查一批 key 删掉过期的。CPU 与内存的折中。Redis 采用。
Redis = 惰性删除 + 定期删除。惰性删除保证「读到的数据一定不过期」(正确性),定期删除保证「过期 key 最终被回收」(内存不泄漏)。如果两者都没跟上导致内存打满,还有第三道防线:内存淘汰策略。
Q2:定期删除的具体算法是什么?
activeExpireCycle(),有两种模式:
- SLOW 模式:由
serverCron触发(hz默认 10,即每 100ms 一次),时间上限是 CPU 时间的 25%(hz=10 时约 25ms); - FAST 模式:在每次事件循环的
beforeSleep触发,上限 1ms,两次之间至少间隔 2ms。
算法(对每个 db 循环):
- 从
expires字典随机采样 20 个 key; - 检查并删除其中已过期的;
- 如果过期比例 > 25%(即 20 个里超过 5 个过期),就继续再采一批;否则跳到下一个 db;
- 每检查 16 个 key 就看一次是否超时,超了立即退出。
这个 25% 的阈值是自适应机制:过期 key 少时几乎不耗 CPU,过期 key 爆发时能连续多轮快速追赶。
局限:如果带 TTL 的 key 有 1000 万而只有 5 万过期(0.5%),随机采 20 个大概率一个都碰不到,就直接退出了——这些 key 要等被访问(惰性删除)或者下次侥幸被采样。所以给批量缓存加随机 TTL 抖动很重要。
Q3:过期时间存在哪里?是相对时间还是绝对时间?
存在每个 redisDb 的 expires 字典里(独立于存数据的 dict),key 是同一个 SDS 对象的指针(不复制),value 是 绝对的 Unix 毫秒时间戳。
用绝对时间戳的重要影响:
TTL是实时计算出来的(过期时间戳 - 当前时间);- 主节点传播给从节点和 AOF 的永远是
PEXPIREAT(绝对时间戳),而不是原始的EXPIRE(相对秒数)——否则网络延迟会导致从节点的过期时刻晚于主节点,造成不一致; - 修改系统时钟会导致大批 key 意外过期或不过期(NTP 大幅校时是真实的线上事故来源);
- RDB/AOF 里存的也是绝对时间戳,主节点从 RDB 恢复时会跳过已过期的 key。
Q4:主从复制下,从节点会自己删除过期 key 吗?读从节点会读到过期数据吗?
从节点不会自己删除过期 key。规则是:只有主节点判定 key 过期并删除,然后显式生成一条 DEL/UNLINK 命令传播给所有从节点,从节点执行这条命令才真正删除。
这样设计是为了保证复制流是唯一的数据变更来源,避免主从因时钟偏差或删除时机不同而数据分叉。
但从节点读取时会做逻辑过期判断:expireIfNeeded 发现自己是从节点时,返回"已过期"但不执行删除,调用方据此返回 nil。所以从客户端视角看,从节点不会返回过期数据(正确性有保证),只是这个 key 还占着内存。
重要补充:Redis 3.2 之前存在 bug——从节点读取时不做过期判断,会直接返回已过期的值。所以在 3.2 之前用读写分离确实可能读到脏数据。这是升级版本的一个具体理由。
Q5:Redis 有哪 8 种内存淘汰策略?怎么选?
按候选范围分两组:
allkeys-*(所有 key 都可淘汰):
allkeys-lru:最近最少使用;allkeys-lfu:使用频率最低(4.0+);allkeys-random:随机。
volatile-*(只淘汰带 TTL 的 key):
volatile-lru、volatile-lfu、volatile-random;volatile-ttl:剩余 TTL 最短的优先。
noeviction(默认):不淘汰,写命令返回 OOM 错误,读命令仍可用。
选型:
- 纯缓存(丢了能重建)→
allkeys-lru/allkeys-lfu,保证永远可写; - 有明显热点(有批处理任务扫全表)→
allkeys-lfu,LRU 会被批量扫描污染; - 缓存 + 持久数据混存 →
volatile-lru,只淘汰缓存; - 纯数据库用途(不可丢数据)→
noeviction+ 严格监控告警。
volatile-* 的重大陷阱:如果没有任何 key 设了 TTL,volatile 策略找不到候选,行为退化成 noeviction——内存满了直接不可写。这是常见事故:运维配了 volatile-lru 觉得很安全,但业务代码忘了加 EX。
Q6:Redis 的 LRU 是标准 LRU 吗?为什么不用哈希表+双向链表?
不是,Redis 用的是「近似 LRU」(approximated LRU)。
不用标准 LRU 的两个原因:
- 内存开销:双向链表要给每个 key 加
prev/next两个指针 = 16 字节/key。1 亿 key 就是 1.6GB 纯元数据,对内存数据库不可接受; - 读操作产生写副作用:每次读都要移动链表节点,破坏 CPU 缓存局部性,而且在 fork 期间会因为读操作触发 COW(读请求导致内存页被复制),这对 RDB/AOF 重写期间的内存占用是灾难。
Redis 的近似 LRU:
- 在
robj的 24 bitlru字段(利用结构体对齐的空隙,零额外内存)里记录最后访问时间(秒级,来自serverCron每 100ms 更新的全局server.lruclock,避免频繁系统调用); - 淘汰时随机采样
maxmemory-samples(默认 5)个 key,选 idle 最大的淘汰; - 3.0 引入了大小为 16 的「淘汰池」:采样结果插入按 idle 排序的池中,池在多次淘汰之间保留,历次采样的好候选会累积。这个改进让效果显著接近真实 LRU(官方对比图显示 samples=10 时几乎无差别)。
Q7:LFU 是怎么实现的?8 bit 计数器怎么表示海量访问?
LFU 模式下,robj 的 24 bit lru 字段被拆成:
高 16 bit: ldt(上次衰减时间,分钟级)| 低 8 bit: counter(0~255)
两个核心设计:
(1)对数递增(概率性):如果每次访问 +1,8 bit 几秒就打满 255,热点之间失去区分度。所以 Redis 用 LFULogIncr:
p = 1 / ((counter - 5) * lfu_log_factor + 1)
以概率 p 才 +1
counter 越大,增长越难。默认 lfu-log-factor 10 时:访问 1 万次 counter 到 100,访问 100 万次才到 255。这样 8 bit 就能区分跨 6 个数量级的访问频率。
(2)时间衰减(惰性):只增不减的话,“曾经很热现在已冷"的 key 永远不被淘汰。lfu-decay-time(默认 1)表示每 N 分钟 counter 减 1。衰减是惰性计算的——读取 counter 时根据 ldt 算出该衰减多少(LFUDecrAndReturn),不需要定时任务遍历。
(3)新 key 初始值 LFU_INIT_VAL = 5(不是 0):给新 key 一个"新手保护”,否则刚写入的 key 立刻成为最该淘汰的目标,新数据永远进不了缓存。
查看:OBJECT FREQ key(只在 LFU 策略下可用)。redis-cli --hotkeys 就是基于它实现的。
Q8:LFU 和 LRU 的区别?什么时候必须用 LFU?
核心区别:LRU 看「最后访问时间」,LFU 看「访问频率」。
必须用 LFU 的场景——防止缓存污染(sequential flooding):
缓存里有 10 个热点 key,每秒被访问几千次。
凌晨跑了个数据分析任务,全表扫描读了 100 万个冷 key。
LRU:这 100 万个冷 key 的"最后访问时间"都是刚刚,10 个热点 key 反而显得更旧
→ 热点 key 被淘汰、冷 key 占满内存 → 业务请求全部未命中 → DB 被打爆
LFU:冷 key 各自只被访问 1 次,counter 极低,优先被淘汰 → 热点安然无恙
反过来 LRU 更好的场景:访问模式随时间平移的数据(新闻资讯、朋友圈时间线、直播弹幕)——最近的数据就是最热的,“新热点"用 LRU 能立刻被保护,而 LFU 要慢慢积累 counter 才能被认可(LFU 对突发新热点响应慢)。
一般结论:大多数缓存场景 LFU 更优,尤其是有定时批处理任务的系统。
Q9:maxmemory-samples 调大有什么影响?
它控制淘汰时的随机采样数量(默认 5)。
- 调大(如 10):淘汰决策更接近真实 LRU/LFU,缓存命中率更高,但每次淘汰要多做几次随机采样和 idle 计算,CPU 开销上升约 50%;
- 调小(如 3):CPU 更省,但淘汰精度明显下降,可能误删热数据;
- 超过 10 收益微乎其微(官方对比图显示 10 已经几乎等于真实 LRU)。
官方建议:默认 5 适合绝大多数场景;只在缓存命中率极其关键、且 CPU 有余量时调到 10。
Q10:内存淘汰是在什么时候发生的?会阻塞主线程吗?
发生在每条命令执行之前(processCommand → performEvictions):
- 计算
mem_used(要减去从节点输出缓冲区和 AOF 缓冲区,因为它们不是数据且可能瞬间很大); - 超过
maxmemory就循环淘汰 key,直到降下来; - 如果淘汰完还降不下来(比如就剩一个巨大的 key),写命令返回 OOM 错误。
会阻塞主线程,因为淘汰是同步做的。风险点:
- 内存长期贴着
maxmemory时,每条写命令都要先做淘汰计算,延迟明显升高; - 如果淘汰到一个大 key(千万元素的集合),同步释放内存会阻塞数百毫秒到数秒。
缓解:开启 lazyfree-lazy-eviction yes,让内存释放交给 bio 后台线程;同时从根本上控制大 key 和留足内存余量(maxmemory 设为物理内存 60%~70%)。
监控 INFO stats 的 evicted_keys——持续快速增长说明内存严重不足。
Q11:为什么 mem_fragmentation_ratio 小于 1 很危险?
mem_fragmentation_ratio = used_memory_rss / used_memory。
> 1(正常 1.0~1.5):操作系统看到的物理内存(RSS)大于 Redis 逻辑用量,差值就是内存碎片;> 1.5:碎片严重,考虑开activedefrag yes或重启释放;< 1:RSS 小于逻辑内存,说明 Redis 的一部分内存被换出到了 swap。
< 1 危险的原因:内存访问变成了磁盘访问。Redis 的所有操作都假设内存访问是纳秒级的,一旦某个数据页在 swap 里,一次 GET 就要等磁盘 IO(毫秒级),延迟恶化 1000 倍以上,而且因为是单线程,这一次慢会让所有排队的请求都跟着慢。实例基本等于不可用。
处理:立刻确认 swap 用量(free -m、cat /proc/<pid>/smaps | grep Swap),减少数据量或扩容内存;生产环境建议关闭 swap,或至少 vm.swappiness = 1,并且一定要设 maxmemory 防止内存无限增长。
Q12:一个 key 已经过期了,但 DBSIZE 还算它吗?SCAN 会返回它吗?
DBSIZE:直接返回 dict 的元素个数,包括还没被删除的过期 key。所以 DBSIZE 可能大于"实际有效 key 数”。
SCAN:会过滤掉已过期的 key(内部对每个候选 key 调用 expireIfNeeded)。所以 SCAN 遍历的总数可能小于 DBSIZE。
RANDOMKEY:会跳过过期 key(内部最多重试 100 次找一个未过期的,都失败就返回 nil)。
KEYS pattern:也会过滤过期 key。
这个差异在排查"key 数量对不上"时很有用:DBSIZE 明显大于 SCAN 出来的数量,说明积压了很多过期未删的 key。
Q13:为什么大批 key 同时过期会导致问题?怎么避免?
问题:
- 过期删除的时间预算被打满:SLOW 模式每 100ms 只有 25ms,如果几百万 key 同时到期,会连续多个周期都把预算跑满(
expired_time_cap_reached_count增长),期间 CPU 占用高、其他命令延迟上升; - 删除本身的阻塞:如果过期的是大 key,同步释放内存会造成明显毛刺(要开
lazyfree-lazy-expire); - 缓存雪崩:大量缓存同时失效,请求全部穿透到数据库,可能把 DB 打挂(第 14 篇详讲)。
避免:给 TTL 加随机抖动:
ttl := baseTTL + time.Duration(rand.Intn(300))*time.Second
这样既避免了雪崩,也把过期删除的压力均摊到时间轴上。另外别忘了开 lazyfree-lazy-expire yes。
Q14:Redis 的过期机制会不会导致主从数据不一致?
正常情况下不会,因为:
- 只有主节点删除过期 key,并显式传播
DEL给从节点,复制流是唯一的变更来源; - 传播的过期命令是
PEXPIREAT(绝对时间戳) 而不是EXPIRE,消除了网络延迟造成的偏差; - 从节点读取时做逻辑过期判断返回 nil(3.2+),所以不会返回过期数据;
- Lua 脚本执行期间时间被"冻结",保证脚本在主从上的执行结果一致。
但有两个表象上的差异(不是数据不一致,是资源占用不一致):
- 从节点内存可能高于主节点——堆积着"逻辑已过期但还没收到 DEL"的 key;
- 主从切换后会有一波集中删除——新主节点开始承担主动过期职责,之前积压的过期 key 集中被清理,造成延迟毛刺和内存陡降。
真正会导致不一致的是异步复制本身(主节点写完就返回,还没传到从节点就宕机了),这跟过期机制无关,见第 10 篇。
小结
- 过期时间存在每个 db 的
expires字典里(key 是共享的 SDS 指针,value 是绝对毫秒时间戳);主节点传播给从节点/AOF 的统一转成PEXPIREAT,避免网络延迟造成偏差。 - TTL 规律:替换整个 value 的命令(
SET/GETSET)清除 TTL,修改 value 内部的命令保留 TTL;SET ... KEEPTTL可显式保留;RENAME会让新 key 继承旧 key 的 TTL。 - Redis = 惰性删除(保证读不到过期数据)+ 定期删除(保证内存最终回收)+ 内存淘汰(最后防线);没采用定时删除是因为 CPU 开销太大。
- 定期删除
activeExpireCycle:SLOW 模式由serverCron每 100ms 触发、上限 25% CPU 时间;FAST 模式在beforeSleep触发、上限 1ms。算法是随机采样 20 个,过期比例 > 25% 就继续采——这是自适应设计。局限是过期 key 占比低时采样命中率差。 - 8 种淘汰策略:
noeviction(默认)+allkeys-{lru,lfu,random}+volatile-{lru,lfu,random,ttl}。volatile-*在没有任何 key 带 TTL 时会退化成 noeviction(导致不可写),是常见事故。纯缓存用allkeys-lfu/allkeys-lru。 - 近似 LRU:
robj的 24 bitlru字段记秒级访问时间(零额外内存,来自每 100ms 更新的全局lruclock)+ 随机采样maxmemory-samples(默认 5) + 3.0 引入的 16 槽淘汰池(跨次保留好候选)。不用标准 LRU 是因为链表指针要 16 字节/key,且读操作改链表会破坏缓存局部性并加剧 fork 期间的 COW。 - LFU:24 bit 拆成 16 bit ldt(分钟级衰减时间)+ 8 bit counter;对数概率递增(
lfu-log-factor 10时 100 万次访问才到 255,让 8 bit 覆盖 6 个数量级)+ 惰性时间衰减(lfu-decay-time)+ 新 key 初始值 5(新手保护)。 - LFU 抗批量扫描污染,LRU 对突发新热点响应快;大多数缓存场景 LFU 更优,时效性数据(新闻、时间线)LRU 更贴合。
- 主从过期语义:只有主节点删除并显式传播 DEL;从节点不删但读取时做逻辑过期判断返回 nil(3.2 之前有 bug 会返回脏数据);从节点内存可能因积压过期 key 而高于主节点。
- 生产必配:
maxmemory(物理内存 60~70%)+ 合适的 policy +lazyfree-*全开;给批量缓存加随机 TTL 抖动;监控evicted_keys、expired_time_cap_reached_count、mem_fragmentation_ratio(< 1 说明用了 swap,极度危险)。
xingliuhua