Redis 内存淘汰策略(8 种)
面试中问"Redis 内存满了怎么办?"——这不是一个选择题,是架构设计的第一道坎。
问题
Redis 是内存数据库,内存是有限的。当写入的数据超过 maxmemory 配置时,Redis 的处理方式是什么?怎样选择淘汰策略?不同策略的适用场景和代价分别是什么?
核心分析
内存淘汰的触发条件
Redis 的内存淘汰不是在内存快满时才启动,而是每次执行命令前检查。事件循环在处理任何写命令前,都会调用 freeMemoryIfNeeded() 检查当前内存是否超过 maxmemory,超过则触发淘汰,直到内存回到限制以下或者没有可淘汰的 key 为止。
淘汰时序:
请求到达 → 事件循环 dispatch → 检查命令类型
├─ 读命令(GET/EXISTS/HLEN...)→ 直接执行,无淘汰动作
└─ 写命令(SET/HSET/LPUSH/SADD...)
→ 调用 freeMemoryIfNeeded()
├─ used_memory <= maxmemory → 放行,执行命令
└─ used_memory > maxmemory
→ 按策略淘汰 key(同步循环)
→ 淘汰后再次检查
├─ 内存达标 → 执行命令
└─ 无 key 可淘汰且 still over → 返回 OOM 错误这个检查是同步阻塞的——如果内存严重超限且需要淘汰大量 key,这个命令的延迟会显著升高。这也是为什么 maxmemory 要留有一定余量,而不是设到物理内存极限。
踩坑实录: 某社交平台 feed 缓存实例,maxmemory 设到 30GB(物理内存 32GB),日常稳定在 28GB 左右。某次突发流量写入大量热点数据,瞬间触发淘汰 200 万+ key,freeMemoryIfNeeded() 阻塞了 1.2 秒,下游读请求排队超时,雪崩到 DB 层。事后措施:maxmemory 降到 24GB,留 25% 余量。
8 种策略全景
Redis 提供了 8 种 maxmemory-policy 策略,分三大家族:
不淘汰族
noeviction:内存超限时写请求直接报错,读请求正常。这是默认策略,适合不允许丢任何数据的场景(如 Session 存储)。
全空间淘汰族(针对所有 key)
allkeys-lru:在所有 key 中淘汰最近最少使用的 key。最常用、最通用的策略。allkeys-lfu:在所有 key 中淘汰最不常访问的 key。适合有明确访问频率差异的场景。allkeys-random:随机淘汰任意 key。兜底用,性能最好但最不可控。
带 TTL 淘汰族(只针对设置了过期时间的 key)
volatile-lru:只在设置了 TTL 的 key 中淘汰最近最少使用的。volatile-lfu:只在设置了 TTL 的 key 中淘汰最不常访问的。volatile-random:只在设置了 TTL 的 key 中随机淘汰。volatile-ttl:淘汰剩余 TTL 最短的 key(即将过期的优先淘汰)。
近似 LRU vs 标准 LRU
Redis 的 LRU 不是教科书上那种精确 LRU,而是近似 LRU。原因是精确 LRU 需要维护一个双向链表,每次访问都移动节点,在 Redis 单线程模型下代价太高。
Redis 的做法是:从 maxmemory-samples(默认 5)个 key 中随机采样,选出其中空闲时间最长的淘汰。采样数越大,淘汰越接近真实 LRU,但 CPU 开销也越大。
近似 LRU 采样流程:
SET key:val (内存已超限)
↓
freeMemoryIfNeeded()
↓
while (used_memory > maxmemory):
1. 从全局哈希表随机取 maxmemory-samples 个 key
2. 对比每个 key 的 lru 字段(24bit 时间戳)
3. 挑出 idle_time 最大的 key(即"最久没被访问"的)
4. 异步删除(dbAsyncDelete)
5. used_memory -= key_size
↓
内存达标 → 放行写命令采样数与准确率的数据:
| samples | 接近标准 LRU | 单次淘汰 CPU 开销 | 建议场景 |
|---|---|---|---|
| 5(默认) | ~90% | 低 | 大多数通用缓存 |
| 10 | ~99% | 中(约 2x) | 对淘汰精确度敏感的场景 |
| 20 | ~99.5% | 高(约 4x) | 不推荐,性价比低 |
这个取舍是 P7 级面试的分水岭——知道 LRU 有近似,还要知道怎么调、调多少。
代码示例:配置和监控
# 查看当前内存限制和淘汰策略
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
redis-cli CONFIG GET maxmemory-samples
# 运行时修改(生产环境建议用 CONFIG SET,无需重启)
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG SET maxmemory-samples 10
# 监控内存淘汰情况
redis-cli INFO stats | grep evicted_keys
# evicted_keys:0 —— 当前尚未发生淘汰
# evicted_keys:12345 —— 已淘汰 12345 个 key
# 查看内存使用详情
redis-cli INFO memory# 用 redis-cli 模拟内存超限,观察淘汰行为
# 先设一个很小的内存限制
redis-cli CONFIG SET maxmemory 1mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# 大量写入触发淘汰
for i in $(seq 1 10000); do
redis-cli SET "key:$i" "$(python3 -c "print('x'*1024)")" > /dev/null 2>&1
done
# 看淘汰了多少
redis-cli INFO stats | grep evicted_keys选型决策树
判断逻辑不复杂,按下面三步走:
1. 数据是否允许丢失?
否 → noeviction(如 Session、用户 Token)
是 → 看第 2 步
2. 所有 key 是否都设置了 TTL?
是 → 可考虑 volatile-lru 或 allkeys-lru 均可
否 → 必须用 allkeys-* 系列,否则没有 TTL 的 key 永远不会被淘汰
3. 访问模式特征?
流量波动大、最近访问的 key 最重要 → allkeys-lru
有明确热点、高频访问 key 持续高命中 → allkeys-lfu
数据访问均匀、无热点 → allkeys-random(性能最好)
方案不确定 → allkeys-lru(最通用的默认选择)策略对比与场景
| 策略 | 适用场景 | 不适用场景 | 典型风险 |
|---|---|---|---|
| noeviction | Session 存储、用户 Token、支付订单状态 | 缓存类业务(设计上就能丢数据) | 写请求报错,需业务层捕获 OOM 异常重试或降级 |
| allkeys-lru | 通用缓存(商品详情、首页推荐、用户信息) | 有明确长期热点的场景(如排行榜) | 冷数据可能挤压热点,周期性任务产生的冷数据需要额外清理 |
| allkeys-lfu | 访问频率差异大的场景(热搜词、活跃用户) | 流量波动大的场景(促销活动突发流量) | 衰减参数需要调优,波谷热点可能被误淘汰 |
| allkeys-random | 数据访问均匀、兜底方案 | 有明确访问模式的数据 | 淘汰不可控,可能把热点数据随机淘汰 |
| volatile-lru | 只淘汰缓存数据,持久数据不受影响 | 没有 TTL 的 key 不需要淘汰 | 无 TTL 的 key 永远不会被淘汰,可能导致"内存泄漏"假象 |
| volatile-lfu | 热点数据在 TTL key 中 | 所有 key 都设了 TTL 但访问频率差异小 | 同上,且 LFU 衰减参数需要额外关注 |
| volatile-ttl | 理论上有用,实际效果差 | 大多数场景 | 可能淘汰即将被访问的 key(如验证码、临时 token) |
| volatile-random | 极少用 | 几乎所有场景 | 不可控,不稳定 |
P7/P8 深度
1. LFU 的衰减机制
LFU 不是简单的计数器累加。Redis 的 LFU 实现包含访问频率计数器和衰减因子两部分:
- 计数器以 16 位存储,最大 65535
- 每次访问时计数器做对数增长(不是线性),避免高频 key 的计数器很快溢出
lfu-decay-time控制衰减速度:counter 每隔 N 分钟衰减一次,避免"昨日热点"永远占着位置
默认 lfu-decay-time=1(每分钟衰减),如果业务流量在特定时段有波峰波谷,需要调大这个值防止热点在波谷期被误淘汰。
真实案例: 某资讯 App 的推荐缓存用 allkeys-lfu,早晨 8 点早高峰时用户访问量集中,产生大量热点 key。到了中午流量回落,LFU 衰减速度跟不上,下午 2 点的突发流量(如某条新闻爆了)无法把新 key 放入缓存,因为旧热点的计数器还没衰减到位。把 lfu-decay-time 从 1 调整到 2(每 2 分钟衰减一次),问题解决。
2. LRU 时钟精度陷阱
Redis 的 LRU 时间戳用 24 位存储,最大约 194 天。当 server.lruclock 溢出时,所有 key 的 lru 字段重置,导致大量 key 被误判为"很久没访问",触发大规模淘汰。
实际场景中,lruclock 每 100ms 更新一次,如果节点长时间未收到任何请求,lruclock 也可能不更新。低流量场景下,LRU 的准确性会下降。
测试验证:
# 查看 lruclock 当前值
redis-cli INFO server | grep lru
# lru_clock:15432145
# 连续运行 194 天后,lru_clock 会归零
# 此时所有 key 的 lru 字段都变成"刚写入"到"很久以前"的随机值
# 实际生产 case:某 Redis 实例运行 200 天未重启,lruclock 溢出后
# evicted_keys 在 30 分钟内激增 300 万,CPU 从 20% 飙升到 90%3. volatile-ttl 的坑
volatile-ttl 看起来是"谁快过期淘汰谁",直觉上很合理。但实际效果往往不如预期——因为 TTL 短的 key 很可能就是正要被用户访问的关键数据(比如验证码、临时 token),淘汰它们反而容易引发缓存穿透。
更安全的做法是:如果必须淘汰带 TTL 的 key,选 volatile-lru 而非 volatile-ttl。
4. 内存淘汰是卡顿的隐形杀手
当 maxmemory 设得接近物理内存上限,并且需要一次性淘汰大量 key 时,freeMemoryIfNeeded() 的循环会成为单线程模型下的阻塞点。在生产环境做容量规划时,建议:
maxmemory留 20-30% 余量,不要设到物理内存极限- 监控
evicted_keys指标,如果持续增长说明内存不够,需要扩容或清理数据 - 对
DEL大 key 的淘汰用UNLINK(异步删除)代替,减少主线程阻塞
为什么 UNLINK 比 DEL 好:
| 操作 | 行为 | 阻塞时间 |
|---|---|---|
| DEL key | 同步释放内存,删除大 key 会长时间阻塞 | 删除 100 万元素 list 约 200ms |
| UNLINK key | 在 O(1) 时间内从全局键空间移除,实际内存回收交给后台线程 | 通常 < 1ms |
5. 淘汰策略与持久化的交互
当 RDB 快照或 AOF 重写正在进行时,主进程 fork 出一个子进程。此时如果触发大量淘汰,COW(Copy-On-Write)机制会放大内存消耗——因为每个被修改的 page 都需要复制一份给子进程。
实际数据: 某实例 8GB 内存,maxmemory=6GB,在 AOF 重写期间触发淘汰,COW 导致物理内存瞬间冲到 9.5GB,超过容器限制被 OOM Kill。解决方案:重写期间禁用内存淘汰触发的写操作,或把 maxmemory 设得更保守。
总结
| 策略 | 适用场景 | 风险 |
|---|---|---|
| noeviction | 数据不能丢(Session、Token) | 写请求报错,需业务层处理 |
| allkeys-lru | 通用缓存,访问有时效性 | 冷数据可能挤压热点 |
| allkeys-lfu | 访问频率差异大的场景 | 衰减参数需要调优 |
| allkeys-random | 兜底,数据访问均匀 | 淘汰不可控 |
| volatile-lru | 只淘汰缓存数据,持久数据不受影响 | 无 TTL 的 key 永远不会被淘汰 |
| volatile-lfu | 热点数据在 TTL key 中 | 同上 |
| volatile-ttl | 理论上有用,实际效果差 | 可能淘汰即将被访问的 key |
| volatile-random | 极少用 | 不可控,不稳定 |
一句话总结:选 allkeys-lru 作为默认策略,根据业务访问模式确定是否切换到 LFU,始终留 20-30% 内存余量,监控 evicted_keys 触发警戒线就扩容,大 key 淘汰用 UNLINK 替代 DEL。