Redis内存模型介绍
1. Redis 内存模型到底在描述什么
从源码视角看,Redis 内存模型至少包含四层:
- 对象层:每个值都先被包装成
redisObject,记录类型、编码方式、访问信息和实际数据指针。 - 数据库层:每个逻辑库由
redisDb表示,内部至少维护keyspace和expires两张字典。 - 编码层:同一种数据类型会根据元素规模和内容选择不同底层结构,例如
int、embstr、raw、listpack、quicklist、dict、skiplist。 - 分配回收层:底层通常通过
jemalloc向操作系统申请内存,再由Redis自己做引用计数、惰性删除、异步释放和淘汰控制。
一句话概括:Redis 内存模型的核心,就是“对象包装 + 字典组织 + 紧凑编码 + 分层回收”。
2. 从一个 Key 看 Redis 是怎么存进内存的
2.1 顶层入口是 redisObject
Redis 并不是把值直接塞进哈希表,而是先封装成对象。源码里典型定义如下:
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // LRU 或 LFU 相关信息
int refcount;
void *ptr; // 指向真实底层结构
} robj;
这里面最重要的是四个字段:
type:逻辑类型,例如string、list、hash、set、zset。encoding:底层怎么存,例如int、embstr、raw、listpack、hashtable。lru:不是单纯时间戳。开启LFU时,这里会复用一部分位来记录访问频率和衰减信息。ptr:真正指向底层数据结构。
因此,同样都是字符串类型,内存里可能完全不是同一种形态。type 决定语义,encoding 决定内存布局和性能。
2.2 Key 自己也是对象,不只是 Value 才占内存
很多人估算 Redis 内存时,只按业务数据长度计算,这是错误的。一个键值对通常至少包含:
- 键对象本身;
- 键字符串内容,一般是
SDS; - 值对象本身;
- 值的底层编码结构;
- 哈希表节点
dictEntry; - 如果设置了过期时间,还要在
expires字典中再存一份键到过期时间的映射。
所以业务上看起来只有几十字节的数据,实际在内存中可能是其数倍。小 Key 很多时,元数据开销往往比数据本身更大。
2.3 字符串底层一般不是 C 原生字符串,而是 SDS
Redis 大量场景下使用 SDS(Simple Dynamic String)来保存字符串:
struct sdshdr8 {
uint8_t len; // 已使用长度
uint8_t alloc; // 总容量
unsigned char flags;
char buf[];
};
SDS 的价值在于:
O(1)获取长度:直接读len,不需要像strlen一样扫到\0。- 二进制安全:可以存
\0,适合图片片段、序列化结果、压缩数据。 - 减少重分配次数:扩容时会做预分配,避免每次追加都
realloc。 - 避免缓冲区溢出:修改前会先检查容量。
这也是为什么 Redis 既能高效处理文本,又能安全处理二进制内容。
3. 数据库级内存组织:redisDb 如何管理所有 Key
3.1 一个逻辑库本质上维护两张核心字典
Redis 的每个逻辑数据库通常都对应一个 redisDb 结构,关键成员可以抽象成:
typedef struct redisDb {
dict *dict; // key -> value
dict *expires; // key -> expire time
dict *blocking_keys;
dict *watched_keys;
// 其他元数据省略
} redisDb;
其中最重要的是两张表:
| 字典 | 作用 | 说明 |
|---|---|---|
dict |
保存真正的键值对 | key -> redisObject value |
expires |
保存过期时间 | key -> 过期毫秒时间戳 |
注意两个点:
- 过期时间不是挂在 value 里,而是单独存一张表。这样没有设置过期时间的键就不会额外占这一部分空间。
- 删除一个过期键时,通常要同时从两张表里删。
3.2 字典底层是哈希表,冲突通过链式结构解决
Redis 的字典节点通常可以理解为:
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 哈希冲突时挂链
} dictEntry;
这意味着一个普通键值对,不只是“字符串 + 字符串”,至少还有:
- 桶数组;
dictEntry节点;- 键对象与值对象;
- 指针和对齐带来的额外空间。
因此,Redis 适合高性能缓存,但并不适合存储超海量、超细粒度的小对象碎片。
3.3 Rehash 是渐进式的,避免一次性搬迁阻塞主线程
字典扩容或缩容时,Redis 不会停下来一次性把所有键迁走,而是使用 渐进式 rehash:
- 先申请新的哈希表
ht[1]。 - 保留旧表
ht[0]。 - 后续每次增删改查时,顺带搬迁一部分桶。
- 全部搬完后,再让新表替换旧表。
这样做的本质,是把一次 O(N) 的大停顿拆成很多次很小的搬迁成本。Redis 的“快”,不仅来自内存,也来自它对阻塞点的刻意拆分。
4. 同一种数据类型,为什么内存布局会变化
4.1 Redis 追求的不是固定结构,而是按规模自适应编码
Redis 很典型的设计思路是:小数据优先紧凑存储,大数据再切换到通用结构。这样做是为了同时兼顾:
- 小对象的低内存占用;
- 大对象的读写性能;
- 编码切换时的总体性价比。
4.2 常见数据类型编码对比
| 类型 | 小数据编码 | 大数据编码 | 说明 |
|---|---|---|---|
String |
int、embstr |
raw |
小整数直接内嵌,短字符串常用 embstr |
List |
quicklist + listpack |
quicklist + listpack |
现代版本统一走 quicklist,节点内部是 listpack |
Hash |
listpack |
hashtable |
小 Hash 用连续内存更省 |
Set |
intset |
hashtable |
全是整数且元素少时用 intset |
ZSet |
listpack |
skiplist + hashtable |
范围查找靠跳表,点查靠字典 |
这里要注意版本差异:
- 早期版本里很多小集合使用
ziplist。 - 较新的版本里,
ziplist已逐步被listpack替代,因为listpack在连锁更新、空间表达和实现复杂度上更合理。
4.3 String 的三种典型编码最容易被问到
4.3.1 int
如果值可以表示为 64 位有符号整数,Redis 会直接按整数保存,而不是额外分配一段字符串内存。
优点是:
- 节省内存;
- 自增自减类命令实现高效;
- 避免频繁字符串解析。
4.3.2 embstr
短字符串通常使用 embstr。它的关键特征是:redisObject 和 SDS 分配在一块连续内存里。
这样做有两个直接收益:
- 只需要一次内存分配和一次释放;
- 连续内存更容易命中
CPU Cache。
所以短字符串非常常见时,embstr 会明显降低管理成本。
4.3.3 raw
长字符串会使用 raw 编码,此时对象头和字符串内容分开分配。它更灵活,但也意味着更多指针跳转和更高一点的元数据开销。
4.4 为什么 ZSet 要同时用跳表和字典
ZSet 的大对象编码通常是 skiplist + hashtable 组合,而不是二选一:
hashtable:适合按成员名直接查分数,平均O(1)。skiplist:适合按分数排序、范围查询、排名查询,平均O(logN)。
这是一种典型的“以空间换查询模型完整性”的设计。如果只保留字典,就很难高效做范围查找;如果只保留跳表,按成员精确定位又不够快。
5. Redis 的内存是怎么申请和释放的
5.1 Redis 自己不直接管理物理页,通常依赖 jemalloc
Redis 大多数发行版本默认使用 jemalloc 作为内存分配器,而不是完全自己实现一套堆管理。原因很现实:
- 通用分配器已经针对小对象、多尺寸分配、线程缓存和碎片控制做了大量优化;
Redis内部对象很多,尺寸差异明显,适合交给成熟分配器处理;- 更容易在吞吐和碎片率之间取得平衡。
但这也带来一个现象:used_memory 降了,不代表进程的 RSS 会立刻同步下降。因为分配器可能只是把内存留在自己的 arena 中复用,没有马上还给操作系统。
5.2 释放内存不等于立刻把内存还给操作系统
在 Redis 里,删除一个键通常经历三层含义:
- 从
Redis数据结构上解除引用; - 把对象交给分配器释放;
- 分配器再决定是否把物理内存归还给操作系统。
所以线上常见现象是:
- 执行了
DEL; used_memory下降;- 但进程
RSS看起来没怎么掉。
这不一定是内存泄漏,更可能是 内存碎片或分配器缓存。
5.3 大 Key 删除为什么可能卡顿
如果一个键很大,例如:
- 包含几十万字段的
Hash; - 很长的
List; - 很大的
ZSet;
那么同步删除时,主线程要把底层结构一层层释放掉,这个过程本身就可能很重。
因此 Redis 提供了异步释放思路,例如:
- 使用
UNLINK,先把键从主字典摘掉,再把真正释放动作交给后台线程; - 开启
lazyfree-lazy-user-del、lazyfree-lazy-eviction等配置,让部分删除或淘汰走惰性异步回收。
这类设计的重点是:主线程先恢复可响应,再慢慢做真正的 free。
6. 过期、淘汰、删除,看起来都在释放内存,实际不是一回事
6.1 过期删除是时间维度的问题
过期键的处理核心是“它逻辑上已经无效”,主要有两种触发方式:
- 惰性删除:访问某个键时,先检查是否过期,过期就删除。
- 定期删除:后台周期性随机扫描一批设置了过期时间的键,删除其中已过期的部分。
惰性删除节省 CPU,但可能让过期键在内存里多待一段时间;定期删除能兜底,但不能一次扫完整个库,否则会阻塞。
6.2 内存淘汰是容量维度的问题
淘汰发生在设置了 maxmemory 且内存达到上限时,本质是在问:为了给新写入腾空间,应该牺牲谁。
常见策略包括:
allkeys-lruallkeys-lfuallkeys-randomvolatile-lruvolatile-lfuvolatile-ttl
所以要分清:
- 过期删除:键到了失效时间,理论上应该被清走。
- 淘汰策略:内存不够时,主动挑一些键牺牲掉。
- 显式删除:业务自己执行
DEL或UNLINK。
6.3 过期和淘汰都会影响内存,但判断依据完全不同
| 机制 | 触发条件 | 决策依据 | 目标 |
|---|---|---|---|
| 过期删除 | 到达过期时间 | TTL 是否已到 | 清理逻辑无效数据 |
| 内存淘汰 | 超过 maxmemory |
LRU、LFU、TTL 等策略 | 给写入腾空间 |
| 显式删除 | 业务主动执行命令 | 应用逻辑 | 立即移除数据 |
这三个概念混在一起回答,是面试里非常常见的失分点。
7. 为什么业务数据不大,Redis 内存却很高
7.1 元数据开销经常被低估
一个键值对除了业务内容,还有大量附属成本:
- 对象头;
SDS头;dictEntry;- 哈希桶;
- 过期字典;
- 对齐填充;
- 分配器内部管理开销。
所以存很多很短的小字符串,内存利用率可能并不高。
7.2 内存碎片会让 RSS 明显大于 used_memory
常见指标里:
used_memory:Redis视角下已分配的逻辑内存;used_memory_rss:进程实际驻留物理内存;mem_fragmentation_ratio:两者比值。
如果 mem_fragmentation_ratio 明显偏高,常见原因有:
- 频繁增删不同尺寸对象;
- 大量短生命周期对象;
- 后台持久化期间的写时复制;
- 分配器 arena 内部出现难以复用的小碎块。
7.3 持久化会放大瞬时内存压力
Redis 执行 BGSAVE 或 BGREWRITEAOF 时会 fork 子进程。fork 本身不是把整份数据立刻复制一遍,而是走 写时复制(Copy-On-Write,COW)。
但如果这期间主进程持续修改大量页面,就会触发页面复制,导致瞬时内存上升。因此线上评估内存容量时,不能只盯着平时稳态数据量,还要给 fork + COW 预留余量。
这也是 Redis 和很多纯缓存中间件在容量规划上最大的差异之一。
8. 如何理解 Redis 内存模型和 Memcached 的差异
Redis 和 Memcached 都是内存型系统,但内存组织思路不同:
| 维度 | Redis |
Memcached |
|---|---|---|
| 数据模型 | 丰富,支持多种结构 | 主要是简单 key-value |
| 值编码 | 同一类型可多种编码切换 | 更偏固定化分配 |
| 元数据 | 较多,对象头和结构层次丰富 | 相对更轻 |
| 内存管理思路 | 对象模型 + 分配器 + 惰性回收 | slab 分配更典型 |
| 适用场景 | 缓存、计数器、排行榜、分布式协作 | 高吞吐简单缓存 |
所以:
- 如果场景只是海量简单字符串缓存,
Memcached的内存模型更轻; - 如果需要
Hash、Set、ZSet、事务、Lua、持久化、复制、过期语义,Redis的对象模型更强。
这属于典型的 能力更强,元数据成本也更高。
9. 线上排查 Redis 内存问题时看什么
9.1 先看整体指标
常用命令:
INFO memory
MEMORY STATS
INFO stats
重点关注:
used_memoryused_memory_rssmem_fragmentation_ratiomaxmemoryevicted_keysexpired_keys
9.2 再看是不是大 Key 或编码不合理
可以使用:
redis-cli --bigkeys
redis-cli --memkeys
MEMORY USAGE user:1
OBJECT ENCODING user:1
排查重点:
- 是否存在超大
Hash、List、ZSet; - 小对象是否因为字段太多或字符串过长,已经从紧凑编码升级为通用结构;
- 是否有大量本该过期但仍堆积的数据;
- 是否有热写导致
fork期间COW明显放大内存。
9.3 优化方向通常有四类
- 减少键数量:能合并就合并,降低对象头和字典节点开销。
- 缩短键名和字段名:业务数据没变,但元数据立刻变少。
- 避免大 Key:拆分超大集合,降低删除阻塞和
COW放大。 - 合理设置过期与淘汰策略:避免无效数据长期占内存。
10. 面试中的精简回答
如果面试官问“你怎么理解 Redis 内存模型”,可以直接这样回答:
Redis 的内存模型可以分成四层来看。第一层是对象层,所有值都会被封装成 redisObject,里面记录类型、编码和底层指针;第二层是数据库层,一个 redisDb 至少有 keyspace 和 expires 两张字典,分别保存键值和过期时间;第三层是编码层,同一种数据类型会根据数据规模选择 int、embstr、raw、listpack、quicklist、dict、skiplist 等不同结构,以平衡内存和性能;第四层是分配回收层,底层通常依赖 jemalloc,并结合惰性删除、异步释放、过期删除和淘汰策略来控制内存。`
所以 Redis 内存占用高,不只是因为数据本身,还包括对象头、哈希表节点、过期字典、分配器碎片,以及持久化 fork 带来的 COW 开销。这也是为什么线上分析 Redis 内存问题时,不能只看 value 大小。