Redis 是用 C 写的,但它从来不用 C 标准库里那种"以 \0 结尾"的字符串。它自己实现了一套叫 SDS(Simple Dynamic String)的字符串结构——这篇讲清楚为什么要自己造轮子,以及它具体是怎么做到"取长度是 O(1)"、"能存任意二进制数据"、"不用每次 append 都重新分配内存"这几件事的。
本机真实装了 Redis 8.10.1,所有编码判断、内存增长数字都是用 redis-cli 真实跑出来的,源码引用精确对应这个版本。
C 语言里一个字符串就是一个 char*,靠结尾的 \0 标记"到这里为止"。这个设计换来了简单,但代价不小。
strlen() 得从头扫到 \0 才知道长度。Redis 里 STRLEN 是高频命令,不能接受每次都扫一遍。
C 字符串把第一个 \0 当作结尾——但图片、序列化数据这些二进制内容里,\0 就是正常的一个字节,不是终止符。
原生 C 字符串不记录自己实际分配了多大空间,每次 strcat 都得先 realloc,频繁拼接的场景代价很高。
SDS 就是针对这三条,专门设计的替代品——一个 header 记着长度和已分配容量,后面跟着真正的字符数据。
SDS 不是一种固定大小的结构,而是根据字符串长度,从 5 种 header 类型里选一个最小够用的。
src/sds.h · redis @ 8.10.1, L28 struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; ... }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; uint32_t alloc; ... }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; uint64_t alloc; ... };
一个只有 5 个字符的短字符串,如果 len/alloc 都用 64 位整数存,光 header 就要 17 字节,比字符串本身还大好几倍。所以 SDS 按需选类型:短字符串用 sdshdr8(header 只要 3 字节),长字符串才用得上 sdshdr64。len 字段的存在直接解决了"取长度 O(1)"——不用扫,读一个字段就行。
Redis 存字符串时还有一层"外层编码"——不是所有字符串都会走完整的 SDS 结构。
44 字节是真实分界线,不是凑整——源码里写得很直接:
src/object.c · redis @ 8.10.1, L330 /* 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
embstr 把"对象头 + SDS header + 字符数据"一次性分配在一块连续内存里(所以是"embedded"),省掉一次单独的内存分配;raw 编码里,对象头和 SDS 的字符数据是两块分开分配的内存,通过指针相连。44 这个数字,是精确算出来的——刚好让最大的 embstr 对象仍然落在 jemalloc 64 字节这一档分配粒度里,再大一点就得跳到下一档,不如干脆分开存。
SDS 的 alloc 字段记录的是"已经申请到的总容量",不是"当前用了多少"——两者之间的差,就是不用重新分配也能直接写的富余空间。
8 次 append,只触发了 3 次真正的内存重新分配——这条"平的时候不动、用完才跳"的曲线,就是 SDS 预分配策略在起作用。真实的分配规则:
src/sds.c · redis @ 8.10.1, L293 #define SDS_MAX_PREALLOC (1024*1024) /* 1MB */ ... if (greedy == 1) { if (newlen < SDS_MAX_PREALLOC) newlen *= 2; // 需求量小于 1MB:直接翻倍 else newlen += SDS_MAX_PREALLOC; // 已经很大了:每次只多加 1MB,不再翻倍 }
规则很直白:字符串还小的时候,翻倍换未来好几次 append 都不用再分配;字符串已经很大了(比如几十 MB),翻倍会一次性浪费太多内存,改成每次固定只多要 1MB。这是一个"用空间换时间"和"别浪费太多空间"之间的具体取舍点,不是拍脑袋定的。
用上面真实实验同样的操作序列(初始 1 字节,每次 append 10 字节,共 8 次),复现容量增长过程。
演示里的分配数字(alloc 从 1 → 22 → 62 → 142)是对真实增长公式(翻倍,阈值 1MB)的精确计算结果,不是凑出来的形状;和上面真实测出的内存曲线(58→106→218)节奏一致——都是"平几步、跳一次"——只是绝对数值不同,因为真实 MEMORY USAGE 还包含对象头开销和内存分配器自己的取整规则,这两部分本文没有逐字节还原。
因为 SDS 靠 len 字段知道自己多长,不靠扫描找 \0,存储任意字节(包括 \0 本身)就是免费获得的能力。
换成 C 标准库的字符串处理,这条数据在第一个 \0 处就被当成结尾了,后面的 "world" 会直接丢失。