乐趣区

【Redis源码分析】一个对SDSHDR5是否使用的疑问

熊浩含
问题提出
1、在 Redis 源码中有一句注释,是对 sdshdr5 的解释:
/* 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[];
};
那么 sdshdr5 真的不使用了吗
2、在 Redis5 中,执行以下命令,key 和 value 最终是用哪种 sds 存放?
比如:
> set a ttt
sds 基础回顾
从 Redis3.2 开始,sds 就有了 5 种类型,5 种类型分别存放不同大小的字符串。在创建字符串时,sds 会根据字符串的长度选择不同的类型。最终由 sdsnewlen 函数创建字符串:
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;// 为空时强制用 sdshdr8
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */

sh = s_malloc(hdrlen+initlen+1);
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_16: {

}
case SDS_TYPE_32: {

}
case SDS_TYPE_64: {

}
}
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = ‘\0’;
return s;
}
除了创建空字符串时会强转为 SDS_TYPE_8 外,没有什么其它特别之处了。
gdb 结果
问题中的 key 和 value 都是长度短于 32 的字符串,似乎应该都用 sdshdr5 来存。但 gdb 打印后发现,key 确实是用 sdshdr5 存储的,但 value 却是用 sdshdr8 存储的。在 getCommand 函数处打断点,打印 c -db->dict 中的相关内容:

分别打印 key 和 val 的值,其中 key 是 sds,val 是 robj。结果如下:
(gdb) p (sds)0x7f09d2009830
$117 = 0x7f09d2009830 “\ba”
(gdb) p *(robj*)0x7f09d2029830
$118 = {type = 0, encoding = 8, lru = 1536715, refcount = 1, ptr = 0x7f09d2029843}
(gdb) p (sds)0x7f09d2029842
$119 = 0x7f09d2029842 “\001ttt”

ttt 前的 001,代表 flags 是 00000001(二进制),低三位表类型,意味着存 ttt 所用的类型为 SDS_TYPE_8
a 前的 b,代表 flags 是 00001000(二进制),低三位表类型,意味着存 a 所用类型为 SDS_TYPE_5

#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
set 命令流程
光看 sdsnewlen 无法解释问题,执行
>set a ttt
入口函数是 setcommand,我们从 setcommand 命令入口看起:
void setCommand(client *c) {

c->argv[2] = tryObjectEncoding(c->argv[2]);
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}
最终调 setGenericCommand,c->argv[1],c->argv[2] 是两个 robj, 存放着 key 和 value, 打印结果如下:
(gdb) p (sds)((*c->argv[1])->ptr-1)
$125 = 0x7f09d2029aca “\001a”
(gdb) p (sds)((*c->argv[2])->ptr-1)
$126 = 0x7f09d202988a “\001ttt”
可以看到,__两个 robj 底层的 sds_type 都是 sdshdr8__。为什么是两个 sdshdr8 呢?argv 应该是在命令解析的时候生成的,继续跟源码。命令解析的源头在 readQueryFromClient, 从 readQueryFromClient 一直往下跟,调用链如下:

最终走到了 createStringObject:
robj *createStringObject(const char *ptr, size_t len) {
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)//OBJ_ENCODING_EMBSTR_SIZE_LIMIT = 44
return createEmbeddedStringObject(ptr,len);
else
return createRawStringObject(ptr,len);
}
redis 在存储命令参数时,根据参数长度选不同的结构。有意思的是,参数长度小于 44 时,走 createEmbeddedStringObject 分支, 但 createEmbeddedStringObject 中又强制用 sdshdr8 来存字符串:
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);// 指定 sdshdr8

return o;
}
而当参数长度大于 44 时,走一般流程。此时创建的字符串长度既然大于 44,更大于 32 了,自然也不可能用 sdshdr5。换而言之,__从 Buffer 中解析出的命令参数,redis 统一用大于 sdshdr5 的结构存,这跟之前 gdb 的现象是一致的__。那什么时候 key 变回由 sdshdr5 存储了呢?回过头继续跟 setGenericCommand,调用链如下:
setGenericCommand–>setKey–>dbAdd
在 dbAdd 函数中,可以看到,redis 对待存入的 key 做了一次复制,__正是这次复制将 key 由之前的 sdshdr8 转成了 sdshdr5__:
void dbAdd(redisDb *db, robj *key, robj *val) {
sds copy = sdsdup(key->ptr);
int retval = dictAdd(db->dict, copy, val);

}
sdsdup 复制只看字符串内容,根据字符串内容创建新的 sds, 由于 key->ptr 指向的字符串是 ”a”, 故 copy 这个 robj 底层是个 sdshdr5。最终调用 dictAdd 时,键的 robj 底层是 sdshdr5, 而值的 robj 底层是 sdshdr8。
总结
最终可以确认,长度小于 32 的键值对,键的底层是 sdshdr5, 而值的 robj 底层是 sdshdr8。

Q1: 为什么用 sdshdr5 存 key 可以,存 value 不行?个人猜想是键不更新而值会更新,故键用尽可能小的结构存;值更新会引起扩容,索性直接用大些的结构存。

Q2: 为什么解析参数时,Redis 又抛弃了小的 sdshdr5?个人猜想是为了编码方便。不同命令的参数个数都不相同,一开始分不清哪个位置是 key 哪个位置是 value,索性统一处理,在具体场景下,再单独优化。

Q3: 源码里面的注释是不是错了呢?笔者给 Redis 作者发了一封邮件去确认下,还未收到回信。

退出移动版