Skip to content
Go back

Redis SDS 与 embstr 的 44 字节阈值:从 sdshdr 到 jemalloc 分配链

一句话结论(30s)

Redis 的 embstr 编码本质是给短字符串设计的一条「连续内存 + 一次 malloc」的路径。关键设计:因为 RedisObject(16B) + sdshdr8 头(3B) + \0(1B) 共占 20 字节,正好能被 jemalloc 的 64 字节 bucket 整除,所以留给字符串内容的极限就是 64 − 20 = 44 字节。权衡:因为 embstr 里 SDS 的 alloc 恰好等于 len(无预留空间),任何修改都要 realloc、而 realloc 可能破坏 RedisObject 与 SDS 的连续性,所以 Redis 干脆把它设计成「只读」,一旦 APPEND 就升级为 raw(两次分配)。

核心原理(2min)

SDS 用 len 字段 O(1) 取长度、alloc 跟踪容量、二进制安全,解决 C 字符串三大痛点;Redis 3.2+ 用 __packed__ 的 5 种头类型按长度选型,短字符串走 3 字节的 sdshdr8。每个值都被 16 字节的 RedisObject 包装(type/encoding/lru 位域 4B + refcount 4B + ptr 8B)。当字符串长度 ≤ 44 字节时用 embstr 编码,把 RedisObject + sdshdr8 + 内容 + \0 放进同一块连续内存,一次 malloc 正好填满 jemalloc 的 64B bucket,省去 raw 编码的第二次分配和指针跳转;超过 44B 则走 raw。int 编码则是把整数直接塞进 ptr 指针字段,零额外分配。

底层深入(5-10min)

一、SDS:Redis 的字符串基座

Redis 用 SDS(Simple Dynamic String)替代 C 语言的 char*,解决三个痛点:

C 字符串缺陷SDS 改进
O(n) 获取长度len 字段 O(1)
缓冲区溢出自动扩容,alloc 跟踪容量
二进制不安全(遇 \0 截断)len 指定长度,二进制安全

1.1 SDS 头结构的演变

Redis 3.2 之前:单一种类的 sdshdr

struct sdshdr {
    int len;    // 已用长度(4字节)
    int free;   // 剩余空间(4字节)
    char buf[]; // 柔性数组指向实际数据
};

无论字符串多短,头都占 8 字节。对于大量 5-10 字节的 Key,这是严重的内存浪费。

思考一下:SDS 为什么要拆成五种头,而不是一种通用头? 因为字符串的长度分布极不均衡——大量 Key 只有几字节,少数 Value 才是几 KB、几 MB。如果所有字符串都套用最大号的头(sdshdr64lenalloc 各占 8 字节),一个 5 字节的 Key 光头部就吃掉 17 字节,元数据比数据还大,纯属浪费;反过来,如果都用最小号头,又装不下大字符串。所以 Redis 按长度分档:短的走 1 字节计数的 sdshdr8,长的逐级升到 sdshdr16/32/64——用「按需选型」换取「整体内存最小」。这也解释了为什么 SDS 能比朴素 char* 省:不是不存元数据,而是只为实际需要的长度范围付元数据的钱。

Redis 3.2+:引入 5 种头类型,按需选择。以下为 sds.h 中五种 sdshdr 的逐字源码:

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

其中 sdshdr8len(1 字节) + alloc(1 字节) + flags(1 字节) 合计恰好 3 字节,这正是短字符串(≤44 字节)实际使用的头类型,也就是 44 字节阈值推导里被减掉的「SDS 头 3B」。alloc 的注释 excluding the header and null terminator 点明它只统计内容区、不把头与 \0 算进去——这是后面 64 − 16 − 3 − 1 = 44 里那个 31 的来源。

__attribute__ ((__packed__)) 是关键——它禁用了编译器的内存对齐填充。没有这个属性,sdshdr8 可能被填充到 4 或 8 字节,embstr 的阈值计算将完全不同。

1.2 头类型选择逻辑

