一句话结论(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。如果所有字符串都套用最大号的头(sdshdr64 里 len 和 alloc 各占 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[];
};
其中 sdshdr8 的 len(1 字节) + alloc(1 字节) + flags(1 字节) 合计恰好 3 字节,这正是短字符串(≤44 字节)实际使用的头类型,也就是 44 字节阈值推导里被减掉的「SDS 头 3B」。alloc 的注释 excluding the header and null terminator 点明它只统计内容区、不把头与 \0 算进去——这是后面 64 − 16 − 3 − 1 = 44 里那个 3 和 1 的来源。
__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。也就是说,createStringObject 用 len <= 44 当 embstr/raw 的分界,就是为了让最大的 embstr(RedisObject 16B + SDS 头 3B + 内容 44B + \0 1B = 64B)正好塞满 jemalloc 的 64 字节 bucket;一旦超过 44,总大小就超 64、得跳到下一个 size class,于是改走 raw。
embstr 内部分配大小的计算来自 sds.h 的 sdsReqSize:
static inline size_t sdsReqSize(size_t len, char type) {
return len + sdsHdrSize(type) + 1;
}
代入 len=44、type=SDS_TYPE_8(sdsHdrSize 对 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。
四、三种编码对比
| 特性 | int | embstr | raw |
|---|---|---|---|
| 存储内容 | 整数(long 范围) | 短字符串(≤44字节) | 长字符串(>44字节) |
| 内存分配 | 零额外分配(ptr 直接存整数) | 一次分配(jemalloc 64B bucket) | 两次分配 |
| 内存布局 | robj.ptr = (void*)123 | 连续内存 | robj + SDS 分离 |
| 修改支持 | 只读(修改后变为 embstr/raw) | 只读(修改后变为 raw) | 可修改(有预留空间) |
| 典型场景 | SET count 100 | SET 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 和字符串格式化。INCR、DECR 等操作在这个编码下直接对 ptr 做算术。
限制:仅当字符串可以用 long long 表示时(-2^63 到 2^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 上千次,线性增长累积的开销远高于翻倍。代价是可能有多达一倍的内存预留浪费,这是典型的空间换时间。