当今天在网上寻找Redis字符串的实现相关的文章时,我找到的依然是一堆Redis3时代的文章,大量老旧的sds相关的内容,仿佛无论新旧,Redis的字符串就是这样实现的。但事实上,早在Redis3.2之后,字符串早就不是之前的sds了结构了。那Redis3.2.0是什么时候发布的呢?答:2016年5月,没错,一个已经过时6年的方案,至今天还流行在很多文章之中~
一、Redis3.0时代的字符串
1.sds基本结构
这个基本就不用多说了,在 sds.h 文件内可以看到 sdshdr 的定义
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
len 表示长度,free 表示剩余容量,buf 就是字符串数据。
字符串使用 len 来表示长度,而不是 c 中的以 '\0' 结尾,这样可以保证字符串二进制安全,即字符串内即使有 '\0' 也不会出错,同时也使获取长度的时间复杂度从 O(n) 降到了 O(1)
2.sds字符串拼接
sds字符串的拼接函数在 sds.c 内,较为值得看一下,可以更好的理解下sds结构。
#define SDS_MAX_PREALLOC (1024*1024)
typedef char *sds;
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len); //先扩容
if (s == NULL) return NULL;
sh = (void*) (s-(sizeof(struct sdshdr)));
memcpy(s+curlen, t, len); //将新字符串拷贝到原字符串后
sh->len = curlen+len;
sh->free = sh->free-len;
// 在末尾增加 '\0'
// 为什么已经有len表示长度了,还要在末尾增加 '\0' 呢?
// 其实是为了兼容 <string.h> 部分库函数,如 strcasecmp 一类的
s[curlen+len] = '\0';
return s;
}
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
size_t free = sdsavail(s);
size_t len, newlen;
if (free >= addlen) return s; \\如果剩余空间足够,直接返回
len = sdslen(s);
// 变量s是sdshdr结构体中buf的指针,所以s-(sizeof(struct sdshdr))其实就是将指针向前移动一个sdshdr的长度,也就是指向了sdshdr结构体的头部。
// 而将其指向头部的原因就是为了覆盖原有的sdshdr
sh = (void*) (s-(sizeof(struct sdshdr)));
newlen = (len+addlen);
// SDS_MAX_PREALLOC 是1mb,如果新字符串小于1mb,则每次扩容会翻倍,如果大于1mb,每次扩容会在后面在加1mb
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
if (newsh == NULL) return NULL;
newsh->free = newlen - len; //更新剩余空间大小
return newsh->buf;
}
所以原有的sds字符串结构可以说很好理解。
二、Redis3.2之后的字符串结构
1.hisds字符串
在查看新版Redis的源码时,我们可以看一以 sds.h 内已经没有原来的 sdshdr 结构体了,取而代之的是 hisdshdr
typedef char *hisds;
/* 注意:hisdshdr5 并未使用 */
struct __attribute__ ((__packed__)) hisdshdr5 {
unsigned char flags; /* 低三位表类型,高五位表长度 */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr8 {
uint8_t len;
uint8_t alloc; /* 不包括头部和结尾的 '\0' */
unsigned char flags; /* 低三位表类型,高五位未使用 */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr64 {
uint64_t len;
uint64_t alloc;
unsigned char flags;
char buf[];
};
我们可能看到,hisdshdr 并不是一个结构体,而是多个。
__attribute__ ((__packed__))
表示结构体不使用内存对齐。
举个例子:
struct A {
int dat;
char buf[];
}
请问上面的结构体占用了几个字节?
int 为 32 位,需要4个字节,而 char[] 则只是指向第一个字符,所以只需要1个字节,那结构体A应该占用5个字节。
然后我们 sizeof 一下试试,结果竟然是 8
原因就是内存对齐,char[] 会和 int 对齐,使其也占用了4个字节。
__attribute__ ((__packed__))
则表示该结构体不使用内存对齐。
struct __attribute__ ((__packed__)) A {
int dat;
char buf[];
}
此时的 A 在 sizeof 一下,结果就是5了。
2.hisds字符串的拼接
为了进一步了解hisds,我们在来看看它的拼接函数,和sdshdr的拼接有何不同。
#define HI_SDS_TYPE_5 0
#define HI_SDS_TYPE_8 1
#define HI_SDS_TYPE_16 2
#define HI_SDS_TYPE_32 3
#define HI_SDS_TYPE_64 4
#define HI_SDS_TYPE_BITS 3 // flag中表示类型的位数,只有低三位表示类型
#define HI_SDS_TYPE_MASK 7 // 只有低三位表类型,所以使用00000111 & flag 来取类型
// 通过 s 返回 hisdshdr 即 hisds 的头部指针,hisdshdr##T 表示将参数 T 转成文本
// 例如:HI_SDS_HDR(8,s) hisdshdr##T 即会变成 hisdshdr8
#define HI_SDS_HDR(T,s) ((struct hisdshdr##T *)((s)-(sizeof(struct hisdshdr##T))))
// 由于 hisdshdr5 中,使用flag的高5位表示长度,所以它的长度是 flag >> 3
#define HI_SDS_TYPE_5_LEN(f) ((f)>>HI_SDS_TYPE_BITS
hisds hi_sdscatlen(hisds s, const void *t, size_t len) {
size_t curlen = hi_sdslen(s);
s = hi_sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
hi_sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
static inline void hi_sdssetlen(hisds s, size_t newlen) {
unsigned char flags = s[-1];
switch(flags&HI_SDS_TYPE_MASK) {
case HI_SDS_TYPE_5:
{
unsigned char *fp = ((unsigned char*)s)-1;
*fp = (unsigned char)(HI_SDS_TYPE_5 | (newlen << HI_SDS_TYPE_BITS));
}
break;
case HI_SDS_TYPE_8:
HI_SDS_HDR(8,s)->len = (uint8_t)newlen;
break;
case HI_SDS_TYPE_16:
HI_SDS_HDR(16,s)->len = (uint16_t)newlen;
break;
case HI_SDS_TYPE_32:
HI_SDS_HDR(32,s)->len = (uint32_t)newlen;
break;
case HI_SDS_TYPE_64:
HI_SDS_HDR(64,s)->len = (uint64_t)newlen;
break;
}
}
和之前sdscatlen
的函数相比,新的 hi_sdscatlen
基本没太大变化。
其中的 hi_sdsMakeRoomFor
函数即使我们不看,也能猜到其是用于扩容的,那我们一会在看它。
与之前相比,hi_sdscatlen
还用了一个 hi_sdssetlen
替代了之前直接设置len和free的代码。
而 hi_sdssetlen
的定义则比较有趣。首先,s[-1]来获取flag,因为 s 正是 hisdshdr 中的 char buf[]
而它的上一个字段正是 unsigned char flag
所以可以直接用 s[-1]的方式拿到 flag。
然后通过 flag 判断类型,并设置新的长度。
现在呢,我们在来看看 hi_sdsMakeRoomFor
虽然我们知道这个函数是扩容用的,但也要看看它的实现
hisds hi_sdsMakeRoomFor(hisds s, size_t addlen) {
void *sh, *newsh;
size_t avail = hi_sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & HI_SDS_TYPE_MASK;
int hdrlen;
/* 如果剩余空间足够,则直接返回 */
if (avail >= addlen) return s;
len = hi_sdslen(s);
sh = (char*)s-hi_sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < HI_SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += HI_SDS_MAX_PREALLOC;
/* 通过新长度判断新类型 */
type = hi_sdsReqType(newlen);
/* 不使用 hisdshdr5 */
if (type == HI_SDS_TYPE_5) type = HI_SDS_TYPE_8;
hdrlen = hi_sdsHdrSize(type);
/* 如果头部类型没变,那直接在原内存上重写 */
if (oldtype==type) {
newsh = hi_s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* 如果头部类型变了,那它所需的空间也会变化,所以需要重新分配内存空间 */
newsh = hi_s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
hi_s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
hi_sdssetlen(s, len); //设置长度
}
hi_sdssetalloc(s, newlen); //设置剩余空间
return s;
}
可以看到,hi_sdsMakeRoomFor
的逻辑和 sdsMakeRoomFor
是基本一致的,但部分位置不同。
比如,长度扩展都是一样的,如果小于1mb,每次扩容则翻倍,如果大于1mb,则每次只增加1mb。
但不同的是,hisds 需要通过新长度判断类型。即 type = hi_sdsReqType(newlen);
比如当新长度大于 256,那就要换用 hisdshdr16 来表示了。其它同理。
而 hi_sdsReqType
就是最简单的大小判断。定义如下:
static inline char hi_sdsReqType(size_t string_size) {
if (string_size < 32)
return HI_SDS_TYPE_5;
if (string_size < 0xff)
return HI_SDS_TYPE_8;
if (string_size < 0xffff)
return HI_SDS_TYPE_16;
if (string_size < 0xffffffff)
return HI_SDS_TYPE_32;
return HI_SDS_TYPE_64;
}
其实Redis的代码并不多,也很好理解,只需要几个实例就基本可以窥其一二,通过理解拼接函数,我们基本可以看懂大部分的sds中的代码了。
三、结语
相对而言,hisds也只是头部变了,本身和 sds 并没有太大区别。那为什么Redis要这么改?
其实答案就是为了剩那一捏捏的内存~
一个 sdshdr 占多少内存?
len 和 free 都是 uint ,各占4个字节;
buf 是 char[] 占1个字节,但由于sdshdr是内存对齐的,所以也占了4个字节;
也就是说,sdshdr占了12个字节。
那 hisdshdr 占多少字节呢?
hisdshdr8 的话,len 和 alloc 都是 uint8,各占1个字节;
flag 是 char 类型,占1个字节;
buf 是 char[] 类型,占1个字节;
也就是说,hisdshdr8 只占用 4个字节。同理,hisdshdr16占用6个字节,hisdshdr32占用10个字节,hisdshdr64占用18个字节。
所以 hisdshdr 不仅可以表达更长的uint64位长度的字符串,而且在一般情况下还只占用更小的内存。
所以说,Redis也真不愧是一个把内存省到极致的工具~
Q.E.D.