char sdsReqType(size_t string_size) {
    if (string_size < 1 << 5) return SDS_TYPE_5;
    if (string_size <= (1 << 8) - sizeof(struct sdshdr8) - 1) return SDS_TYPE_8;
    if (string_size <= (1 << 16) - sizeof(struct sdshdr16) - 1) return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
    if (string_size <= (1ll << 32) - sizeof(struct sdshdr32) - 1) return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}

sds sdsnewlen(const void *init, size_t initlen) {
    return _sdsnewlen(init, initlen, 0);
}

sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
    void *sh;

    char type = sdsReqType(initlen);
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    size_t bufsize;

    if (trymalloc) {
        /* protect against size_t overflow */
        if (initlen + hdrlen + 1 <= initlen)
            return NULL;
    } else {
        assert(initlen + hdrlen + 1 > initlen); /* Catch size_t overflow */
    }

    sh = trymalloc?
        s_trymalloc_usable(hdrlen+initlen+1, &bufsize) :
        s_malloc_usable(hdrlen+initlen+1, &bufsize);
    if (sh == NULL) return NULL;

    adjustTypeIfNeeded(&type, &hdrlen, bufsize);
    return sdsnewplacement(sh, bufsize, type, init, initlen);
}

sdsnewlen 真正干活的是 _sdsnewlen,它第一步就用 sdsReqType(initlen) 按内容长度选类型:一个 44 字节的字符串不满足 < 1<<5,于是落入 <= (1<<8) − sizeof(struct sdshdr8) − 1(即 ≤252)这一支,得到 SDS_TYPE_8、头长 hdrlen = 3——这正是 embstr 阈值里「SDS 头 3B」的出处。值得注意的是,这里的 sdsReqType 是本仓库(Valkey 分支)的真实实现,它在比较时扣掉了 sizeof(struct sdshdrN),比老 Redis 那种 string_size < 1<<8 的裸长度写法更严谨,保证 alloc 字段装得下的是「实际缓冲区」而非仅逻辑长度。

至于 sdshdr5 为何被废弃,源码注释也给出了答案——Empty strings are usually created in order to append. Use type 8 since type 5 is not good at this:空串默认也要升级成 type 8,方便后续 APPEND 时记录剩余空间,所以现在新字符串实际都从 sdshdr8 起步。

二、RedisObject:字符串的包装层

每个 Redis 键值都以 RedisObject 包装:

typedef struct redisObject {
    unsigned type:4;       // 类型 (string, list, hash...)
    unsigned encoding:4;   // 编码 (int, embstr, raw...)
    unsigned lru:24;       // LRU 时钟 (或 LFU)
    int refcount;          // 引用计数 (4字节)
    void *ptr;             // 指向实际数据的指针 (8字节 on 64-bit)
} robj;  // 共 16 字节

type + encoding + lru 共享 4 字节(位域),refcount 4 字节,ptr 8 字节 → 总共 16 字节

三、embstr 的 44 字节阈值:完整推导

3.1 embstr vs raw 的核心差异

raw 编码:RedisObject 和 SDS 是两次独立的内存分配:

堆内存布局:
[redisObject 16B] ---ptr---> [sdshdr8 3B + buf + \0]  ← 两次 malloc

embstr 编码:RedisObject 和 SDS 连在一块连续内存中,一次分配:

堆内存布局(连续):
[redisObject 16B][sdshdr8 3B][buf (最多44B)][\0 1B]  ← 一次 malloc
共 64 字节

3.2 为什么是 44 字节?

先问一句:阈值凭什么恰好是 44,而不是 40 或 50? 这不能拍脑袋,得从内存分配器往上反推。embstr 的目标是「一次 malloc 装下整条字符串」,而分配器按固定粒度给内存——所以 44 其实是被「分配器的最小 bucket 大小」反向约束出来的,不是一个随意挑的数。往下看推导。

jemalloc 的 size class 决定了内存分配的最小粒度。最接近 embstr 需求的 size class 是 64 字节 bucket

