如何正确预估redis写入容量

全局视角

我们先以一个全局的视角看看 redis 的数据是怎么存放的:

redisDb (数据库)
  └── dict (全局字典)
       └── ht[0] (哈希表数组)
            └── [Bucket] ──> dictEntry (节点)
                               ├── key: [ SDS ("mykey") ]
                               └── val: [ redisObject ]
                                            ├── type: REDIS_STRING
                                            ├── encoding: EMBSTR (或 RAW)
                                            └── ptr ──> [ SDS ("hello") ]

Redis 的顶层存储核心是用全局字典(Global Dict,也叫 Keyspace)来管理所有的数据,Dict 采用的是双哈希表结构来保存数据主要是用来做渐进式 rehash,双哈希表结构用ht[0]ht[1]来表示,通常数据只在 ht[0] 中,当哈希表需要扩容或缩容时,Redis 会一边处理请求,一边分批将数据从 ht[0] 迁移到 ht[1]

哈希表其实就是一张大 bucket 数组,每个 bucket 是 dictEntry,由 dictht 数据结构来进行管理:

typedef struct dictht {
    // 哈希表的槽
    dictEntry **table;
    // 哈希表槽个数,是2的整数次幂
    unsigned long size;
    // size-1,计算出一个key的hash后,直接 hash & sizemask即可算出所属的槽
    unsigned long sizemask;
    // 已使用大小
    unsigned long used;
} dictht;

在全局字典中,每一个键值对都被封装在一个 dictEntry 结构体中:

  • Key(键):始终是一个指向 SDS (Simple Dynamic String) 结构的指针。即使你设置的是数字键,Redis 也会将其转为字符串 SDS 存储,SDS 结构下面我们会说;
  • Value(值):始终是一个 redisObject 结构体(或其指针)。redisObject 就像一个通用容器,它封装了所有 Redis 数据类型(String, List, Hash 等)。

redisObject

在 Redis 中使用 redisObject 统一来管理底层的数据结构,无论底层是SDSziplistdict统一用 redisObject 来进行封装,然后通过 type 来进行标识。

在 Redis 的 C 语言源码中,它的定义如下(以 64 位系统为例):

字段名 占用空间 作用说明
type 4 bits 逻辑类型:标识它是 String、List、Hash、Set 还是 ZSet。
encoding 4 bits 物理编码:标识底层具体是用什么实现的(如 ziplist、skiplist、int 等)。
lru / lfu 24 bits 对象热度:记录最后一次被访问的时间(LRU)或访问频率(LFU),用于内存淘汰。
refcount 4 bytes 引用计数:记录有多少地方引用了这个对象。为 0 时对象被销毁。
ptr 8 bytes 数据指针:指向底层真实数据的内存地址。

合计算下来,一个 redisObject 固定占用 16 字节。

这样做就是统一了接口,当你执行 DEL 命令时,Redis 不需要关心你删的是 String 还是 List,它只需要操作 redisObject 这个通用结构。

除此之外它有三大作用:

  1. 类型检查与多态

    当你输入 LPOP key 时,Redis 会先检查这个 redisObjecttype 是不是 REDIS_LIST。如果不是,直接返回错误。如果是,它会根据 encoding 字段去调用对应的函数(比如是从 linkedlist 弹出还是从 listpack 弹出)。

  2. 内存管理与共享

    通过 refcount 的引用计数来控制内存的释放,当引用计数归零,Redis 才会真正释放内存。

  3. 内存淘汰(LRU/LFU 算法)

    LRU 模式就会通过时间戳来看该对象是否应该被淘汰。LFU 模式它根据数据被访问的频率来决定淘汰对象,高 16 位存时间,低 8 位存访问计数。 如果这个字段很久没更新,当 Redis 内存不足时,它就会优先被“踢出”内存。

虽然在全局字典看来,所有的 Value 都是一个 redisObject,但 redisObject 内部通过 typeptr 指向了完全不同的底层世界:

