内存管理、过期删除与淘汰策略
系统梳理 Redis 内存管理、TTL、主动过期、被动过期、maxmemory、淘汰策略、LRU、LFU、LRM、lazyfree、RSS 与容器 OOM 的生产边界。
第 13 章:内存管理、过期删除与淘汰策略
本章以截至 2026 年 6 月的 Redis Open Source 8.8 文档与源码为主要技术基线。Redis 8.6 起新增
allkeys-lrm、volatile-lrm策略,用于按“最近修改时间”而非“最近访问时间”选择淘汰对象;涉及旧版本时会单独标注。 (Redis)
1. 本章定位
Redis 内存问题不能只看一个 used_memory,也不能把“过期删除”“内存淘汰”“释放物理内存”混为一谈。完整链路是:
写入数据
↓
Redis 申请内存
↓
可选:设置 TTL
↓
被动过期 / 主动过期 / maxmemory 淘汰 / 用户删除
↓
键从逻辑键空间移除
↓
同步释放或后台 lazyfree
↓
内存归还给分配器
↓
分配器选择复用或归还给操作系统
↓
RSS 可能下降,也可能不下降
因此,生产中经常出现以下看似矛盾的现象:
- 键已经过期,但内存没有立即下降;
- 已设置
maxmemory,进程仍然被容器 OOMKill; - 批量
DEL后 Redis 出现数百毫秒甚至数秒延迟; used_memory不高,但used_memory_rss很高;- 使用
volatile-lru后仍收到 Redis OOM 错误; - 开启 lazyfree 后,删除成功但内存迟迟不释放。
本章的核心,是建立三个相互独立但又彼此影响的模型:
- TTL 模型:数据何时在业务语义上失效;
- 容量模型:Redis 达到
maxmemory后如何腾出空间; - 物理内存模型:分配器、操作系统、Swap 和容器限制如何影响 RSS。
2. 学习目标
完成本章后,应能够:
- 解释
maxmemory与进程 RSS、容器内存上限之间的区别; - 正确选择
noeviction、allkeys-*、volatile-*淘汰策略; - 说明 Redis 的 LRU、LFU 为什么都是近似算法;
- 解释被动过期、主动过期及其 CPU 开销;
- 使用
INFO memory、INFO stats和MEMORY STATS判断内存问题; - 区分
DEL、UNLINK和 lazyfree,避免 BigKey 删除阻塞; - 分析 Redis OOM、操作系统 OOMKill、Swap 抖动等不同故障;
- 在 Go 应用中实现 TTL 抖动、超时控制和容量保护。
2.1 本章边界与跳转
本章是过期删除、淘汰策略和内存管理的主章节。第 3 章中的 TTL 重点是命令、Key 生命周期和业务语义;本章重点是 Redis 内部如何回收、采样、淘汰以及为什么 RSS、碎片和 Swap 会影响稳定性。缓存雪崩和随机 TTL 的架构防护见第 18 章;大 Key、热 Key 和内存上涨的线上诊断流程见第 20 章。
3. 核心概念
3.1 maxmemory 不是物理内存硬上限
maxmemory 是 Redis 用于判断是否需要淘汰数据或拒绝增量写入的逻辑内存阈值,不是操作系统层面的 RSS 限制。
maxmemory 4gb
maxmemory-policy allkeys-lfu
当 Redis 判断内存使用超过 maxmemory 时:
- 有可执行的淘汰策略:尝试删除候选键;
- 使用
noeviction:拒绝会继续增加内存的命令; - 使用
volatile-*但没有带 TTL 的候选键:行为退化为类似noeviction; - 淘汰速度赶不上写入速度:部分写入仍可能收到 OOM 响应。
maxmemory 0 表示不设置数据集内存限制;在生产环境中,这通常意味着 Redis 会持续增长,直到受到机器、容器或操作系统限制。复制缓冲区和 AOF 缓冲区中的部分内存不会计入淘汰判断,以避免“为了同步淘汰命令而增加缓冲区,继而触发更多淘汰”的反馈循环。 (Redis)
实际内存预算应满足:
容器或机器可用内存
>
maxmemory
+ 分配器碎片
+ Redis 进程及线程开销
+ AOF/复制/客户端缓冲区
+ 模块内存
+ RDB/AOF 重写期间的写时复制峰值
+ 操作系统安全余量
因此,把 maxmemory 直接设置成 Pod 或机器内存上限,是典型的错误配置。
3.2 淘汰策略
Redis 当前主要支持以下策略。allkeys-* 从全部键中选择候选;volatile-* 只从设置了过期时间的键中选择候选。 (Redis)
| 策略 | 候选键范围 | 选择依据 | 典型用途 |
|---|---|---|---|
noeviction | 不淘汰 | 超限后拒绝增量写入 | 不允许静默丢数据的专用实例 |
allkeys-lru | 全部键 | 最近最少使用 | 访问具有明显时间局部性的通用缓存 |
allkeys-lfu | 全部键 | 近似最低访问频率 | 热点相对稳定、长尾明显的缓存 |
allkeys-random | 全部键 | 随机 | 访问概率接近均匀、价值相近 |
allkeys-lrm | 全部键 | 最近最少修改 | Redis 8.6+,读多写少且修改时间更能代表价值 |
volatile-lru | 仅带 TTL 的键 | 最近最少使用 | 混合数据实例,但存在较大运维风险 |
volatile-lfu | 仅带 TTL 的键 | 近似最低访问频率 | 仅允许淘汰带 TTL 的低频数据 |
volatile-random | 仅带 TTL 的键 | 随机 | TTL 键价值相近 |
volatile-ttl | 仅带 TTL 的键 | 剩余 TTL 较短 | 应用通过 TTL 表达数据价值优先级 |
volatile-lrm | 仅带 TTL 的键 | 最近最少修改 | Redis 8.6+,按写入新旧进行淘汰 |
noeviction 的准确含义
noeviction 并不表示“超过上限后所有写命令都会失败”。
更准确的说法是:
Redis 会拒绝那些预计导致内存继续增长的命令;读取、删除、覆盖成更小值等命令仍可能成功。
此外,noeviction 只解决“不要自动淘汰”问题,并不会自动提供持久化、事务一致性或数据零丢失保证。
volatile-* 的隐藏风险
假设实例中有:
- 30 GB 永不过期数据;
- 10 GB 带 TTL 缓存;
maxmemory为 32 GB;- 策略为
volatile-lru。
Redis 只能从 10 GB TTL 数据中淘汰。一旦 TTL 数据被淘汰殆尽,而永久键仍占用接近或超过上限,Redis 将找不到候选键并拒绝增量写入。
因此,将“永久数据”和“可淘汰缓存”放在同一实例中,通常不如拆分实例清晰、安全。
3.3 Redis 为什么使用近似 LRU
严格 LRU 通常需要:
- 维护全局有序链表或类似结构;
- 每次读取都更新键的位置;
- 为链表指针和同步维护付出额外空间;
- 在高吞吐场景中产生大量内存写操作。
Redis 没有对所有键维护精确的全局 LRU 链表,而是:
- 在对象元数据中保存近似访问时间;
- 从候选键空间随机采样;
- 将较优候选放入固定大小的 eviction pool;
- 从候选池中选择最适合淘汰的键。
当前源码中的候选池大小为 16。官方示例配置的 maxmemory-samples 默认值为 5;调大采样数通常可以让结果更接近精确 LRU,但会增加淘汰过程的 CPU 开销。 (Redis)
maxmemory-samples 5
这意味着 allkeys-lru 的准确语义不是:
一定删除整个 Redis 中最久没有访问的那个键。
而是:
在一批近似候选中,尽可能删除最久未访问的键。
3.4 Redis 的近似 LFU
LFU 关注的不是“多久没有访问”,而是“历史访问频率”。
Redis LFU 不保存完整精确计数,而是使用:
- 较小的 8 位计数器;
- Morris Counter 风格的概率递增;
- 周期性衰减;
- 与 LRU 类似的随机采样和候选池。
访问越频繁,计数器继续增长的概率越低,从而用很少的空间近似表示很大的访问次数。衰减机制用于避免历史热点永久占据缓存。 (Redis)
相关配置包括:
lfu-log-factor 10
lfu-decay-time 1
lfu-log-factor越大,计数器增长越慢;lfu-decay-time表示计数衰减的时间尺度;- 衰减过慢:旧热点难以及时退出;
- 衰减过快:LFU 逐渐接近短期访问策略。
LFU 适合热点相对稳定的内容缓存;当业务流量频繁切换阶段或热点快速迁移时,LRU 可能更快适应。
3.5 过期删除:被动过期与主动过期
为每个 TTL 键创建独立定时器,会产生大量定时器管理开销。Redis 因此组合使用两种机制。
被动过期
客户端访问键时,Redis 检查其过期时间:
- 未过期:正常返回;
- 已过期:先删除,再按键不存在处理。
被动过期可以避免扫描全部键,但从不再被访问的过期键无法通过该机制及时释放。
主动过期
Redis 周期性从过期索引中抽样检查键:
- 选择部分数据库或过期索引;
- 抽样检查若干键;
- 删除已经过期的键;
- 如果抽样中已过期比例仍高,继续下一轮;
- 达到本轮时间预算后停止,把 CPU 让给正常请求。
当前 Redis 源码包含慢速和快速主动过期周期,并通过时间上限避免过期扫描长期占用事件循环。active-expire-effort 可以在 1~10 之间调整:值越大,Redis 越积极清理过期数据,但会消耗更多 CPU。 (Redis)
active-expire-effort 1
需要重点监控:
expired_keys
expired_stale_perc
expired_time_cap_reached_count
expire_cycle_cpu_milliseconds
这些指标可以判断:
- 是否存在大量已经逻辑过期但尚未被物理清理的键;
- 主动过期周期是否频繁达到 CPU 时间上限;
- 过期清理是否正在成为延迟来源。 (Redis)
TTL 集中过期
大量键使用完全相同的 TTL,容易在同一个时间窗口集中失效:
// 不推荐:大量请求都固定 10 分钟
rdb.Set(ctx, key, value, 10*time.Minute)
后果包括:
- 主动过期扫描 CPU 突增;
- 大量缓存同时失效;
- 数据库回源流量激增;
- 缓存重建并发升高;
- 网络和连接池同时承压。
常见解决方法是为 TTL 添加随机抖动:
实际 TTL = 基础 TTL + 随机区间
例如:
10 分钟 + [0, 2 分钟)
3.6 内存指标与碎片
关键指标
| 指标 | 含义 | 主要用途 |
|---|---|---|
used_memory | Redis 通过内存分配器申请的内存 | 判断 Redis 逻辑内存规模 |
used_memory_dataset | 扣除主要内部开销后的数据集内存 | 判断真正业务数据占用 |
used_memory_rss | 操作系统观察到的常驻物理内存 | 判断进程实际物理内存压力 |
used_memory_peak | 历史峰值 | 判断 RSS 是否由历史高峰遗留 |
maxmemory | 配置的逻辑容量阈值 | 判断淘汰水位 |
mem_fragmentation_ratio | used_memory_rss / used_memory | 粗略观察 RSS 与已分配内存差距 |
mem_fragmentation_bytes | RSS 与分配内存差值 | 判断问题的绝对规模 |
allocator_frag_ratio | allocator active / allocated | 更接近分配器外部碎片 |
allocator_rss_ratio | allocator resident / active | 判断分配器保留页情况 |
mem_not_counted_for_evict | 未计入淘汰阈值的部分缓冲内存 | 解释 maxmemory 与实际进程内存差异 |
lazyfree_pending_objects | 等待后台释放的对象数 | 判断 lazyfree 是否积压 |
官方说明中特别强调:mem_fragmentation_ratio 不只包含纯粹的内存碎片,还可能包含代码段、线程栈、分配器保留页等进程开销。因此,必须同时观察绝对字节数、数据集大小和更细粒度的 allocator 指标。 (Redis)
常见判断方式
情况一:used_memory_rss 明显大于 used_memory
可能原因:
- 内存碎片;
- 曾经出现较高峰值;
- jemalloc 保留页尚未归还操作系统;
- lazyfree 正在后台释放;
- 进程自身及线程栈开销。
情况二:used_memory 大于 used_memory_rss
这通常意味着一部分 Redis 内存已经被操作系统换出到 Swap。Redis 再次访问这些页面时会触发缺页和磁盘换入,延迟可能明显恶化。 (Redis)
情况三:碎片率很高,但绝对值很小
例如:
used_memory: 5 MB
used_memory_rss: 15 MB
mem_fragmentation_ratio: 3.0
虽然比率为 3,但绝对差值仅 10 MB,可能只是进程固定开销。不能仅凭比率判断“严重内存泄漏”。
3.7 jemalloc 基础
Redis 常见 Linux 构建使用 jemalloc,实际使用的分配器应通过以下指标确认:
mem_allocator
jemalloc 通过 size class、arena 和页管理降低频繁分配成本,但也会产生如下现象:
- 一个内存页中仅释放部分对象时,整个页不能立即归还操作系统;
- 已释放内存可以被 Redis 后续写入复用;
used_memory已经下降,但 RSS 可能保持在历史峰值附近;- 不同尺寸对象频繁创建和删除,会加剧碎片。
因此:
RSS 没有立即下降,不等同于内存没有被复用,也不必然代表内存泄漏。
Redis 的主动碎片整理依赖兼容的 jemalloc 构建。它通过逐步移动对象来压缩碎片,但会额外消耗 CPU,不应在没有指标证据的情况下盲目开启。 (Redis)
3.8 DEL、UNLINK 与 lazyfree
DEL
默认情况下,DEL 会在命令执行路径中:
- 从键空间中删除键;
- 释放键对应的数据结构;
- 完成后再处理后续命令。
删除普通字符串通常很快,但删除包含数百万成员的 Hash、Set、List 或 ZSet 时,释放过程需要遍历大量内部分配,可能阻塞 Redis 的命令处理线程。
UNLINK
UNLINK 将工作拆成两部分:
- 主线程立即把键从键空间中摘除;
- 后台线程异步释放对象占用的内存。
其主线程复杂度为每个键 O(1),实际对象释放的 O(N) 工作在后台完成。 (Redis)
UNLINK large:zset:2026-06-21
UNLINK 返回后:
- 客户端已经无法再访问该键;
- 物理内存不保证立即下降;
lazyfree_pending_objects可能上升;- 后台释放线程仍会消耗 CPU。
lazyfree 配置
常见配置包括:
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
lazyfree-lazy-user-del no
分别控制:
- 淘汰键是否后台释放;
- 过期键是否后台释放;
- Redis 内部删除是否后台释放;
- 用户执行
DEL时是否按异步方式释放。
开启 lazyfree 可以降低主线程长尾延迟,但不能消除释放成本,只是把成本转移到后台线程。若删除速度持续大于后台释放速度,队列会积压,实际内存仍可能居高不下。
3.9 三种不同的 OOM
Redis OOM 响应
典型现象:
OOM command not allowed when used memory > 'maxmemory'
这是 Redis 根据自身容量策略主动拒绝命令,进程仍然存活。
常见原因:
noeviction;volatile-*没有可淘汰的 TTL 键;- 淘汰速度赶不上写入速度;
- 单个命令需要申请较大临时内存;
- lazyfree 释放速度过慢。
操作系统或容器 OOMKill
现象是 Redis 进程直接退出,Kubernetes 可能显示:
Reason: OOMKilled
即使 used_memory 没超过 maxmemory,以下内存仍可能使 RSS 超过容器限制:
- 内存碎片;
- AOF 或复制缓冲区;
- 客户端输出缓冲区;
- 后台保存或重写期间的写时复制;
- Redis 模块;
- lazyfree 积压;
- 操作系统和进程固定开销。
Swap
Redis 对内存访问频繁且随机。页面被换出后,访问这些页面会触发磁盘换入,造成不可预测的长尾延迟。应同时检查:
vmstat 1
重点观察:
si # swap in
so # swap out
一旦 Redis 进程持续发生 Swap,应优先减轻内存压力,而不是仅通过增加客户端超时掩盖问题。 (Redis)
4. 命令与 Go 使用方法
4.1 配置检查
CONFIG GET maxmemory*
CONFIG GET active-expire-effort
CONFIG GET lazyfree-*
CONFIG GET lfu-*
CONFIG GET activedefrag
动态调整示例:
CONFIG SET maxmemory 4gb
CONFIG SET maxmemory-policy allkeys-lfu
CONFIG SET maxmemory-samples 10
CONFIG SET active-expire-effort 2
需要持久化到配置文件时,可根据部署方式执行:
CONFIG REWRITE
在云托管 Redis、Kubernetes Operator 或配置中心管理的环境中,应以平台配置为准,避免运行时配置在重启后丢失。
4.2 内存与淘汰诊断
INFO memory
INFO stats
MEMORY STATS
MEMORY DOCTOR
重点观察:
used_memory
used_memory_dataset
used_memory_rss
used_memory_peak
maxmemory
maxmemory_policy
mem_fragmentation_ratio
mem_fragmentation_bytes
allocator_frag_ratio
allocator_rss_ratio
mem_not_counted_for_evict
lazyfree_pending_objects
evicted_keys
expired_keys
expired_stale_perc
expired_time_cap_reached_count
expire_cycle_cpu_milliseconds
keyspace_hits
keyspace_misses
current_eviction_exceeded_time
evicted_keys 持续增长,意味着 Redis 正在通过牺牲缓存数据维持容量;expired_keys 增长则表示键因 TTL 被删除。两者业务含义不同。 (Redis)
4.3 单键诊断
MEMORY USAGE cache:user:42 SAMPLES 5
TTL cache:user:42
PTTL cache:user:42
OBJECT IDLETIME cache:user:42
OBJECT FREQ cache:user:42
注意:
MEMORY USAGE对嵌套集合可采用抽样,返回值用于容量估计;OBJECT IDLETIME更适合观察 LRU 语义;OBJECT FREQ用于观察 LFU 频率;- 不应在线上对大量键逐个执行这些命令。
删除 BigKey:
UNLINK cache:large-report:42
延迟诊断:
SLOWLOG GET 128
LATENCY LATEST
4.4 Go 客户端:超时、redis.Nil 与容量保护
go-redis/v9 的 *redis.Client 可以被多个 goroutine 并发使用。每次调用仍应传递独立的 Context,并正确区分 redis.Nil、超时、网络错误和 Redis 服务端错误。 (Redis)
下面的示例实现:
- 客户端连接和读写超时;
- 缓存读取时处理
redis.Nil; - TTL 随机抖动;
- 基于
INFO memory的应用层容量保护; - 使用
UNLINK异步删除大键; - 使用
atomic.Bool保证并发访问安全。
package redismem
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
var ErrCapacityProtected = errors.New(
"redis cache capacity protection is active",
)
func NewClient(addr, password string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
DialTimeout: time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
PoolTimeout: time.Second,
// 同时尊重命令级 Context 的截止时间。
ContextTimeoutEnabled: true,
})
}
// CapacityGuard 是应用侧的提前限流机制。
// 它不能替代 Redis 的 maxmemory,只用于在接近容量上限时减少非关键缓存写入。
type CapacityGuard struct {
threshold float64
blocked atomic.Bool
}
func NewCapacityGuard(threshold float64) (*CapacityGuard, error) {
if threshold <= 0 || threshold >= 1 {
return nil, fmt.Errorf(
"threshold must be between 0 and 1, got %f",
threshold,
)
}
return &CapacityGuard{threshold: threshold}, nil
}
func (g *CapacityGuard) Allow() bool {
return !g.blocked.Load()
}
// Refresh 应由独立监控协程周期性调用,不应在每个业务请求中执行。
func (g *CapacityGuard) Refresh(
ctx context.Context,
rdb *redis.Client,
) error {
cmdCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
info, err := rdb.Info(cmdCtx, "memory").Result()
if err != nil {
// 保留原有保护状态,由上层决定监控失败时 fail-open 还是 fail-closed。
return fmt.Errorf("read redis memory info: %w", err)
}
used, err := parseInfoUint(info, "used_memory")
if err != nil {
return err
}
maxMemory, err := parseInfoUint(info, "maxmemory")
if err != nil {
return err
}
// 旧版本或部分兼容实现可能没有此字段。
notCounted, _ := parseInfoUint(info, "mem_not_counted_for_evict")
if maxMemory == 0 {
// 未配置 maxmemory 时,本保护器无法按 Redis 容量比例判断。
g.blocked.Store(false)
return nil
}
// 近似模拟 Redis 用于淘汰判断的逻辑内存。
logicalUsed := used
if notCounted < logicalUsed {
logicalUsed -= notCounted
} else {
logicalUsed = 0
}
ratio := float64(logicalUsed) / float64(maxMemory)
g.blocked.Store(ratio >= g.threshold)
return nil
}
func parseInfoUint(info, key string) (uint64, error) {
prefix := key + ":"
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, prefix) {
continue
}
raw := strings.TrimPrefix(line, prefix)
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf(
"parse INFO field %q value %q: %w",
key,
raw,
err,
)
}
return value, nil
}
return 0, fmt.Errorf("INFO memory does not contain field %q", key)
}
func ttlWithJitter(
base time.Duration,
jitter time.Duration,
) (time.Duration, error) {
if base <= 0 {
return 0, fmt.Errorf("base TTL must be positive")
}
if jitter < 0 {
return 0, fmt.Errorf("TTL jitter must not be negative")
}
if jitter == 0 {
return base, nil
}
// 生成 [0, jitter) 内的随机时长。
n, err := cryptorand.Int(
cryptorand.Reader,
big.NewInt(int64(jitter)),
)
if err != nil {
return 0, fmt.Errorf("generate TTL jitter: %w", err)
}
return base + time.Duration(n.Int64()), nil
}
func SetCache(
ctx context.Context,
rdb *redis.Client,
guard *CapacityGuard,
key string,
value []byte,
baseTTL time.Duration,
jitter time.Duration,
) error {
if guard != nil && !guard.Allow() {
return ErrCapacityProtected
}
ttl, err := ttlWithJitter(baseTTL, jitter)
if err != nil {
return err
}
cmdCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
if err := rdb.Set(cmdCtx, key, value, ttl).Err(); err != nil {
return fmt.Errorf("set cache key %q: %w", key, err)
}
return nil
}
func GetString(
ctx context.Context,
rdb *redis.Client,
key string,
) (value string, found bool, err error) {
cmdCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
value, err = rdb.Get(cmdCtx, key).Result()
switch {
case err == nil:
return value, true, nil
case errors.Is(err, redis.Nil):
// 缓存未命中不是系统错误。
return "", false, nil
default:
return "", false, fmt.Errorf("get cache key %q: %w", key, err)
}
}
func DeleteAsync(
ctx context.Context,
rdb *redis.Client,
keys ...string,
) error {
if len(keys) == 0 {
return nil
}
cmdCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
if err := rdb.Unlink(cmdCtx, keys...).Err(); err != nil {
return fmt.Errorf("unlink redis keys: %w", err)
}
return nil
}
使用示例:
guard, err := NewCapacityGuard(0.85)
if err != nil {
panic(err)
}
// 后台周期性调用,而不是每个请求调用。
if err := guard.Refresh(ctx, rdb); err != nil {
// 上报监控。缓存场景一般可保留上一次状态。
}
err = SetCache(
ctx,
rdb,
guard,
"product:42",
productJSON,
10*time.Minute,
2*time.Minute,
)
switch {
case err == nil:
// 写入成功。
case errors.Is(err, ErrCapacityProtected):
// 数据库仍是事实来源时,可以跳过本次非关键缓存写入。
default:
// 网络、超时或 Redis 服务端错误。
}
应用层容量保护的边界
容量保护不是一致性机制,也不能替代 maxmemory:
- 检查和写入之间存在时间窗口;
- 其他应用也可能同时写入;
INFO memory本身是某个时间点的快照;- Redis Cluster 中必须分别检查每个主节点;
- 阈值不能机械固定为 85%,应依据实例峰值、淘汰速率和业务容错能力确定。
对于数据库为事实来源的纯缓存,可以选择容量保护触发后跳过缓存写入;对于会话、锁、限流等正确性数据,不能静默跳过,应返回明确错误或使用独立容量充足的实例。
5. 典型业务场景
| 场景 | 是否适合 | 数据量级 | 一致性要求 | 主要风险 | 建议与替代方案 |
|---|---|---|---|---|---|
| 商品详情、文章内容缓存 | 适合 | GB~TB,分片部署 | 最终一致 | 缓存雪崩、热点迁移 | allkeys-lru 或 allkeys-lfu,TTL 抖动 |
| 搜索结果、推荐结果缓存 | 适合 | 大量短生命周期键 | 最终一致 | 键数量爆炸、过期扫描压力 | 较短 TTL、随机抖动、限制单值大小 |
| 登录会话、刷新令牌 | 有条件适合 | 中等 | 较高 | 淘汰导致用户异常退出 | 独立实例、容量预留,通常避免 allkeys-* |
| 分布式锁 | 不适合作为可淘汰缓存 | 较小 | 很高 | 键被淘汰造成互斥失效 | 独立实例、noeviction,并完善锁语义 |
| 限流计数器 | 有条件适合 | 高基数 | 较高 | 淘汰后限流失效 | 独立实例、明确 TTL、容量估算 |
| 永久配置与临时缓存混放 | 不推荐 | 任意 | 混合 | volatile-* 候选耗尽后 OOM | 拆分实例,分别制定策略 |
| 超大报表或大型集合 | 通常不适合 | 单键数十 MB 以上 | 视业务而定 | BigKey 阻塞、复制和迁移开销 | 对象存储、数据库分页、拆分键 |
| TTL 能表达业务价值的数据 | 适合 | 中等 | 最终一致 | TTL 设计不合理导致误淘汰 | 可评估 volatile-ttl |
| 读多写少、修改时间决定新鲜度 | 适合 Redis 8.6+ | 大规模缓存 | 最终一致 | 旧但频繁读取的数据仍可能被淘汰 | 评估 allkeys-lrm |
6. 底层实现
6.1 主键空间与过期索引
Redis 需要同时回答两个问题:
- 某个键对应什么值;
- 某个键是否设置了过期时间,以及何时过期。
概念上,Redis 维护主键空间和独立的过期索引。当前 Redis 8.x 源码通过 db->expires 及其 kvstore 抽象管理过期信息,而不是为每个键创建系统定时器。 (GitHub)
设置 TTL 后,Redis 保存的是绝对时间戳。因此,系统时钟发生大幅跳变可能影响键的过期判断。Redis 主节点负责决定键何时过期,并将相应删除传播到 AOF 和副本。 (Redis)
6.2 淘汰候选选择
达到 maxmemory 后,Redis 大致执行以下过程:
计算用于淘汰判断的逻辑内存
↓
判断是否超过 maxmemory
↓
根据 policy 确定候选键空间
↓
随机采样若干键
↓
按 LRU/LFU/LRM/TTL 等标准评分
↓
更新 eviction pool
↓
删除最合适的候选键
↓
重新检查内存是否降到阈值以下
淘汰循环具有时间预算。Redis 不会无限期只做淘汰而完全不处理事件循环;在压力过高时,后续命令可能等待下一轮淘汰,或直接收到无法分配内存的错误。 (GitHub)
6.3 时间复杂度
| 操作 | 主线程复杂度 | 说明 |
|---|---|---|
| 设置或更新 TTL | 平均 O(1) | 更新过期索引 |
| 被动过期检查 | 平均 O(1) 加删除成本 | 访问键时检查时间戳 |
| 主动过期 | 受样本数和时间预算约束 | 不全量扫描全部键 |
| LRU/LFU 淘汰选择 | 与采样数、候选池相关 | 不做全局精确排序 |
| 删除字符串 | 通常 O(1) | 对象本身较简单 |
DEL 大型集合 | 可能接近 O(M) | M 为内部元素或分配数量 |
UNLINK 摘除键 | 每键 O(1) | O(M) 释放工作转到后台 |
| lazyfree 后台释放 | O(M) | 不占主命令路径,但仍消耗 CPU |
6.4 为什么删除 BigKey 会阻塞
一个包含百万成员的 ZSet 并不是单块连续内存。它可能涉及:
- 键对象;
- 有序集合主体;
- 哈希索引;
- 跳表节点;
- 成员字符串;
- 大量独立内存分配。
同步删除时,Redis 必须遍历并释放这些分配。即使删除命令本身只有一个键,内部释放工作仍可能非常大。
因此,BigKey 的风险不仅是“占用内存多”,还包括:
- 删除阻塞;
- 过期阻塞;
- 淘汰阻塞;
- 复制传输耗时;
- AOF 重写膨胀;
- Cluster 迁移缓慢;
- lazyfree 队列积压。
6.5 过期扫描与 CPU 的平衡
主动过期不是越积极越好。
积极扫描的收益:
- 更快清理过期键;
- 降低过期残留内存;
- 减少因残留键导致的容量压力。
代价:
- 更多 CPU 消耗;
- 主线程可用于正常命令的时间减少;
- 大量集中 TTL 下可能增加延迟抖动。
调整 active-expire-effort 前,应先检查:
expired_stale_perc是否持续偏高;expired_time_cap_reached_count是否快速增长;- CPU 是否有余量;
- 是否存在大量相同 TTL;
- 是否可以先通过 TTL 抖动解决。
6.6 延迟与吞吐影响
| 机制 | 对吞吐的影响 | 对尾延迟的影响 |
|---|---|---|
增大 maxmemory-samples | 淘汰计算增加 | 淘汰压力下可能升高 |
提高 active-expire-effort | 过期扫描占用更多 CPU | 可能增加延迟抖动 |
同步 DEL BigKey | 命令处理暂停 | 可能产生明显长尾 |
UNLINK | 主线程更快返回 | 后台 CPU 和内存回收延迟增加 |
| 开启主动碎片整理 | 占用额外 CPU | 配置激进时可能抖动 |
| 高频淘汰 | 实际有效吞吐下降 | 写入可能等待淘汰或失败 |
| Swap | 吞吐显著下降 | 毫秒级延迟可能恶化为更高量级 |
7. 高性能、并发与高可用分析
7.1 高性能设计
预留物理内存余量
maxmemory 必须低于机器或容器上限,差值用于吸收:
- 分配器碎片;
- AOF 和复制缓冲;
- 后台任务;
- fork 写时复制;
- 客户端输出缓冲;
- 短时流量峰值。
这个余量不能只根据平均值计算,必须参考峰值、历史 RSS 和故障演练结果。
控制淘汰频率
持续增长的 evicted_keys 说明缓存容量长期不足。淘汰并非无成本:
- 消耗 CPU;
- 降低命中率;
- 增加数据库回源;
- 形成“写入—淘汰—回源—重建”的放大循环。
避免 TTL 同步
TTL 抖动应在应用层统一封装,避免不同业务团队遗漏。对固定时间点失效的数据,可以采用分批预热、逻辑过期或后台刷新,而不是让全部键在同一秒物理过期。
控制 BigKey
常见做法:
- 将大 Hash 拆分为多个分桶;
- 大 List 按时间或页分段;
- ZSet 按业务维度和时间窗口拆分;
- 大 JSON 改为字段化、压缩或存入对象存储;
- 删除时优先使用
UNLINK。
7.2 并发设计
Redis 的核心命令执行路径仍依赖事件循环的有序处理语义。一个同步释放 BigKey 的命令,可能让所有其他连接一起等待。
Go 侧应注意:
*redis.Client可被多个 goroutine 复用;- 每个操作使用独立
Context; - 不要在每个请求中调用
INFO memory; - 容量状态使用原子变量或只读快照共享;
- 不要让多个 goroutine 并发修改同一个 Pipeline;
- 对网络超时可按幂等性有限重试;
- 对 Redis OOM 响应不要盲目重试,否则只会放大内存压力。 (Redis)
缓存系统通常适合 fail-open:
容量保护触发
→ 跳过非关键缓存写入
→ 继续从数据库返回业务结果
但锁、会话、限流计数等数据不能直接套用该策略。
7.3 高可用与复制
Redis 主节点负责判定键过期,并把删除结果传播给 AOF 和副本。副本在跟随主节点期间不会独立决定主数据键的过期,但会保存 TTL 元数据;副本晋升为主节点后,可以根据这些时间信息自行处理过期。 (Redis)
高可用环境还应关注:
-
副本内存可能高于主节点 官方示例配置中,副本默认可忽略
maxmemory,以避免复制过程中因本地淘汰造成数据集偏离。必须为副本预留足够内存。 (GitHub) -
复制和 AOF 缓冲会占用额外内存 这部分内存可能不计入淘汰阈值,却会计入实际 RSS。
-
故障转移后的策略一致性 各候选主节点应配置一致的
maxmemory和淘汰策略,否则故障转移后行为会改变。 -
Cluster 必须逐主节点评估容量 整个集群平均使用率正常,不代表某个哈希槽集中的主节点没有逼近上限。
-
热点和 BigKey 会造成节点级不均衡 Cluster 解决的是键空间分片,不会自动把一个 BigKey 拆到多个节点。
8. 常见错误与生产事故
8.1 大量键在同一秒过期
现象
expired_keys短时间暴涨;- CPU 升高;
- P99 延迟增加;
- 数据库查询量和连接数突增。
根因
大量缓存统一使用固定 TTL,形成过期风暴和回源雪崩。
排查
- 检查键 TTL 分布;
- 查看
expire_cycle_cpu_milliseconds; - 对比数据库 QPS 与缓存命中率;
- 检查是否存在定时批量写入任务。
修复
- TTL 添加随机抖动;
- 对热点数据提前刷新;
- 限制缓存重建并发;
- 数据库侧增加熔断和限流。
预防
由缓存 SDK 统一封装 TTL 策略,不允许业务代码随意使用整齐的固定过期时间。
8.2 使用 volatile-lru 但仍收到 OOM
现象
Redis 返回 OOM 错误,但实例中似乎仍存在大量数据。
根因
大部分键没有 TTL,Redis 只能淘汰少量带 TTL 的键。候选耗尽后,策略退化为无法继续淘汰。
排查
INFO keyspace
CONFIG GET maxmemory-policy
抽样检查业务键是否设置 TTL,并核对永久键占用。
修复
- 对可淘汰键补充 TTL;
- 改用
allkeys-lru或allkeys-lfu; - 将永久数据和缓存拆到不同实例。
预防
上线前明确每类键的生命周期,并通过扫描或指标监控无 TTL 键比例。
8.3 maxmemory 等于容器上限导致 OOMKilled
现象
Redis 没有明显 OOM 响应,Pod 却突然重启,状态显示 OOMKilled。
根因
maxmemory 只约束逻辑数据集。碎片、复制缓冲、AOF、客户端缓冲和写时复制使 RSS 超出容器限制。
排查
- 查看 Pod 退出原因;
- 对比
used_memory、used_memory_rss; - 检查
mem_not_counted_for_evict; - 检查是否正在执行 RDB 或 AOF 重写;
- 查看内核或容器 OOM 日志。
修复
- 下调
maxmemory; - 临时扩容;
- 处理 BigKey 和碎片;
- 限制客户端输出缓冲;
- 错峰执行后台持久化任务。
预防
基于历史 RSS 峰值和 fork 峰值进行容量预算,而不是只看平均 used_memory。
8.4 DEL BigKey 导致全实例延迟
现象
- 一条
DEL出现在慢日志; - 所有业务请求同时变慢;
- CPU 未必达到 100%;
- 删除完成后服务恢复。
根因
主线程同步遍历并释放大型集合的内部节点。
排查
SLOWLOG GET;MEMORY USAGE;- 离线扫描 BigKey;
- 检查删除、过期和淘汰发生时间。
修复
- 使用
UNLINK; - 将大对象拆分;
- 对自动过期的大键评估
lazyfree-lazy-expire; - 在低峰期分批清理。
预防
建立单键大小、集合成员数和单命令操作量的工程上限。
8.5 批量删除后 RSS 不下降
现象
键数量和 used_memory 已下降,但 used_memory_rss 长时间维持高位。
根因
- jemalloc 保留页面供后续复用;
- 内存页中仍存在未释放对象;
- 碎片较高;
- lazyfree 尚未完成;
- 历史峰值造成 RSS 滞留。
排查
INFO memory
MEMORY STATS
重点查看:
used_memory_peak
allocator_frag_bytes
allocator_rss_bytes
lazyfree_pending_objects
修复
- 等待 lazyfree 完成;
- 观察后续写入能否复用;
- 在确认碎片严重且 CPU 有余量后评估主动碎片整理;
- 极端情况下通过副本切换和滚动重启释放 RSS。
预防
避免大量不同尺寸对象频繁创建和删除,容量评估同时考虑 RSS 峰值。
8.6 LFU 旧热点长期不退出
现象
流量已经切换,新热点命中率仍低,旧数据持续占据缓存。
根因
LFU 衰减过慢,历史热点的频率计数长期偏高。
排查
- 观察
OBJECT FREQ; - 对比热点变化周期与
lfu-decay-time; - 检查命中率和淘汰键分布。
修复
- 缩短衰减时间;
- 评估改用 LRU;
- 对明确分代的数据使用版本化键空间。
预防
根据热点生命周期压测 LFU 参数,不能假定 LFU 在所有场景都优于 LRU。
8.7 lazyfree 队列持续积压
现象
UNLINK很快返回;used_memory和 RSS 下降缓慢;lazyfree_pending_objects持续上升;- Redis 仍然出现内存压力。
根因
大对象产生速度或删除速度超过后台线程的释放能力。
排查
- 观察
lazyfree_pending_objects; - 统计 BigKey 删除频率;
- 检查后台线程 CPU;
- 对比写入速率和释放速率。
修复
- 限制批量删除速度;
- 拆分 BigKey;
- 分批清理;
- 临时扩容;
- 减少同时进行的后台任务。
预防
不要把 lazyfree 当作无限吞吐的垃圾回收器;数据模型本身必须限制单键规模。
9. 选型与权衡
9.1 淘汰策略选择表
| 业务特征 | 推荐策略 | 原因 | 主要代价 |
|---|---|---|---|
| 所有键都是可重建缓存,近期访问最能代表价值 | allkeys-lru | 通用、适应热点变化较快 | 不能长期保留低频但稳定热点 |
| 所有键都是缓存,热点长期稳定 | allkeys-lfu | 更能保留长期高频键 | 需要考虑计数衰减和热点切换 |
| 键访问概率接近均匀 | allkeys-random | 算法简单,策略差异不大 | 无法保护高价值键 |
| 最近修改时间比读取时间更重要 | allkeys-lrm | 适合读多写少、旧版本数据价值低 | 需要 Redis 8.6+ |
| 只有 TTL 键允许被删除 | volatile-lru / volatile-lfu | 保护永久键 | 候选不足时会拒绝写入 |
| TTL 越短的数据价值越低 | volatile-ttl | 利用 TTL 表达优先级 | 仍是近似候选选择 |
| 不允许 Redis 自动删除任何键 | noeviction | 容量不足时明确失败 | 业务必须处理 OOM,且需严格容量规划 |
| 永久数据和缓存混合 | 优先拆实例 | 规则清晰、故障域隔离 | 增加资源和运维成本 |
9.2 决策顺序
可以按以下顺序选择:
1. 数据是否允许被静默淘汰?
├─ 不允许 → 独立实例 + noeviction
└─ 允许
↓
2. 是否所有键都属于缓存?
├─ 否 → 优先拆分实例
└─ 是
↓
3. 什么因素最能代表数据价值?
├─ 最近访问 → allkeys-lru
├─ 长期频率 → allkeys-lfu
├─ 最近修改 → allkeys-lrm
├─ 剩余寿命 → volatile-ttl
└─ 价值接近 → allkeys-random
最终策略必须通过以下指标验证:
- 缓存命中率;
evicted_keys增长速率;- 数据库回源 QPS;
- P99 延迟;
- Redis CPU;
- 单位时间写入量;
- 热点迁移速度;
- Redis OOM 响应数量。
策略选择不是一次性配置,而是需要结合真实流量持续校准。
10. 高频面试题
10.1 过期删除和内存淘汰有什么区别?
推荐回答
一句话回答:过期删除由键的 TTL 驱动,内存淘汰由 maxmemory 压力驱动。
TTL 到期后,键通过被动访问检查或主动过期扫描删除;淘汰则只在 Redis 超过容量阈值时,根据 LRU、LFU、随机或 TTL 等策略选择牺牲键。一个键即使没有到期,也可能被淘汰;一个键到期时,即使内存很充足,也应在后续过期处理过程中被删除。 (Redis)
面试官追问
expired_keys和evicted_keys分别代表什么?- 没有设置
maxmemory时还会发生过期吗? - 过期键是否在到期那一毫秒立即释放内存?
常见错误回答
“过期和淘汰都是 Redis 内存不够时删除键。”
评分点
- 初级:能区分 TTL 与内存压力;
- 中级:能说出被动过期和主动过期;
- 高级:能解释逻辑失效、物理释放和 RSS 下降不是同一时刻。
10.2 maxmemory 是 Redis 进程的物理内存硬上限吗?
推荐回答
不是。maxmemory 是 Redis 用于触发淘汰或拒绝增量写入的逻辑阈值,不等同于 used_memory_rss,也不等同于容器内存限制。
分配器碎片、AOF 和复制缓冲、客户端缓冲、模块以及 fork 写时复制都可能让实际 RSS 高于 maxmemory,所以必须预留额外物理内存。 (Redis)
面试官追问
mem_not_counted_for_evict是什么?- 为什么 Redis 仍会被 OOMKill?
- 如何决定
maxmemory与容器上限之间的差值?
常见错误回答
“设置 maxmemory=8GB 后,Redis 进程最多只会占 8GB。”
评分点
- 初级:知道不是硬上限;
- 中级:能说出 RSS 和碎片;
- 高级:能结合复制、AOF、COW 和容器限制完成容量预算。
10.3 noeviction 是否会拒绝所有写命令?
推荐回答
不会。它主要拒绝那些会使内存继续增长的命令;读取、删除以及可能减少内存的操作仍可执行。
noeviction 的价值是避免 Redis 自动牺牲键,但业务必须能够处理容量不足错误,并且它不等于持久化或强一致性保证。 (Redis)
面试官追问
- 覆盖成更小值是否可能成功?
- 收到 OOM 后是否应该自动重试?
- Lua 脚本或事务中如何处理内存错误?
常见错误回答
“达到上限后 Redis 整体变成只读。”
评分点
- 初级:知道会返回 OOM;
- 中级:知道不是全部命令失败;
- 高级:能分析脚本、重试风暴和业务降级。
10.4 allkeys-* 和 volatile-* 的主要区别是什么?
推荐回答
allkeys-* 从全部键中选候选,volatile-* 只从设置了 TTL 的键中选候选。
若使用 volatile-* 但没有可淘汰 TTL 键,Redis 将无法继续腾出空间,行为类似 noeviction。混合永久数据和缓存时,拆分实例通常比依赖 volatile 策略更安全。 (Redis)
面试官追问
- 为什么
volatile-lru不一定保护服务可写? - 什么场景适合
volatile-ttl? - TTL 本身是否占用内存?
常见错误回答
“volatile 表示只淘汰快过期的键。”
评分点
- 初级:知道候选范围区别;
- 中级:知道候选耗尽问题;
- 高级:能提出实例拆分与故障域隔离。
10.5 Redis 为什么不用精确 LRU?
推荐回答
精确 LRU 需要在每次访问时维护全局有序结构,会增加指针空间、内存写操作和 CPU 开销。
Redis 使用近似访问时间、随机采样和 eviction pool,在较低成本下得到接近 LRU 的效果。提高 maxmemory-samples 能改善选择质量,但会增加淘汰 CPU。 (Redis)
面试官追问
maxmemory-samples调大有什么影响?- 候选池的作用是什么?
- 近似 LRU 是否可能淘汰并非最旧的键?
常见错误回答
“Redis 的 LRU 就是随机删除,所以性能高。”
评分点
- 初级:知道 LRU 是近似的;
- 中级:能说明随机采样;
- 高级:能解释候选池、空间开销和命中率权衡。
10.6 Redis LFU 是精确访问计数吗?
推荐回答
不是。Redis LFU 使用小型概率计数器和衰减机制近似表示访问频率,并通过采样选择淘汰候选。
这种设计可以用较少元数据表示很大的访问次数,但计数并不等于真实请求数。lfu-log-factor 控制增长速度,lfu-decay-time 控制历史热度衰减。 (Redis)
面试官追问
- 为什么计数越高越难继续增加?
- 衰减过慢有什么问题?
- LFU 如何适应新热点?
常见错误回答
“Redis 会为每个键保存一个 64 位访问次数。”
评分点
- 初级:知道 LFU 衡量频率;
- 中级:知道概率计数和衰减;
- 高级:能结合热点生命周期调参。
10.7 allkeys-lru 和 allkeys-lfu 如何选择?
推荐回答
LRU 更关注近期访问,适合热点快速变化或具有时间局部性的业务;LFU 更关注长期频率,适合稳定热点和明显长尾分布。
不能只凭理论判断,应比较命中率、回源流量、热点切换速度和淘汰后的重建成本。LFU 并不必然优于 LRU。 (Redis)
面试官追问
- 新闻热点缓存更适合哪一个?
- 商品基础信息更适合哪一个?
- 流量阶段性切换时 LFU 有什么风险?
常见错误回答
“LFU 信息更多,所以任何场景都比 LRU 好。”
评分点
- 初级:能说出最近访问与访问频率;
- 中级:能结合业务模式选择;
- 高级:能设计压测和线上对照指标。
10.8 Redis 为什么同时需要被动过期和主动过期?
推荐回答
被动过期能在访问时低成本保证不会返回已过期数据,但无法清理以后再也不被访问的键;主动过期通过周期性抽样释放这些残留键。
Redis 不全量扫描,是为了限制 CPU 消耗。主动周期具有时间预算,并根据过期样本比例自适应继续或停止。 (Redis)
面试官追问
- 为什么不为每个键创建定时器?
active-expire-effort越大越好吗?- 如何判断主动过期达到时间上限?
常见错误回答
“每个 TTL 键都有一个定时任务,到期立刻删除。”
评分点
- 初级:知道两种过期方式;
- 中级:知道抽样与时间预算;
- 高级:能结合 CPU、过期残留和 TTL 风暴分析。
10.9 volatile-ttl 会精确删除剩余 TTL 最短的键吗?
推荐回答
不会保证全局精确最短。Redis 仍然通过候选采样选择相对更短 TTL 的键,而不是对全部 TTL 键维护一个精确最小堆。
因此,该策略适用于应用已经用 TTL 表达数据价值的场景,但不能依赖它实现严格优先级队列。 (Redis)
面试官追问
- 为什么不维护全局 TTL 排序?
- 大量键 TTL 相同时会怎样?
- 它能否替代延迟队列?
常见错误回答
“Redis 每次一定删除全局最早过期的键。”
评分点
- 初级:知道按 TTL 倾向淘汰;
- 中级:知道仍是近似采样;
- 高级:能区分缓存优先级与严格调度语义。
10.10 如何判断 Redis 是否存在严重内存碎片?
推荐回答
不能只看 mem_fragmentation_ratio,应同时看 mem_fragmentation_bytes、used_memory_peak、allocator_frag_ratio、allocator_rss_ratio 和数据集绝对规模。
小实例中很高的比率可能只是固定进程开销;RSS 远高于 used_memory 可能是碎片或历史峰值;used_memory 高于 RSS 则需要警惕 Swap。 (Redis)
面试官追问
- 为什么删除数据后 RSS 不下降?
- 主动碎片整理有什么代价?
- 如何区分碎片和内存泄漏?
常见错误回答
“碎片率超过 1 就说明 Redis 内存泄漏。”
评分点
- 初级:会比较 used memory 与 RSS;
- 中级:会看绝对字节数和峰值;
- 高级:能分析 allocator active、resident 及滚动重启策略。
10.11 DEL 和 UNLINK 有什么区别?
推荐回答
DEL 默认在命令路径中同步释放对象,BigKey 可能阻塞;UNLINK 先从键空间摘除键,再由后台线程释放对象。
UNLINK 主线程每个键为 O(1),但后台释放仍需要 O(N),所以它只是转移成本,并不会让内存瞬间消失。 (Redis)
面试官追问
UNLINK返回后还能读到键吗?- 如何判断后台释放积压?
- lazyfree 是否应该全部开启?
常见错误回答
“UNLINK 会立刻把内存归还给操作系统。”
评分点
- 初级:知道同步和异步区别;
- 中级:知道逻辑删除与物理释放;
- 高级:能分析后台队列、CPU 和 RSS 滞后。
10.12 为什么设置了 maxmemory,Redis 仍可能 OOMKill?
推荐回答
因为 maxmemory 管理的是 Redis 的逻辑淘汰阈值,而 OOMKill 取决于整个进程或容器的实际物理内存。
分配器碎片、AOF、复制缓冲、客户端输出缓冲、fork 写时复制、模块和后台释放积压都可能使 RSS 超过容器上限。 (Redis)
面试官追问
- Redis OOM 响应与内核 OOMKill 如何区分?
- RDB 保存期间为什么内存可能增加?
- 应如何预留 headroom?
常见错误回答
“出现 OOMKill 说明 maxmemory 没有生效。”
评分点
- 初级:能区分 Redis 错误与进程退出;
- 中级:能说出碎片和缓冲区;
- 高级:能结合 COW、容器限制和峰值容量设计。
10.13 Redis 主从环境中由谁负责过期?
推荐回答
主节点负责判定过期并把删除结果传播给 AOF 和副本。副本跟随主节点时不会独立删除主节点数据,但会保存过期时间,晋升后可以自行处理。
这样可以减少主从因时钟和扫描时机不同而产生的数据集分歧。 (Redis)
面试官追问
- 主从时钟偏差有什么影响?
- 副本为什么可能比主节点占用更多内存?
- 故障转移后是否会出现集中清理?
常见错误回答
“每个副本都根据自己的定时器独立删除键。”
评分点
- 初级:知道主节点主导;
- 中级:知道删除会传播;
- 高级:能分析晋升、时钟和副本内存配置。
10.14 Go 应用如何降低 Redis 内存事故风险?
推荐回答
应统一封装 TTL 抖动,使用带超时的 Context,把 redis.Nil 当作缓存未命中,周期性采集容量而不是每请求查询,并在接近水位时限制非关键缓存写入。
*redis.Client 可以并发复用,但容量保护必须按实例或 Cluster 主节点执行;Redis OOM 不应盲目重试,删除 BigKey 应优先使用 UNLINK。 (Redis)
面试官追问
- 容量监控失败时应 fail-open 还是 fail-closed?
- Cluster 中如何检查所有主节点?
- 哪些数据不能跳过缓存写入?
常见错误回答
“每次写入前调用一次 INFO memory,超过 80% 就返回成功但不写。”
评分点
- 初级:会设置超时并处理
redis.Nil; - 中级:会做 TTL 抖动和后台容量采集;
- 高级:能区分缓存与正确性数据,并设计分节点保护、降级和监控闭环。
11. 一分钟面试回答
Redis 的内存管理需要区分 TTL 过期、maxmemory 淘汰和操作系统物理内存三个层次。TTL 通过被动检查和主动抽样扫描删除;达到 maxmemory 后,Redis 根据 noeviction、allkeys-* 或 volatile-* 决定拒绝写入还是淘汰。LRU 和 LFU 都是近似算法,使用采样和候选池控制 CPU 与空间成本,LFU 还包含概率计数和衰减。maxmemory 不是 RSS 硬上限,碎片、AOF、复制缓冲和写时复制仍可能导致 OOMKill。删除 BigKey 应优先使用 UNLINK,但需监控 lazyfree 积压。Go 应用侧应使用 Context 超时、正确处理 redis.Nil、为 TTL 添加抖动,并按节点实施容量保护。
12. 本章小结
- 过期与淘汰不是同一个机制:TTL 决定数据寿命,
maxmemory决定容量不足时的处理方式。 maxmemory不是 RSS 硬上限:必须为碎片、缓冲区和后台任务预留物理内存。- volatile 策略只淘汰 TTL 键:候选耗尽后仍可能出现 OOM。
- Redis LRU/LFU 都是近似算法:通过采样、候选池和紧凑元数据换取高吞吐。
- 主动过期需要平衡 CPU 和过期残留:TTL 抖动通常比盲目提高扫描强度更有效。
- RSS 不下降不等于内存泄漏:分配器可能保留已释放页面供后续复用。
- BigKey 的风险包含释放成本:
UNLINK可以降低主线程阻塞,但不能消除后台工作量。 - Redis OOM、OOMKill 和 Swap 是不同故障:排查路径和解决方案不能混用。
- Go 侧容量保护只能作为前置防线:不能替代 Redis 配置和完整容量规划。
13. 自测清单
maxmemory与used_memory_rss为什么可能相差很大?- 使用
volatile-lfu时,没有 TTL 的键会发生什么? - Redis 为什么不维护精确的全局 LRU 链表?
- LFU 的概率计数和衰减分别解决什么问题?
- 被动过期为什么不能单独完成全部过期清理?
expired_time_cap_reached_count持续增长说明什么?- 为什么执行
UNLINK后 RSS 可能仍然不下降? - 如何区分 Redis 返回的 OOM 与容器 OOMKill?
- Go 应用中的 TTL 抖动主要防止什么故障?
- Redis Cluster 中为什么必须逐个主节点实施容量监控?
14. 本章使用的官方资料
- Redis Open Source 当前版本与下载信息。 (Redis)
- Redis 8.6 新增 LRM 淘汰策略说明。 (Redis)
- Redis Key Eviction 官方文档。 (Redis)
- Redis
EXPIRE与过期传播官方文档。 (Redis) - Redis
INFO内存与统计字段文档。 (Redis) - Redis
MEMORY STATS官方文档。 (Redis) - Redis 内存优化和 RSS 说明。 (Redis)
- Redis 延迟与 Swap 诊断文档。 (Redis)
- Redis
UNLINK官方文档。 (Redis) - Redis 8.8
evict.c、expire.c源码。 (GitHub) - go-redis 官方使用、错误处理及并发安全说明。 (Redis)