返回文章列表

内存管理、过期删除与淘汰策略

系统梳理 Redis 内存管理、TTL、主动过期、被动过期、maxmemory、淘汰策略、LRU、LFU、LRM、lazyfree、RSS 与容器 OOM 的生产边界。

第 13 章:内存管理、过期删除与淘汰策略

本章以截至 2026 年 6 月的 Redis Open Source 8.8 文档与源码为主要技术基线。Redis 8.6 起新增 allkeys-lrmvolatile-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 后,删除成功但内存迟迟不释放。

本章的核心,是建立三个相互独立但又彼此影响的模型:

  1. TTL 模型:数据何时在业务语义上失效;
  2. 容量模型:Redis 达到 maxmemory 后如何腾出空间;
  3. 物理内存模型:分配器、操作系统、Swap 和容器限制如何影响 RSS。

2. 学习目标

完成本章后,应能够:

  1. 解释 maxmemory 与进程 RSS、容器内存上限之间的区别;
  2. 正确选择 noevictionallkeys-*volatile-* 淘汰策略;
  3. 说明 Redis 的 LRU、LFU 为什么都是近似算法;
  4. 解释被动过期、主动过期及其 CPU 开销;
  5. 使用 INFO memoryINFO statsMEMORY STATS 判断内存问题;
  6. 区分 DELUNLINK 和 lazyfree,避免 BigKey 删除阻塞;
  7. 分析 Redis OOM、操作系统 OOMKill、Swap 抖动等不同故障;
  8. 在 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 通常需要:

  1. 维护全局有序链表或类似结构;
  2. 每次读取都更新键的位置;
  3. 为链表指针和同步维护付出额外空间;
  4. 在高吞吐场景中产生大量内存写操作。

Redis 没有对所有键维护精确的全局 LRU 链表,而是:

  1. 在对象元数据中保存近似访问时间;
  2. 从候选键空间随机采样;
  3. 将较优候选放入固定大小的 eviction pool;
  4. 从候选池中选择最适合淘汰的键。

当前源码中的候选池大小为 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 周期性从过期索引中抽样检查键:

  1. 选择部分数据库或过期索引;
  2. 抽样检查若干键;
  3. 删除已经过期的键;
  4. 如果抽样中已过期比例仍高,继续下一轮;
  5. 达到本轮时间预算后停止,把 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_memoryRedis 通过内存分配器申请的内存判断 Redis 逻辑内存规模
used_memory_dataset扣除主要内部开销后的数据集内存判断真正业务数据占用
used_memory_rss操作系统观察到的常驻物理内存判断进程实际物理内存压力
used_memory_peak历史峰值判断 RSS 是否由历史高峰遗留
maxmemory配置的逻辑容量阈值判断淘汰水位
mem_fragmentation_ratioused_memory_rss / used_memory粗略观察 RSS 与已分配内存差距
mem_fragmentation_bytesRSS 与分配内存差值判断问题的绝对规模
allocator_frag_ratioallocator active / allocated更接近分配器外部碎片
allocator_rss_ratioallocator 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 和页管理降低频繁分配成本,但也会产生如下现象:

  1. 一个内存页中仅释放部分对象时,整个页不能立即归还操作系统;
  2. 已释放内存可以被 Redis 后续写入复用;
  3. used_memory 已经下降,但 RSS 可能保持在历史峰值附近;
  4. 不同尺寸对象频繁创建和删除,会加剧碎片。

因此:

RSS 没有立即下降,不等同于内存没有被复用,也不必然代表内存泄漏。

Redis 的主动碎片整理依赖兼容的 jemalloc 构建。它通过逐步移动对象来压缩碎片,但会额外消耗 CPU,不应在没有指标证据的情况下盲目开启。 (Redis)


DEL

默认情况下,DEL 会在命令执行路径中:

  1. 从键空间中删除键;
  2. 释放键对应的数据结构;
  3. 完成后再处理后续命令。

删除普通字符串通常很快,但删除包含数百万成员的 Hash、Set、List 或 ZSet 时,释放过程需要遍历大量内部分配,可能阻塞 Redis 的命令处理线程。

UNLINK 将工作拆成两部分:

  1. 主线程立即把键从键空间中摘除;
  2. 后台线程异步释放对象占用的内存。

其主线程复杂度为每个键 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-lruallkeys-lfu,TTL 抖动
搜索结果、推荐结果缓存适合大量短生命周期键最终一致键数量爆炸、过期扫描压力较短 TTL、随机抖动、限制单值大小
登录会话、刷新令牌有条件适合中等较高淘汰导致用户异常退出独立实例、容量预留,通常避免 allkeys-*
分布式锁不适合作为可淘汰缓存较小很高键被淘汰造成互斥失效独立实例、noeviction,并完善锁语义
限流计数器有条件适合高基数较高淘汰后限流失效独立实例、明确 TTL、容量估算
永久配置与临时缓存混放不推荐任意混合volatile-* 候选耗尽后 OOM拆分实例,分别制定策略
超大报表或大型集合通常不适合单键数十 MB 以上视业务而定BigKey 阻塞、复制和迁移开销对象存储、数据库分页、拆分键
TTL 能表达业务价值的数据适合中等最终一致TTL 设计不合理导致误淘汰可评估 volatile-ttl
读多写少、修改时间决定新鲜度适合 Redis 8.6+大规模缓存最终一致旧但频繁读取的数据仍可能被淘汰评估 allkeys-lrm