64 (jemalloc bucket)
- 16 (RedisObject)
-  3 (sdshdr8 头部:len 1 + alloc 1 + flags 1)
-  1 (\0 终止符)
= 44 字节(可用于字符串内容)

jemalloc 分配 32 → 48 → 64 字节这些颗粒度。如果 embstr 内容超过 44 字节,需要升级到 128 字节 bucket,内存浪费急剧增加。因此 Redis 将阈值定为 44——恰好让 RedisObject + sdshdr8 + 字符串 + \0 填满一个 64 字节的 jemalloc bucket。

3.3 源码中的阈值

/* Create a string object with EMBSTR encoding if it is smaller than
 * OBJ_ENCODING_EMBSTR_SIZE_LIMIT, otherwise the RAW encoding is
 * used.
 *
 * The current limit of 44 is chosen so that the biggest string object
 * we allocate as EMBSTR will still fit into the 64 byte arena of jemalloc. */
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}

源码注释直接把 44 的来历写死:The current limit of 44 is chosen so that the biggest string object we allocate as EMBSTR will still fit into the 64 byte arena of jemalloc。也就是说,createStringObjectlen <= 44 当 embstr/raw 的分界,就是为了让最大的 embstr(RedisObject 16B + SDS 头 3B + 内容 44B + \0 1B = 64B)正好塞满 jemalloc 的 64 字节 bucket;一旦超过 44,总大小就超 64、得跳到下一个 size class,于是改走 raw。

embstr 内部分配大小的计算来自 sds.hsdsReqSize

static inline size_t sdsReqSize(size_t len, char type) {
    return len + sdsHdrSize(type) + 1;
}

代入 len=44type=SDS_TYPE_8sdsHdrSize 对 sdshdr8 返回 3):sdsReqSize(44, SDS_TYPE_8) = 44 + 3 + 1 = 48,再加 sizeof(robj)=16 恰好 64,验证 44 字节阈值是精确推导出来的,而非拍脑袋。

3.4 embstr 的”只读”特性

embstr 在 Redis 中设计为 只读。对 embstr 执行 APPEND 等修改操作时,Redis 会将其升级为 raw:

// 对 embstr 执行 APPEND 时
void appendCommand(client *c) {
    // ...
    if (o->encoding == OBJ_ENCODING_EMBSTR) {
        // 升级为 raw:重新分配内存,拷贝数据
        o = dbUnshareStringValue(c->db, c->argv[1], o);
    }
    // 执行 append
}

为什么 embstr 只读? 因为 embstr 的 RedisObject + SDS 是连续的内存,而 SDS 的 alloc 记录了已分配空间。但 embstr 中 alloc 恰好是字符串长度(没有多余空间),任何修改都需要扩容——扩容意味着 realloc,而 realloc 后的新地址可能不再能保证 RedisObject 和 SDS 的连续性。为了一致性,Redis 选择直接升级为 raw。

四、三种编码对比

特性intembstrraw
存储内容整数(long 范围)短字符串(≤44字节)长字符串(>44字节)
内存分配零额外分配(ptr 直接存整数)一次分配(jemalloc 64B bucket)两次分配
内存布局robj.ptr = (void*)123连续内存robj + SDS 分离
修改支持只读(修改后变为 embstr/raw)只读(修改后变为 raw)可修改(有预留空间)
典型场景SET count 100SET name "Alice"SET desc "very long text..."

4.1 int 编码的 trick

// object.c
robj *createStringObjectFromLongLong(long long value) {
    robj *o = createObject(OBJ_STRING, NULL);
    o->encoding = OBJ_ENCODING_INT;
    o->ptr = (void*)value;  // 指针直接存储整数值,不指向任何实际内存
    return o;
}

这是一个取巧设计:64 位系统上指针是 8 字节,long 也是 8 字节(或 4 字节)。直接把整数值存在 ptr 字段中,省去了 SDS 和字符串格式化。INCRDECR 等操作在这个编码下直接对 ptr 做算术。

限制:仅当字符串可以用 long long 表示时(-2^632^63-1),且字面量不超过 LONG_STR_SIZE(21 字符,即 -9223372036854775808 的长度)。

