Skip to content

Redis内存模型介绍

1. Redis 内存模型到底在描述什么

从源码视角看,Redis 内存模型至少包含四层:

  • 对象层:每个值都先被包装成 redisObject,记录类型、编码方式、访问信息和实际数据指针。
  • 数据库层:每个逻辑库由 redisDb 表示,内部至少维护 keyspaceexpires 两张字典。
  • 编码层:同一种数据类型会根据元素规模和内容选择不同底层结构,例如 intembstrrawlistpackquicklistdictskiplist
  • 分配回收层:底层通常通过 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:逻辑类型,例如 stringlisthashsetzset
  • encoding:底层怎么存,例如 intembstrrawlistpackhashtable
  • 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

  1. 先申请新的哈希表 ht[1]
  2. 保留旧表 ht[0]
  3. 后续每次增删改查时,顺带搬迁一部分桶。
  4. 全部搬完后,再让新表替换旧表。

这样做的本质,是把一次 O(N) 的大停顿拆成很多次很小的搬迁成本。Redis 的“快”,不仅来自内存,也来自它对阻塞点的刻意拆分。

4. 同一种数据类型,为什么内存布局会变化

4.1 Redis 追求的不是固定结构,而是按规模自适应编码

Redis 很典型的设计思路是:小数据优先紧凑存储,大数据再切换到通用结构。这样做是为了同时兼顾:

  • 小对象的低内存占用;
  • 大对象的读写性能;
  • 编码切换时的总体性价比。

4.2 常见数据类型编码对比

类型 小数据编码 大数据编码 说明
String intembstr 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。它的关键特征是:redisObjectSDS 分配在一块连续内存里

这样做有两个直接收益:

  • 只需要一次内存分配和一次释放;
  • 连续内存更容易命中 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 里,删除一个键通常经历三层含义:

  1. Redis 数据结构上解除引用;
  2. 把对象交给分配器释放;
  3. 分配器再决定是否把物理内存归还给操作系统。

所以线上常见现象是:

  • 执行了 DEL
  • used_memory 下降;
  • 但进程 RSS 看起来没怎么掉。

这不一定是内存泄漏,更可能是 内存碎片或分配器缓存

5.3 大 Key 删除为什么可能卡顿

如果一个键很大,例如:

  • 包含几十万字段的 Hash
  • 很长的 List
  • 很大的 ZSet

那么同步删除时,主线程要把底层结构一层层释放掉,这个过程本身就可能很重。

因此 Redis 提供了异步释放思路,例如:

  • 使用 UNLINK,先把键从主字典摘掉,再把真正释放动作交给后台线程;
  • 开启 lazyfree-lazy-user-dellazyfree-lazy-eviction 等配置,让部分删除或淘汰走惰性异步回收。

这类设计的重点是:主线程先恢复可响应,再慢慢做真正的 free

6. 过期、淘汰、删除,看起来都在释放内存,实际不是一回事

6.1 过期删除是时间维度的问题

过期键的处理核心是“它逻辑上已经无效”,主要有两种触发方式:

  • 惰性删除:访问某个键时,先检查是否过期,过期就删除。
  • 定期删除:后台周期性随机扫描一批设置了过期时间的键,删除其中已过期的部分。

惰性删除节省 CPU,但可能让过期键在内存里多待一段时间;定期删除能兜底,但不能一次扫完整个库,否则会阻塞。

6.2 内存淘汰是容量维度的问题

淘汰发生在设置了 maxmemory 且内存达到上限时,本质是在问:为了给新写入腾空间,应该牺牲谁

常见策略包括:

  • allkeys-lru
  • allkeys-lfu
  • allkeys-random
  • volatile-lru
  • volatile-lfu
  • volatile-ttl

所以要分清:

  • 过期删除:键到了失效时间,理论上应该被清走。
  • 淘汰策略:内存不够时,主动挑一些键牺牲掉。
  • 显式删除:业务自己执行 DELUNLINK

6.3 过期和淘汰都会影响内存,但判断依据完全不同

机制 触发条件 决策依据 目标
过期删除 到达过期时间 TTL 是否已到 清理逻辑无效数据
内存淘汰 超过 maxmemory LRU、LFU、TTL 等策略 给写入腾空间
显式删除 业务主动执行命令 应用逻辑 立即移除数据

这三个概念混在一起回答,是面试里非常常见的失分点。