6. 底层实现

6.1 主键空间与过期索引

Redis 需要同时回答两个问题:

  1. 某个键对应什么值;
  2. 某个键是否设置了过期时间,以及何时过期。

概念上,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 前,应先检查:

  1. expired_stale_perc 是否持续偏高;
  2. expired_time_cap_reached_count 是否快速增长;
  3. CPU 是否有余量;
  4. 是否存在大量相同 TTL;
  5. 是否可以先通过 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)

高可用环境还应关注:

  1. 副本内存可能高于主节点 官方示例配置中,副本默认可忽略 maxmemory,以避免复制过程中因本地淘汰造成数据集偏离。必须为副本预留足够内存。 (GitHub)

  2. 复制和 AOF 缓冲会占用额外内存 这部分内存可能不计入淘汰阈值,却会计入实际 RSS。

  3. 故障转移后的策略一致性 各候选主节点应配置一致的 maxmemory 和淘汰策略,否则故障转移后行为会改变。

  4. Cluster 必须逐主节点评估容量 整个集群平均使用率正常,不代表某个哈希槽集中的主节点没有逼近上限。

  5. 热点和 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-lruallkeys-lfu
  • 将永久数据和缓存拆到不同实例。

预防

上线前明确每类键的生命周期,并通过扫描或指标监控无 TTL 键比例。


8.3 maxmemory 等于容器上限导致 OOMKilled

现象

Redis 没有明显 OOM 响应,Pod 却突然重启,状态显示 OOMKilled

根因

maxmemory 只约束逻辑数据集。碎片、复制缓冲、AOF、客户端缓冲和写时复制使 RSS 超出容器限制。

排查

  • 查看 Pod 退出原因;
  • 对比 used_memoryused_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_keysevicted_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-lruallkeys-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_bytesused_memory_peakallocator_frag_ratioallocator_rss_ratio 和数据集绝对规模。

小实例中很高的比率可能只是固定进程开销;RSS 远高于 used_memory 可能是碎片或历史峰值;used_memory 高于 RSS 则需要警惕 Swap。 (Redis)

面试官追问

  • 为什么删除数据后 RSS 不下降?
  • 主动碎片整理有什么代价?
  • 如何区分碎片和内存泄漏?

常见错误回答

“碎片率超过 1 就说明 Redis 内存泄漏。”

评分点

  • 初级:会比较 used memory 与 RSS;
  • 中级:会看绝对字节数和峰值;
  • 高级:能分析 allocator active、resident 及滚动重启策略。

推荐回答

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 根据 noevictionallkeys-*volatile-* 决定拒绝写入还是淘汰。LRU 和 LFU 都是近似算法,使用采样和候选池控制 CPU 与空间成本,LFU 还包含概率计数和衰减。maxmemory 不是 RSS 硬上限,碎片、AOF、复制缓冲和写时复制仍可能导致 OOMKill。删除 BigKey 应优先使用 UNLINK,但需监控 lazyfree 积压。Go 应用侧应使用 Context 超时、正确处理 redis.Nil、为 TTL 添加抖动,并按节点实施容量保护。


12. 本章小结

  1. 过期与淘汰不是同一个机制:TTL 决定数据寿命,maxmemory 决定容量不足时的处理方式。
  2. maxmemory 不是 RSS 硬上限:必须为碎片、缓冲区和后台任务预留物理内存。
  3. volatile 策略只淘汰 TTL 键:候选耗尽后仍可能出现 OOM。
  4. Redis LRU/LFU 都是近似算法:通过采样、候选池和紧凑元数据换取高吞吐。
  5. 主动过期需要平衡 CPU 和过期残留:TTL 抖动通常比盲目提高扫描强度更有效。
  6. RSS 不下降不等于内存泄漏:分配器可能保留已释放页面供后续复用。
  7. BigKey 的风险包含释放成本UNLINK 可以降低主线程阻塞,但不能消除后台工作量。
  8. Redis OOM、OOMKill 和 Swap 是不同故障:排查路径和解决方案不能混用。
  9. Go 侧容量保护只能作为前置防线:不能替代 Redis 配置和完整容量规划。

13. 自测清单

  1. maxmemoryused_memory_rss 为什么可能相差很大?
  2. 使用 volatile-lfu 时,没有 TTL 的键会发生什么?
  3. Redis 为什么不维护精确的全局 LRU 链表?
  4. LFU 的概率计数和衰减分别解决什么问题?
  5. 被动过期为什么不能单独完成全部过期清理?
  6. expired_time_cap_reached_count 持续增长说明什么?
  7. 为什么执行 UNLINK 后 RSS 可能仍然不下降?
  8. 如何区分 Redis 返回的 OOM 与容器 OOMKill?
  9. Go 应用中的 TTL 抖动主要防止什么故障?
  10. 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.cexpire.c 源码。 (GitHub)
  • go-redis 官方使用、错误处理及并发安全说明。 (Redis)