命令示例 redisObject -> type redisObject -> ptr 指向的内容
SET key "val" REDIS_STRING 指向一个 SDS(可能是 int, embstr 或 raw)
HSET user:1 name "A" REDIS_HASH 指向一个 Dictlistpack/ziplist
LPUSH list "item" REDIS_LIST 指向一个 quicklist(由多个 listpack 组成的双端链表)
SADD tags "java" REDIS_SET 指向一个 Dict (value 为 NULL) 或 intset
ZADD rank 100 "A" REDIS_ZSET 指向一个 zset 结构(内含 Skiplist + Dict

String

Redis 设计了简单动态字符串(Simple Dynamic String,SDS)的结构,用来表示字符串,动态字符串结构如下图所示:

sds

SDS 大致由两部分构成:header以及 数据段,其中 header 还包含3个字段 len、alloc、flags。len 表示数据长度,alloc 表示分配的内存长度,flags 表示了 sds 的数据类型。

在以前的版本中,sds 的header其实占用内存是固定8字节大小的,所以如果在redis中存放的都是小字符串,那么 sds 的 header 将会占用很多的内存空间。

但是随着 sds 的版本变迁,其实在内存占用方面还是做了一些优化:

  1. 在 sds 2.0 之前 header 的大小是固定的 int 类型,2.0 版本之后会根据传入的字符大小调整 header 的 len 和 alloc 的类型以便节省内存占用。
  2. header 的结构体使用 __attribute__ 修饰,这里主要是防止编译器自动进行内存对齐,这样可以减少编译器因为内存对齐而引起的 padding 的数量所占用的内存。

目前的版本中共定义了五种类型的 sds header,其中 sdshdr5 是没用的,所以没画:

sds_header

当执行 SET key value 时,对于 key 来说存放方式就是:

DictEntry
   │
   └── key (指针) 
        │
        ▼ 
  ┌──────────────────────────────────────────────────────────────┐
  │ [ SDS Header ] [           SDS Body (buf)           ] [ \0 ] │
  └──────────────────────────────────────────────────────────────┘
  ▲              ▲                                      ▲
  │              │                                      │
  │              │                                      └── 结尾 (1 byte)
  │              │
  │              └── 你的 1MB 甚至 512MB 的数据
  │
  └── 这里的元数据结构会根据大小变化
      (sdshdr8 -> sdshdr16 -> ... -> sdshdr64)

对于 value 来说,Redis 会根据 value 的情况选择以下三者之一:

  1. int 编码
  • 适用场景:如果字符串内容可以转为 long 类型的整数
  • 实现方式:直接将整数值存在 redisObjectptr 指针位置(指针 8 字节,正好存下一个 long)。
  • 优点零额外内存分配。不需要 SDS,不需要额外的内存块。
  1. embstr 编码
  • 适用场景:长度小于等于 44 字节 的字符串。
  • 实现方式redisObject 结构体与 SDS 结构体在内存中是连续的一块空间。
  • 优点
    • 只需一次内存分配/释放。
    • 利用 CPU 缓存局部性(连续内存读取更快)。
  • 阈值由来:16 字节 (robj) + 3 字节 (sdshdr8) + 44 字节 (data) + 1 字节 (\0) = 64 字节。这正好是常见的 CPU Cache Line 大小。
  1. raw 编码
  • 适用场景:长度大于 44 字节 的字符串。
  • 实现方式redisObjectSDS 是两块独立的内存区域,通过指针连接。
  • 优点:适合大字符串,扩容时不需要重新分配整个 redisObject

image-20260213152933556

所以我们可以看到 key 和 value 其实是分两部分存储:

  • Value (值):可能会因为 RAW 编码 而导致 redisObjectSDS 分离(不挨着)。

  • Key (键):永远没有 redisObject 包装,它直接就是一个 SDS。所以 Key 的 Header 和数据永远是连在一起的,没有任何例外。

容量估算

jemalloc

在估算容量之前,我们来看看 redis 使用的 jemalloc 是怎么做内存分配的。

jemalloc 预先定义了一系列固定的内存块大小(称为 Size Class)。当 Redis 请求分配 N 字节时,jemalloc 会查找第一个大于等于 N 的规格(Size Class)内存块进行分配。

为了减少浪费,jemalloc 的规格设计得很科学,并不是单纯的 2 的幂次方(2, 4, 8, 16…),而是更加细密:

规格区间 具体的 Size Class (字节)
8B – 128B 8, 16, 32, 48, 64, 80, 96, 112, 128
128B – … 160, 192, 224, 256, 320 …

举个具体的例子:

假设你在 Redis 里存一个简单的字符串,算上 SDS 头部等开销,Redis 向系统申请了 20 字节

  1. 查找:jemalloc 看了看手里的规格表:8, 16, 32…
  2. 判定:16 字节装不下 20 字节。
  3. 取整:下一个规格是 32 字节
  4. 分配:给 Redis 分配 32 字节 的内存块。

结果

  • 实际使用:20 字节。
  • 实际占用:32 字节。
  • 浪费:12 字节(这被称为内部碎片)。

为什么要这样做?(好处)

虽然看起来浪费了一点点空间(内部碎片),但对整个系统来说,收益巨大:

  1. 速度极快: 不需要每次都去计算哪里有空闲内存。jemalloc 维护了许多“桶”(Bin),比如“32字节桶”、“64字节桶”。要 20 字节?直接从“32字节桶”里拿一个出来就行,O(1) 复杂度。
  2. 减少外部碎片: 当你释放这 32 字节后,它会干干净净地回到“32字节桶”里。下一个申请 17~32 字节的请求来了,可以直接复用这块内存,严丝合缝。
  3. 缓存友好: 数据按照固定大小排列,更容易被 CPU 缓存(Cache Line)命中。

以 string 为例估算分析

所以根据我们上面的介绍,应该知道一个 String 键值对的总内存占用主要由三部分组成:

image-20260213154831600

  1. 全局字典节点 (dictEntry):固定 24 字节

    • 包含三个指针(Key 指针、Value 指针、Next 指针),各占 8 字节。
  2. 键 (Key):SDS 结构

    • 包含:SDS Header + Data + 1 (\0)
    • 需要注意的是 redis 使用的是 jemalloc 来做内存分配,jemalloc 会将结果向上取整到最近的分配阶梯(如 8, 16, 32, 48, 64 字节)
  3. 值 (Value):取决于编码方式,上面我们有介绍,就不细说了 int、embstr、raw 编码;

    编码方式 计算公式 说明
    INT 16 字节 只有 redisObject,数值直接存在指针里。
    EMBSTR $malloc(16 + 3 + len(Val) + 1)$ redisObject 与 SDS 连续分配,整体向上取整。
    RAW $16 + malloc(3 + len(Val) + 1)$ redisObject 与 SDS 分开分配,各自取整后再求和。

实例估算:存储 SET "key" "value"

我们来算一下这个极小键值对实际占了多少地儿:

  1. dictEntry: 24 字节
  2. Key ("key"):
    • 长度为 3,计算:3(Header) + 3(Data) + 1(\0) = 7 字节。
    • jemalloc 向上取整为 8 字节
  3. Value ("value"):
    • 长度为 5,采用 EMBSTR 编码。
    • 计算:16(robj) + 3(Header) + 5(Data) + 1(\0) = 25 字节。
    • jemalloc 向上取整为 32 字节
  4. 总计:24 + 8 + 32 = 64 字节。

所以我们可以看到个有趣的事实,存储 8 字节的原始数据,Redis 实际需要 64 字节,膨胀率高达 8 倍

估算建议

实测采样法

不要试图用数学公式去死算每一个字节(因为 jemalloc 和 struct padding 很难完全算准),而是采用 “小规模采样 + 线性推演”

我们可以启动一个空的 Redis 实例,记录初始内存 used_memory(通常在 1MB 左右,是 Redis 自身的启动开销)。编写脚本,写入 10,000 个 具有代表性的 Key-Value 数据(长度和类型要符合你的生产场景)。

然后计算初始内存使用 和 最终内存使用的差值,然后计算出单挑数据消耗,将单条数据消耗 X 预计总数据量就可以得到最终的预估结果。

经验法则:估算膨胀系数

如果你没法做测试,只能盲算,必须根据 Key/Value 的平均大小 来应用不同的膨胀系数。

  1. 小对象场景(最容易翻车)

    • 场景:Key = 10 字节,Value = 10 字节。

    • 原始数据:20 字节。

    • Redis 实际占用:约 64 ~ 80 字节

    • 膨胀系数3倍 ~ 4倍

      • 原因dictEntry (24B) + redisObject (16B) 即使什么都不存就已经 40B 了。加上 jemalloc 的 8B/16B/32B 对齐,开销巨大。
  2. 中等对象场景

    • 场景:Key = 30 字节,Value = 500 字节。
    • 膨胀系数1.1倍 ~ 1.2倍
      • 原因:此时数据的占比上来,头部元数据(Overhead)的占比下降。
  3. 大对象场景

    • 场景:Key = 50 字节,Value = 10 KB。

    • 膨胀系数接近 1.05倍

      • 原因:jemalloc 对大内存块的分配非常精准(Page 对齐),且元数据占比可忽略不计。