思考一下:把整数塞进 ptr 指针,不会出问题吗? 因为 64 位系统上指针本来就是 8 字节,long 也装得下 8 字节,所以 ptr 这个「8 字节的坑」既能放指针、也能直接放数值——Redis 只是复用了这个坑,并没有真的去解引用它。只要编码位标记成 OBJ_ENCODING_INT,后续所有操作都知道「这里的 ptr 不是地址,是数值本身」。代价是它只能覆盖 long long 能表示的范围,一旦 APPEND 或超出范围,就得转成 embstr/raw 重新分配。

五、SDS 扩容策略

// sds.c
sds sdsMakeRoomFor(sds s, size_t addlen) {
    // ...
    if (newlen < SDS_MAX_PREALLOC)  // SDS_MAX_PREALLOC = 1024*1024 = 1MB
        newlen *= 2;                // 小于 1MB:翻倍
    else
        newlen += SDS_MAX_PREALLOC; // 大于 1MB:每次 +1MB
    // ...
}

这与 Java ArrayList 的 newCapacity = oldCapacity + (oldCapacity >> 1)(1.5 倍)不同。Redis 的翻倍策略更优:在字符串频繁增长的场景下(如逐步构建一个大 JSON),翻倍减少了 realloc 的次数。

章末提问

追问 1:SDS 相比 C 字符串到底解决了什么?一句话。 回答思路:结论——三个痛点:O(1) 取长度、自动扩容防溢出、二进制安全。因为 C 字符串靠 \0 判定结束,取长度要 O(n) 从头扫、拼接不检查边界会越界、内容里一旦含 \0 就被截断;SDS 用 len/alloc 显式记录长度和容量,把这三件事一次补齐。

追问 2:embstr 的 44 字节阈值是怎么推导出来的?换个分配器还是 44 吗? 回答思路:结论——44 是「64 字节 bucket」反推出来的,不是固定真理。因为 RedisObject 16B + sdshdr8 头 3B + \0 1B 共 20B,而 jemalloc 最贴近的 size class 是 64B,64 − 20 = 44;若换成 glibc malloc(chunk 结构和对齐规则不同),阈值和布局都会变。所以它本质是「Redis 结构 + jemalloc 分配粒度」联动的产物,而不是一个硬编码的魔法数。

追问 3:embstr 为什么设计成只读?对 embstr 执行 APPEND 会发生什么? 回答思路:结论——只读是「连续内存」的代价,APPEND 会升级成 raw。因为 embstr 里 alloc == len,没有预留空间,一改就要扩容;而扩容即 realloc,realloc 返回的新地址可能不再和 RedisObject 连续,等于破坏了 embstr 的前提。与其维护「半连续」的复杂状态,不如直接切成 raw(两次分配),逻辑最简单。

追问 4:int 编码为什么不分配内存,还能直接做 INCR? 回答思路:结论——它把整数值直接写进 ptr 这个 8 字节字段,零额外分配。因为 64 位下指针和 long long 都是 8 字节,ptr 能原样容纳数值,配合 OBJ_ENCODING_INT 标记让读方知道「这不是地址」;INCR/DECR 就是对这个字段做算术,省掉 SDS 和字符串格式化。代价是只覆盖 long long 范围,超范围或修改就退化成 embstr/raw。

追问 5:SDS 扩容为什么要翻倍(×2),而不是固定加一截? 回答思路:结论——翻倍是为了摊薄 realloc 次数,均摊 O(1)。因为容量按几何级数增长,从 1 长到 n 只需 O(log n) 次 realloc,总拷贝量是 O(n);若每次固定加 1MB,长到 1GB 要 realloc 上千次,线性增长累积的开销远高于翻倍。代价是可能有多达一倍的内存预留浪费,这是典型的空间换时间。


Share this post on:

Previous Post
Redis Stream——持久化的消息队列新选择
Next Post
Redis RDB和AOF持久化——bgrewriteaof子进程如何不丢数据