7. 为什么业务数据不大,Redis 内存却很高

7.1 元数据开销经常被低估

一个键值对除了业务内容,还有大量附属成本:

  • 对象头;
  • SDS 头;
  • dictEntry
  • 哈希桶;
  • 过期字典;
  • 对齐填充;
  • 分配器内部管理开销。

所以存很多很短的小字符串,内存利用率可能并不高。

7.2 内存碎片会让 RSS 明显大于 used_memory

常见指标里:

  • used_memoryRedis 视角下已分配的逻辑内存;
  • used_memory_rss:进程实际驻留物理内存;
  • mem_fragmentation_ratio:两者比值。

如果 mem_fragmentation_ratio 明显偏高,常见原因有:

  • 频繁增删不同尺寸对象;
  • 大量短生命周期对象;
  • 后台持久化期间的写时复制;
  • 分配器 arena 内部出现难以复用的小碎块。

7.3 持久化会放大瞬时内存压力

Redis 执行 BGSAVEBGREWRITEAOF 时会 fork 子进程。fork 本身不是把整份数据立刻复制一遍,而是走 写时复制(Copy-On-Write,COW)

但如果这期间主进程持续修改大量页面,就会触发页面复制,导致瞬时内存上升。因此线上评估内存容量时,不能只盯着平时稳态数据量,还要给 fork + COW 预留余量。

这也是 Redis 和很多纯缓存中间件在容量规划上最大的差异之一。

8. 如何理解 Redis 内存模型和 Memcached 的差异

RedisMemcached 都是内存型系统,但内存组织思路不同:

维度 Redis Memcached
数据模型 丰富,支持多种结构 主要是简单 key-value
值编码 同一类型可多种编码切换 更偏固定化分配
元数据 较多,对象头和结构层次丰富 相对更轻
内存管理思路 对象模型 + 分配器 + 惰性回收 slab 分配更典型
适用场景 缓存、计数器、排行榜、分布式协作 高吞吐简单缓存

所以:

  • 如果场景只是海量简单字符串缓存,Memcached 的内存模型更轻;
  • 如果需要 HashSetZSet、事务、Lua、持久化、复制、过期语义,Redis 的对象模型更强。

这属于典型的 能力更强,元数据成本也更高

9. 线上排查 Redis 内存问题时看什么

9.1 先看整体指标

常用命令:

INFO memory
MEMORY STATS
INFO stats

重点关注:

  • used_memory
  • used_memory_rss
  • mem_fragmentation_ratio
  • maxmemory
  • evicted_keys
  • expired_keys

9.2 再看是不是大 Key 或编码不合理

可以使用:

redis-cli --bigkeys
redis-cli --memkeys
MEMORY USAGE user:1
OBJECT ENCODING user:1

排查重点:

  • 是否存在超大 HashListZSet
  • 小对象是否因为字段太多或字符串过长,已经从紧凑编码升级为通用结构;
  • 是否有大量本该过期但仍堆积的数据;
  • 是否有热写导致 fork 期间 COW 明显放大内存。

9.3 优化方向通常有四类

  1. 减少键数量:能合并就合并,降低对象头和字典节点开销。
  2. 缩短键名和字段名:业务数据没变,但元数据立刻变少。
  3. 避免大 Key:拆分超大集合,降低删除阻塞和 COW 放大。
  4. 合理设置过期与淘汰策略:避免无效数据长期占内存。

10. 面试中的精简回答

如果面试官问“你怎么理解 Redis 内存模型”,可以直接这样回答:

Redis 的内存模型可以分成四层来看。第一层是对象层,所有值都会被封装成 redisObject,里面记录类型、编码和底层指针;第二层是数据库层,一个 redisDb 至少有 keyspaceexpires 两张字典,分别保存键值和过期时间;第三层是编码层,同一种数据类型会根据数据规模选择 intembstrrawlistpackquicklistdictskiplist 等不同结构,以平衡内存和性能;第四层是分配回收层,底层通常依赖 jemalloc,并结合惰性删除、异步释放、过期删除和淘汰策略来控制内存。`

所以 Redis 内存占用高,不只是因为数据本身,还包括对象头、哈希表节点、过期字典、分配器碎片,以及持久化 fork 带来的 COW 开销。这也是为什么线上分析 Redis 内存问题时,不能只看 value 大小。