SDS
SDS 源码分析(简单动态字符串)
SDS 解决的问题:C 原生 char* 拿长度要 O(n) 遍历、内容里不能有 '\0'(二进制不安全)、append 时调用者自己算空间容易缓冲区溢出。
核心思路:把 len/alloc 等元信息放在一个 header 里,紧贴在数据前面,但 sds 指针仍然指向数据本身——读它和读 char* 一模一样,需要元信息时用 s[-1] 往前偏移一字节拿 flags,再反推出整个 header。
类比 Java:它相当于一个 C 版的 StringBuilder(可变、带容量、翻倍扩容),同时又能当 String 直接喂给标准库函数。
注意:本地仓是 unstable 分支(8.x 之后),比老教程常讲的 3.2/6.x 版本多了 sdsType() 辅助函数和 adjustTypeIfNeeded()(对齐 jemalloc 实际分配大小),五种 header 的核心设计没变。
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/sds.h
typedef char *sds; // sds 就是 char*,指向 header 后面的 buf;拿到 sds 的代码可以完全不知道 header 的存在
/* 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 */ // 长度直接塞进 flags 高 5 位,连 len 字段都省了:header 只有 1 字节,但也因此记不住"剩余空间"
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */ // 已用长度,O(1) 直接读;长度不靠 '\0' 判定,二进制安全的根源
uint8_t alloc; /* excluding the header and null terminator */ // 已分配容量(不含 header 和结尾 '\0'),剩余空间 = alloc - len
unsigned char flags; /* 3 lsb of type, 5 unused bits */ // 低 3 位标记 header 类型;packed 保证它一定是 buf 的前一字节
char buf[]; // 柔性数组不占结构体大小,sds 指针就指向这里
};
// ... sdshdr16 / sdshdr32 / sdshdr64 结构完全相同,只是 len/alloc 依次换成 uint16/32/64_t
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T)))) // 从 buf 指针回退 sizeof(header),拿到结构体指针;packed 取消对齐填充,这个减法才精确成立
#define SDS_TYPE_5_LEN(s) (((unsigned char)(s[-1])) >> SDS_TYPE_BITS)
static inline unsigned char sdsType(sds s) {
unsigned char flags = s[-1]; // 一切操作的起点:buf 前一字节必是 flags
return flags & SDS_TYPE_MASK;
}
static inline size_t sdslen(const sds s) { // O(1) 取长度,对比 strlen 的 O(n)
switch (sdsType(s)) {
case SDS_TYPE_5: return SDS_TYPE_5_LEN(s);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
// ... 32/64 同理
}
return 0;
}
static inline size_t sdsavail(const sds s) {
switch(sdsType(s)) {
case SDS_TYPE_5: {
return 0; // 类型 5 没有 alloc 字段,永远报告"没有剩余空间"——所以它只配给不再增长的短串
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
return sh->alloc - sh->len;
}
// ... 16/32/64 同理
}
return 0;
}创建路径。header 类型按长度选最小够用的,且分配器多给的内存不会浪费:
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/sds.c
char sdsReqType(size_t string_size) { // 按长度选最省的 header 类型
if (string_size < 1 << 5) return SDS_TYPE_5;
if (string_size <= (1 << 8) - sizeof(struct sdshdr8) - 1) return SDS_TYPE_8; // 上限要扣掉 header 和 '\0':按"整块分配不超过类型可寻址范围"算,防 alloc 字段截断
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; // 32 位平台 size_t 到不了 4G 以上,TYPE_64 无意义
#endif
}
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; // 空串大概率是为了 append 建的,类型 5 记不住剩余空间,直接升 8
int hdrlen = sdsHdrSize(type);
size_t bufsize;
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); // header + 数据 + '\0' 一次 malloc:单块内存,一次 free,缓存友好;_usable 带回分配器实际给的大小
if (sh == NULL) return NULL;
adjustTypeIfNeeded(&type, &hdrlen, bufsize); // jemalloc 常按 size class 多给内存,多到超出当前类型 alloc 字段的表示范围时,就地升级类型
return sdsnewplacement(sh, bufsize, type, init, initlen);
}
sds sdsnewplacement(char *buf, size_t bufsize, char type, const char *init, size_t initlen) {
assert(bufsize >= sdsReqSize(initlen, type));
int hdrlen = sdsHdrSize(type);
size_t usable = bufsize - hdrlen - 1;
sds s = buf + hdrlen; // 返回值不是分配起点,是 buf 的位置——这就是"sds 指针指向数据"的落地处
unsigned char *fp = ((unsigned char *)s) - 1; /* flags pointer. */
// ... 按 type 填 len / alloc / flags(alloc 记的是 usable,即分配器实际给的可用空间)
if (init == SDS_NOINIT)
init = NULL; // SDS_NOINIT:调用方稍后自己填内容(如从 socket 读入),跳过 memset 省一次大写入
else if (!init)
memset(s, 0, initlen);
else if (initlen)
memcpy(s, init, initlen);
s[initlen] = '\0'; // 永远补 '\0':花 1 字节换来能直接传给 printf/strcmp 等只读 C 函数
return s;
}追加路径,SDS 最核心的一段。预分配策略和 Java ArrayList.grow() 是同一个思想,但多了 1MB 封顶:
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/sds.c
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len); // 底层可能 realloc/malloc,s 会变!所以 sds API 都要求用返回值覆盖旧指针
if (s == NULL) return NULL;
memcpy(s+curlen, t, len); // 空间由 MakeRoomFor 保证够,这里放心拷——溢出防线在扩容层,不在每个调用点
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
#define SDS_MAX_PREALLOC (1024*1024) // 预分配封顶 1MB(定义在 sds.h)
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen, reqlen;
char type, oldtype = sdsType(s);
int hdrlen;
size_t bufsize, usable;
int use_realloc;
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s; // 上次预分配的空间还够就零成本返回——预分配的收益全在这一行兑现
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
reqlen = newlen = (len+addlen);
assert(newlen > len); /* Catch size_t overflow */
if (greedy == 1) {
if (newlen < SDS_MAX_PREALLOC) // 新长度 < 1MB:直接翻倍,N 次 append 摊下来只有 O(logN) 次真实 realloc
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC; // >= 1MB:每次固定加 1MB,大字符串再翻倍浪费就失控了
}
type = sdsReqType(newlen);
/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
assert(hdrlen + newlen + 1 > reqlen); /* Catch size_t overflow */
use_realloc = (oldtype == type);
if (use_realloc) { // 类型没变 => header 大小没变 => 数据相对偏移不变,可以 realloc(能原地扩就不搬数据)
newsh = s_realloc_usable(sh, hdrlen + newlen + 1, &bufsize, NULL);
if (newsh == NULL) return NULL;
s = (char*)newsh + hdrlen;
if (adjustTypeIfNeeded(&type, &hdrlen, bufsize)) { // 分配器多给到超出本类型上限:升级 header,数据往后挪出新 header 的位置
memmove((char *)newsh + hdrlen, s, len + 1);
s = (char *)newsh + hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc_usable(hdrlen + newlen + 1, &bufsize); // header 变大,数据起点要后移,realloc 只会保留"从头开始"的字节,帮不上忙
if (newsh == NULL) return NULL;
adjustTypeIfNeeded(&type, &hdrlen, bufsize);
memcpy((char*)newsh+hdrlen, s, len+1); // len+1:连结尾 '\0' 一起搬
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
usable = bufsize - hdrlen - 1;
assert(type == SDS_TYPE_5 || usable <= sdsTypeMaxSize(type));
sdssetalloc(s, usable); // alloc 记分配器实际给的可用大小,不是请求的 newlen——多给的也用上
return s;
}
sds sdsMakeRoomFor(sds s, size_t addlen) {
return _sdsMakeRoomFor(s, addlen, 1); // greedy:不确定后面还追加多少时用(APPEND 命令走这条)
}
/* Unlike sdsMakeRoomFor(), this one just grows to the necessary size. */
sds sdsMakeRoomForNonGreedy(sds s, size_t addlen) {
return _sdsMakeRoomFor(s, addlen, 0); // 明确知道最终大小时用,一字节不多要
}原理串讲
以 sdscat(s, "world") 追加一次为例走通全链路。sdscat 先 strlen 算出追加长度,转给二进制安全的 sdscatlen。
sdscatlen 第一步 sdslen(s):读 s[-1] 拿 flags,& SDS_TYPE_MASK 取低 3 位得到 header 类型,再用 SDS_HDR 宏把指针回退 sizeof(header) 字节读出 len 字段——整个过程没有一次遍历,这就是 O(1) 长度的实现。
然后进 sdsMakeRoomFor(greedy=1):sdsavail 发现剩余空间不够,把目标长度按”不足 1MB 翻倍、超过加 1MB”放大,sdsReqType(newlen) 重新选 header 类型。
如果类型没变,走 s_realloc_usable,分配器能原地扩就不搬字节;如果 append 把字符串从 sdshdr8 顶到了 sdshdr16 的范围,header 从 3 字节变 5 字节,数据起点必须后移——realloc 只保证”从分配起点开始的内容不变”,对”整体后移 2 字节”无能为力,所以这个分支只能 s_malloc_usable 新块再 memcpy,这就是 header 升级比普通扩容贵的原因。
两个分支最后都 sdssetalloc(usable) 把分配器实际给的空间记进 alloc。
回到 sdscatlen:memcpy 拷入新内容,sdssetlen 更新长度,补 '\0',返回新指针。
两个关键的”为什么”。第一,为什么 sds 指向 buf 而字符串元信息放在指针前面,而不像 Java 对象那样返回结构体指针?
因为这样 sds 可以无缝传给任何期望 char* 的现有 C 函数(printf、strcmp、write),Redis 代码里到处直接把 sds 当 char* 用;
同时 header + 数据一次 malloc 分配在同一块内存里,比”结构体 + 指针指向另一块数据”少一次分配、少一次指针跳转,缓存局部性更好。
这套设计成立的前提是 __attribute__((packed)):取消对齐填充后 buf[-1] 恰好是 flags、s - sizeof(header) 恰好是结构体首地址,两条指针算术才严格成立。
第二,为什么扩容后还要 adjustTypeIfNeeded?因为 jemalloc 按 size class 分配,要 100 字节可能实际给 128 字节;老版本 SDS 把请求值记进 alloc,多给的就浪费了,更糟的是多给的量超出当前类型 alloc 字段上限时会发生截断。
新版用 _usable 系列 API 拿到真实大小全部记账,必要时就地升级 header 类型,让 alloc 永远等于真实可用空间。
设计取舍
- API 约定”返回新 sds,旧指针作废”:扩容可能整体搬迁,写
sdscat(s, t)不接返回值等于制造悬垂指针。Java 里StringBuilder.append靠对象壳子隔离了这个问题,C 里只能靠约定。 - 类型 5 只 1 字节 header 但没有 alloc 字段,任何增长路径(
_sdsnewlen空串、_sdsMakeRoomFor)都主动绕开它;它专供创建后不再变的短串,比如海量的 key。 - 变短不自动缩容:
sdsclear只把 len 清零,sdstrim/sdsrange也只改 len,空间留给下次增长(惰性空间释放);要还内存得主动调sdsRemoveFreeSpace。 - packed 取消对齐会带来未对齐内存访问,x86/ARM64 上代价几乎为零,Redis 用它换每个字符串省几字节——字符串数量以千万计时这是大钱。
- alloc 上限即类型上限,
sdsReqType的边界是”整块分配不超过可寻址范围”(扣掉 header 和'\0'),而老版本只看逻辑长度,会在边界长度上出现 alloc 截断的坑。