1. 从一个诡异问题说起:String 在 Redis 里到底存的是什么
我在排查线上问题时遇到过这么一件事:一个同事往 Redis 里存了一段带\x00的二进制数据,结果取出来发现后半段没了。他很困惑地问:“Redis 的 String 不是二进制安全的吗?怎么还会有截断?”
这个问题其实问到了 Redis 源码里一个非常核心的设计:Redis 的 String 类型底层并不是直接用 C 语言里的字符串,而是一种叫 SDS(Simple Dynamic String,简单动态字符串)的结构。当时我让他去翻源码里sds.h和sds.c这两个文件,顺着sds这个结构体往下挖了一遍,他才明白问题出在哪里——他存的不是 Redis String,而是自己在别处把数据截断了。但这个排查过程让我意识到,很多日常用 Redis 的人,对 String 底层的认识其实还停留在“不就一个字符串嘛”的层面。
这篇文章我想认认真真把这件事讲透。你会看到 Redis 为什么放着现成的 C 字符串不用,非得自己搞一套 SDS;SDS 的源码是怎么设计的;它在内存分配、扩容、二进制安全上做了哪些“反直觉”的取舍;以及这些底层设计如何直接影响你写业务代码时的性能。适合想深入理解 Redis 原理的开发者,也适合那些被面试官问过“Redis 的 String 和 C 字符串有什么区别”之后回来补课的同学。
看完之后你会发现,SDS 看起来只是多存了两个字段,实际上是在性能、安全、内存效率之间做了一整套精妙的权衡。理解了它,你对 Redis“快”的认知会从“它用 C 写的所以快”升级到“它在细节上把 C 的坑全填了所以快”。
2. 先看懂 C 字符串的软肋,才知道 SDS 在解决什么
2.1 strlen 的 O(n) 陷阱:每次拿长度都要遍历
C 语言标准库里没有真正的字符串类型,它用char[]数组 + 结尾的\0来表示字符串。strlen这个函数要统计长度,只能从起始地址开始一个字节一个字节地往后数,直到遇到\0为止。也就是说,拿到一个字符串的长度是 O(n) 的,n 是字符串本身的长度。
单看这个操作好像也不算什么,但在 Redis 这种纯内存数据库场景下,这个代价被无限放大了。Redis 的多数命令都需要先拿到 key 的长度做哈希、需要比较 value 的长度做内存统计,如果每次都要 O(n) 扫一遍,数据库的吞吐量会被拖垮。在真实业务里,SET一个几 KB 的字符串、然后反复执行APPEND、STRLEN的场景非常常见,这种场景下每次STRLEN都去遍历整个字符串显然是不能接受的。
SDS 的解决方案很直接:在结构体里专门存一个len字段,字符串多长,这个字段就是多少,取长度时直接返回len,O(1) 搞定。你去看sdslen这个函数的实现,里面根本没有遍历,就是sh->len这样的一个字段读取。
2.2 二进制安全:C 字符串在\0面前直接投降
C 字符串以\0作为终止符,这带来一个很麻烦的问题:如果字符串中间嵌入了\0,比如一段序列化后的二进制数据、一张压缩图片、一个用memcpy拷贝过来的结构体,C 字符串函数会把\0当成字符串的结尾,后面的内容全被丢掉了。
Redis 是内存数据库,远不止存文本。很多人用它来缓存序列化后的对象,比如JSON、protobuf、MessagePack,这些格式的二进制内容里完全可能包含\0字节。如果底层沿用 C 字符串,那存进去一段包含\0的数据,取出来就少了半截,这是毁灭性的问题。
SDS 靠len字段解决这个痛点:它不依赖\0判断字符串是否结束,而是以len为准。buf[]数组里存什么就是什么,即使中间有\0,也只是一个普通字节,不影响读取。你可以把 SDS 理解成“带长度的字节数组”,什么都能存,这也是 Redis 官方强调的“二进制安全”的含义。
2.3 修改字符串时的内存隐患:越界与频繁分配
C 字符串的另一个经典问题是拼接和修改时的内存管理完全靠程序员自觉。strcat(dest, src)不会检查dest的空间够不够,一旦dest容量不足,就会发生缓冲区溢出,轻则踩坏相邻内存,重则成为安全漏洞。strcpy类似,拷贝前不检查目标空间。这俩函数在开源项目里已经“黑化”很久了,安全编码规范基本都要求禁用。
Redis 的APPEND、SETRANGE、GETSET等命令都会修改字符串内容,开发者在源码里不可能每次都手算目标空间够不够——工程量太大,还容易出错。SDS 的做法是封装内存管理:需要扩容时自动分配,空间不够时自动扩容,同时通过free字段记录剩余空间,写操作前先检查空间是否足够,不够就扩容,从机制上杜绝了缓冲区溢出。
还有一个更隐蔽的性能问题是频繁内存分配。C 字符串每次拼接,如果原数组空间不够,就得realloc重新分配内存,把旧内容拷贝过去,释放旧内存。realloc本身是系统调用,频繁触发是很贵的。SDS 在扩容时不只分配刚好的空间,而是“多分配一些”作为预留,这样后续多次追加操作可能就不需要再次分配内存了。这个预分配策略是 SDS 性能优势的重要来源,后面我会展开讲。
2.4 为什么 Redis 不能直接复用 C 字符串的库函数
看到这里可能有人会问:C 字符串的库函数确实有这些问题,但 Redis 可以在使用的时候自己注意点,比如动态分配空间、手动维护一个长度变量,那不就行了吗?
理论上可以,但实际上你会在代码里到处看到这样重复且容易出错的逻辑:每次修改前手动算长度、手动 realloc、手动检查\0,整个逻辑交织在业务代码里,可维护性极差。C 标准库里的string.h提供的是一套通用接口,它不会替你考虑“这个字符串将来还要追加多少内容”,也不会区分“长度字段和分配容量字段”。
Redis 的解决思路很典型:不如我直接设计一个新的字符串结构,把所有坑都提前填了。这个结构就是 SDS。它既可以当作 C 字符串来用(结尾保留\0,兼容部分 C 库函数),又额外提供了长度、容量、二进制安全等能力。源码注释里明确说了,SDS 的设计目标就是:简单、安全、高效。这六个字在sds.h的注释里写着,但代码里体现出来的权衡远比这六个字复杂。
3. SDS 源码逐行拆解:Redis 自己造的字符串轮子
3.1 五种 Header:同一个 String,不同的“马甲”
SDS 头部的定义在sds.h里长这样:
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; uint16_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; uint32_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; uint64_t alloc; unsigned char flags; char buf[]; };注意这里的关键点:SDS 并不是只有一种头部,而是有sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64五种。sdshdr8里的len和alloc是uint8_t,也就是各占 1 字节;sdshdr16里各占 2 字节;以此类推。
为什么要搞五种?因为 Redis 想省内存。如果你只存一个长度不超过 255 的短字符串,用 8 字节的头部就够了,没必要用 64 位的字段去记录长度。Redis 在创建 SDS 时,会根据初始字符串的长度选择合适的 header 类型:长度小于1<<5的用sdshdr5,小于1<<8的用sdshdr8,小于1<<16的用sdshdr16,以此类推。这个设计在源码里叫“按需分配”,是 Redis 在内存利用上做的第一个小细节。
补充说明:
sdshdr5这个类型有些特殊,它的flags字段的 5 个高位直接用来存储字符串长度,没有单独的len和alloc。也就是说它的上限是 31 字节,而且不支持扩容,一旦超过就会被升级为sdshdr8。在 Redis 7.0 之前sdshdr5在部分设计中不主动使用,后面版本里已经调整了使用场景。
3.2__packed__和buf[]:内存对齐与柔性数组的微妙关系
__attribute__ ((__packed__))是 GCC 提供的编译指令,作用是让结构体按 1 字节对齐,取消编译器默认的内存对齐优化。如果不加这个指令,sdshdr16结构体的大小会因为对齐问题变成 6 字节(uint16_t len占 2、uint16_t alloc占 2、unsigned char flags占 1,再加上 1 字节 padding 填充),而加了packed之后就是 5 字节。
这里为什么要跟编译器对着干?因为 SDS 的内存布局非常讲究:结构体后面紧跟的buf[]是柔性数组,它不占结构体空间,直接挂在头部后面。如果头部有填充字节,buf[]的起始地址就会偏移多出几个字节,不仅浪费内存,还会让sds指针与头部之间的相对位置计算变得复杂。通过packed强制紧凑布局,Redis 保证了buf[]正好紧挨着flags,没有任何空洞。
你去看sds.c里的宏定义,会有类似这种操作:
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))它把指向buf[]的指针往回偏移一个头部的长度,就能拿到结构体头部的起始位置。这一切都建立在一个前提上:头部大小是固定的、没有对齐填充的,否则偏移量就算不对。
3.3 二进制安全的底层实现:为什么它能存\0
SDS 的buf[]是一个字节数组,但它同时保留了一个\0结尾,这样做的目的是兼容 C 字符串函数,比如strcmp、strcpy可以直接用在sds的buf上(仅在你知道内容是纯文本的情况下)。
真正的关键在于:SDS 判断字符串是否结束,看的是len,不是\0。比如你创建一个 SDS 存储内容"ab\0cd",它的len是 5,buf数组里五个字节分别是a b \0 c d,最后一位还有一个真实的\0作为“安全后缀”。当sdslen被调用时,返回的是 5,而不是遇到\0就停下的 2。所有 SDS 的读写接口都会严格基于len来操作,这保证了二进制数据在底层可以原样存储。
这个“安全后缀”也是一处细节:它在sds.c的sdsnewlen函数里,分配内存时会额外多分配 1 字节,专门放\0。这个\0不参与len的计算,纯粹是为了兼容性。
3.4 扩容策略:小于 1MB 翻倍,大于 1MB 加 1MB
SDS 最核心的性能机制在sdsMakeRoomFor函数,它负责在追加内容前确保有足够的空闲空间。我摘一段核心逻辑:
sds sdsMakeRoomFor(sds s, size_t addlen) { ... if (avail >= addlen) return s; len = sdslen(s); sh = (char*)s - sdsHdrSize(oldtype); newlen = (len + addlen); if (newlen < SDS_MAX_PREALLOC) newlen *= 2; else newlen += SDS_MAX_PREALLOC; ... }这里的SDS_MAX_PREALLOC是 1MB(1024*1024)。逻辑很简单:如果要扩容后的总长度小于 1MB,就按新长度的 2 倍分配;如果大于等于 1MB,就只额外增加 1MB。
为什么是 1MB 做分水岭?因为在小于 1MB 的时候,翻倍扩容的额外空间不大,但能显著减少后续再次分配内存的次数。比如一个 10 字节的字符串追加 20 字节,扩容后直接分配到 60 字节(newlen = 30,乘以 2 是 60),下次再追加 30 字节以内就完全不需要重新分配。而当字符串超过 1MB 后,翻倍带来的额外内存增长速度太快,可能造成很大的浪费,所以改为固定追加 1MB,让内存增长平缓可控。
我曾经在本地做过一个对比实验:向一个空字符串连续APPEND100 万次,每次追加一个小字段。结果在默认参数下,通过info memory观察内存波动很平稳。随后我把SDS_MAX_PREALLOC临时改成一个极小值再编译测试,内存分配次数明显上升,性能下降肉眼可见。这个细节证明了预分配策略在实际场景中的价值。
注意:如果你用 Redis 存储超大字符串(比如几十 MB 甚至上百 MB),预分配策略会显得“保守”。比如在 50MB 的字符串上追加 1MB,实际分配 51MB,并不会翻倍到 100MB,这是为了保护内存不被无谓占用。Redis 宁可牺牲一点追加性能,也不愿意膨胀到用户预期之外的内存消耗。
4. 三种编码切换:int、embstr、raw 到底怎么选
4.1 临界值 44 字节的推导过程
SDS 解决了“字符串内部怎么存”的问题,但 Redis 还得面对“对象本身怎么存”的问题。字符串对象在 Redis 内部有三种编码:int、embstr、raw。很多人在面试题里看到过“44 字节”这个数字,但不知道它是怎么来的。
先看redisObject的定义,所有 Redis 对象都会带这层结构:
typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; int refcount; void *ptr; } robj;type占 4 位,encoding占 4 位,lru占 24 位,合计 4 字节,再加refcount4 字节,ptr8 字节,总共是 16 字节。
embstr编码要求redisObject和 SDS 结构体在连续的一块内存里,一次性分配,一次性释放。所以 Redis 会计算:redisObject16 字节 +sdshdr8头部 3 字节 + 结尾\01 字节 = 20 字节。Redis 内存分配默认使用 jemalloc,它有一个 64 字节的小块分配桶。64 减掉 20 字节的头部成本,剩 44 字节给数据。
也就是说,当字符串长度小于等于 44 字节时,embstr编码可以完整装进一个 64 字节的 jemalloc 分配单元里,这是效率最高的方案。超过 44 字节,就改用raw编码,redisObject和 SDS 分开分配,各管各的。
4.2embstr是只读的:为什么修改操作会触发转换
embstr有个特性容易被忽略:它被认为是只读的。你可能会想,字符串不是可以APPEND修改吗?实际上,Redis 对embstr执行任何修改操作前都会先把它转成raw,再执行修改。embstr本身不允许原地扩容。
为什么这么设计?因为embstr的内存是和redisObject连在一起的,长度固定。一旦需要扩容,无法在原有位置扩大,必须重新分配一整块更大的连续内存,再把redisObject和 SDS 一起搬过去。与其做这种复杂的原地升级,不如直接创建一个新的raw对象,复制内容,再修改。虽然多了一次内存分配和拷贝,但代码逻辑清晰了很多,而且只影响小对象。
我遇到过不少业务方的疑问:为啥我的 key 的 value 明明只有几个字符,只是执行了一次APPEND,它的object encoding就从embstr变成了raw?这就是原因。如果你特别在意编码状态,尽量避免对短字符串反复执行修改操作,或者改用SETRANGE时也要意识到同样的问题。
4.3int编码:Redis 比你想的更会省
第三种编码int最容易被忽略。当字符串内容能被解析为 long 型整数时,Redis 不会创建 SDS,而是直接把整数存进redisObject的ptr字段里。注意,ptr本来是一个指针,但这里被复用来存储整数值,省掉了整个 SDS 结构。
比如你执行SET num 123456,Redis 在做编码选择时发现123456可以解析为 long,于是直接以int编码存储。这个设计对计数器类业务非常友好,比如INCR、DECR、INCRBY这类命令,它们操作的对象如果本身就是int编码,就能直接在整数上运算,不需要重新解析字符串。
不过要注意:一旦这个“整数”超出 long 的范围,或者执行了APPEND等字符串操作,编码会立即变成raw。我之前遇到一个需求,用户用一个大整数做订单号(超过了 long 范围),结果发现存进去是字符串编码,INCR操作直接报错。这提醒我们,用 Redis 做自增 ID 时要保证数值在 long 范围内,通常LONG_MAX是 9223372036854775807,超过这个值就不能依赖INCR了。
4.4 编码选择的判断逻辑在源码哪里
这个选择逻辑主要在object.c的tryObjectEncoding函数里。大致流程是:
- 检查对象类型是否是字符串类型,不是就直接返回。
- 检查能否转为 long,能转就尝试用
int编码。 - 不能转 long,再看字符串长度,小于等于 44 字节用
embstr,否则用raw。 - 如果对象原来的编码已经是
embstr或者raw,且新编码和旧编码一致,就复用原对象,避免不必要的内存操作。
这里的第 4 条也是一个优化点:如果你连续多次SET同一个 key,值都没有超过 44 字节,Redis 不需要反复重新分配内存,它会复用原来的对象。这种“能省则省”的思路贯穿整个源码。
5. 源码之外:SDS 设计对日常使用的影响
5.1 内存碎片与 jemalloc:为什么used_memory比实际数据大
理解了 SDS 的头部和redisObject结构之后,你会发现 Redis 里存一个简单的"hello",实际消耗的内存远不止 5 字节。redisObject16 字节、sdshdr83 字节、缓冲区里的\01 字节,数据 5 字节,基础成本已经 25 字节。如果数据正好落在 jemalloc 的 32 字节分配桶里,那就直接占 32 字节。
这个“基础开销比数据本身的字节数还高”的现象,在存储海量短字符串时特别明显。我在一次调优中统计过,线上服务 key 的平均长度很短(十几个字节),value 也很短,结果 Redis 的used_memory比纯数据求和多出了将近 60%。很多人第一反应是“内存泄漏”,其实是对象头和内存分配器的固定开销。
要验证这个,可以用redis-cli --stat观察used_memory和used_memory_rss,再用memory usage key单独看某个 key 的实际代价。对于短字符串密集场景,可以考虑改用 Hash 结构(field 复用同一个对象头),能把内存效率提升不少。
5.2APPEND与预分配:频繁追加为什么不会撑爆内存
SDS 的预分配策略保证了大多数APPEND操作不需要重新分配内存,但极端场景下要注意“分配了但没用到”的内存也算在used_memory里。比如你执行APPEND往一个短字符串里追加了 100 次,每次追加 1 字节,扩容时第一次可能会翻倍到几十字节,第二次翻倍到上百字节,这个增长速度在趋势上是平缓的。而如果一次追加 100KB 数据,扩容逻辑会直接按newlen + 1MB或newlen * 2分配,可能一下子多出几百 KB 的空闲空间,但这些空间并不全是“浪费”——后续继续追加时,这些空闲就被消化掉了。
如果你实在在意这个,可以用MEMORY DOCTOR检查一下内存碎片率,用INFO memory看mem_fragmentation_ratio。正常情况下在 1.0 到 1.5 之间算合理,如果高得离谱,再看看是不是有大量短生命周期的小字符串频繁创建/释放。
实操心得:我在做缓存清理时发现,一个值约 200 字节的 key,经过多次
APPEND后,实际占用可能达到 300 多字节,原因就是 SDS 的预分配。所以如果你要存储的内容在写入前长度就确定了,建议直接用SET一次性写入,不要用APPEND拼,能省掉不必要的扩容空间。
5.3 大 key 排查:超过 512MB 会发生什么
Redis 的 String 类型有上限,单个 value 最大 512MB。很多人是在写入大文件或缓存大块数据时触发这个限制的。源码里checkStringLength这个函数专门做这个检查,逻辑很简单:如果新长度超过 512MB(PROTO_MAX_STRING_LEN),直接返回一个错误。
大 key 本身也是个坑。我之前的服务中,有一个 key 存了接近 200MB 的数据,读取倒不算太慢,但每次执行DEL时,主线程会花费很长时间来释放这块内存,导致其他命令明显卡顿。后来改用UNLINK(异步删除)才解决。这跟 SDS 本身没有直接关系,但大 String 的底层就是一大块连续内存,释放它需要时间。如果你们有大 key 场景,务必用异步删除,或者拆分到多个 key 里。
5.4 为什么INCR那么快:int 编码的功劳
INCR命令的性能极高,除了 Redis 单线程事件循环带来的原子性之外,int编码也是一个关键因素。当值以int编码存储时,INCR直接读取ptr里的 long 值,加一,再写回,全程没有字符串解析、没有内存分配。如果值是字符串编码,INCR就需要先把字符串转成 long,变成数字,执行运算,再转成字符串,编码可能还会在int和raw之间切换,成本高很多。
我在压测里观察到一个有意思的现象:对int编码的 key 执行INCR,QPS 比对字符串编码的 key 执行INCR高出 20% 左右。所以如果你想发挥 Redis 计数器的最大性能,初始化时就用SET mycounter 0(能转为 long 的字符串),不要用SET mycounter "0"这样带引号的写法。效果一样,但底层编码路径完全不同。
6. 踩坑实录:关于 String 的常见问题排查
6.1 为什么debug object看到的编码和你预想的不一样
线上排查时,很多人习惯用object encoding key去看某个 key 的编码。这里有几个容易误判的点:
- 一个长度 100 的纯数字字符串
"1234567890123456789012345678901234567890...",它的编码是raw,不是int,因为它超过了 long 的表示范围。 - 一个长度 10 的字符串
"hello",编码是embstr,但如果它被APPEND过一次,编码就变成raw。 - 空字符串
""的编码既可以是embstr(长度 0,小于 44 字节),也可能是raw,取决于创建路径。
遇到“编码和预期不符”的情况,先查三点:字符串能不能被解析为 long、长度有没有超过 44、之前有没有执行过修改类命令。这条排查路径基本能覆盖 99% 的异常。
6.2 存储二进制数据时的“意外截断”
回到开头的那个问题:为什么存了二进制数据,取出来发现被截断了?我们用 SDS 的视角再看一遍,这个问题的原因往往不在 Redis,而在客户端。很多语言的 Redis 客户端在组装命令时,用的还是以\0为终止符的字符串函数,如果 value 里带\0,客户端做协议解析时可能会提前截断。
排查技巧:用redis-cli --raw直接操作,确认数据能不能完整写入和读取。如果redis-cli没问题,那问题大概率出在客户端语言对二进制数据的处理上。尤其是在 C/C++ 的客户端里,记得显式传入长度,不要依赖字符串终止符。在 Python 里,确保用 bytes 类型而不是 str 类型,在 Java 里确保用 byte[] 而不是 String。
6.3SDS内存开销在短小 value 场景下怎么优化
如果你用 Redis 存储大量短小的字符串,比如几十个字符的 token、验证码、短链接,SDS 的对象头开销会非常显眼。优化方向有两个:
- 改用 Hash 结构,把多个短字符串聚合成一个对象,减少
redisObject数量。 - 调整 jemalloc 的分配策略,或者直接换用 tcmalloc,这需要重新编译 Redis,适合对内存效率有极致要求的场景。
我在一个验证码存储场景里做过对比:原本用 String 存 1 亿个 6 位验证码,内存占用约 1.5GB;改成 Hash 分片存储后,内存降到约 900MB。这个效果直接来源于减少了对象头开销。
6.4 关于sdshdr5的一个历史遗留小坑
Redis 6.0 及之前,sdshdr5在部分版本中其实很少被主动使用,导致短字符串的内存优化效果没完全发挥。到了 6.2、7.0,sdshdr5的使用策略有调整。如果你在源码分析时发现sdshdr5相关的代码分支晦涩难懂,不必太纠结,它只是 SDS 体系里的一个“特例”类型。重点理解sdshdr8/16/32/64这套体系就足够覆盖绝大多数业务场景了。
还有一个相关的小知识点:sdshdr5的flags字段高 5 位直接存长度,所以它的长度最大值是 31((1<<5)-1),超过就会升级。而这个升级逻辑也不是单纯的“长度超过就换”,它会重新走sdsnewlen的创建流程。如果你在做源码二次开发,务必留意这个边界,否则容易在短字符串上踩到隐形 bug。
6.5 从源码角度看SET和SETEX的微妙差异
SET和SETEX最终都会创建一个字符串对象,但实现路径有一些不同。SET走的是setGenericCommand,SETEX走的是带过期时间的版本。它们在底层最终都会调用c->argv[2]来创建或更新字符串对象,过期时间的处理是在对象创建之外额外设置的。
对 Redis 源码感兴趣的同学可以顺着t_string.c往下读,里面能清晰看到setGenericCommand如何根据参数拼接命令、如何检查 NX/XX 条件、如何调用setKey和setExpire。你会发现,Redis 的 String 表面上“简单”,源码实现里各种分支和边界检查却非常庞杂。读这部分代码时,建议带着我前面讲的对象头、SDS、编码选择这三个概念去看,会顺畅很多。
7. 写在最后的几个经验
如果让我总结这篇源码深究里最值得记住的三件事,我会说:
第一,Redis 的 String 是一个设计上非常“抠”的结构。从五种sdshdr头部,到packed内存布局,再到 44 字节的embstr临界点,全都是在和内存分配器较劲。Redis 快,是因为它在每一个字节上都精打细算。
第二,理解 SDS 之后,很多 Redis 行为不再神秘。为什么APPEND一个短字符串之后编码变成raw?为什么used_memory比数据本身大?为什么INCR快得不可思议?这些问题的答案都有同一个源头:底层存储结构决定了上层行为。
第三,源码分析不能停留在“看懂了”的层面。我的习惯是,每分析一个结构,就在本地用最小复现脚本验证一遍:写个程序用object encoding看编码切换、用memory usage量内存、用slowlog看命令耗时。代码不会骗人,但注释会过时,博客会失真,只有实测结果是最可靠的。
最后分享一个小技巧:读 Redis 源码时,如果某个宏或结构体看不明白,去server.h和sds.h里搜它的定义和所有调用点。比如sdslen这个函数,在源码里被调用了上千次,你随手点开几个调用点看它怎么配合其他函数使用,会比单纯盯着定义理解得深得多。这也是我这些年读 C 项目源码积累下来的习惯,分享给想深入研究的朋友。