Redis源码学习(1)──字符串
redis 版本:5.0
本文代码在Redis源码中的位置:redis/src/sds.c、redis/src/sds.h
源码整体结构
src:核心实现代码,用 C 语言编写
tests:单元测试代码,用 Tcl 实现
deps:所有依赖库
字符串存储结构
Redis 将字符串的实现称为 sds(simple dynamic string)。为了提高存储空间的利用率,Redis 对不同长度的字符串,采用不同的数据结构。
以下是长度小于32的字符串的存储结构:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags;
char buf[];
};
其中,flags
:高5位指示字符串真实长度,低3位指示数据类型;buf
:柔性数组,指向字符串存储地址。
注:柔性数组是在 C99 及以上标准才支持,其目的是为了在结构体中能够“动态”地设定数组的长度。但需要注意的是,对结构体进行
sizeof
时,柔性数组的大小不被计算在内。因此在对结构体分配大小时,需要注意加上柔性数组的大小。柔性数组必须被声明在结构体的最后,其起始地址与上一字段的末尾地址相连。
其他长度的字符串的存储结构与其类似,结构分别如下:
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; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
其中,len
表示字符串实际长度;alloc
表示申请的字符数组的长度。
可以看出,Redis 最长能存储长度大约为 \(1.84* 10^{19}\) (\(2^{64}-1\)) 的字符串。
注:在声明结构体时,
struct __attribute__ ((__packed__))
语法用来告诉编译器采用紧凑的方式存储数据,即1字节对齐,而不是默认的所有变量大小的最小公倍数做字节对齐。以
sdshdr32
为例,若采用1字节对齐,len
,alloc
,flags
一共占9个字节(4+4+1),而默认会使用4字节对齐,这样flags
也会占用4个字节,一共占用12个字节。采用 packed 的好处是明显的,一来可以减少数据的大小,提高空间利用率;二来这为地址计算带来了方便:无论哪种类型(除
sdshdr5
外),使用buff[-1]
即能获取到flag
。
字符串基本操作
创建
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;
int hdrlen = sdsHdrSize(type);
unsigned char *fp;
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: {
SDS_HDR_VAR(16,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
}
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = '\0';
return s;
}
以上代码比较简单,但有几点需要说明:
- 从第5行代码可以看出,对于空字符串,Redis 将其做为
sdshdr8
类型而不是sdshdr5
类型。其给出的原因是:用户很有可能会在空字符串后追加新的字符串,因此用一个存储长度适中的结构。 sh
指针指向对应结构体的起始地址,其长度为结构体大小 + 待存储字符串的大小 + 1。最后加1是为了在末尾追加一个\0
符。s
指针即为以上提到的柔性数组起始地址。fp
指针地址正好在s
指针指向地址的前一字节,指向flags
。- 第19行代码中,
initlen << SDS_TYPE_BITS
将initlen
左移3位,正好印证之前提到的flags
使用高5位存储字符串长度。之后或上type
,即把低3位置为type
的值(因为之前左移3位后,低3位值均为0,这样或运算的值就是type
)。 SDS_HDR_VAR
宏定义为#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
。其中的##
表示字符连接的意思。以23行代码为例,该行代码在经编译器预处理后,变为struct sdshdr8 *sh = (void*)((s)-(sizeof(struct sdshdr8)))
,语义为sh = s - sizeof(sdshdr8)
。暂时还没弄明白这里为啥要重算一下sh
。按理说第15行代码s = (char*)sh+hdrlen;
中,s
正是由sh +sizeof(sdshdr8)
得到。- 注意,最终返回给外部的是
s
指针而不是结构体指针。
其中的sdsReqType
方法用于根据待存储字符串长度选择合适的存储类型,代码如下:
static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5)
return SDS_TYPE_5;
if (string_size < 1<<8)
return SDS_TYPE_8;
if (string_size < 1<<16)
return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
if (string_size < 1ll<<32)
return SDS_TYPE_32;
return SDS_TYPE_64;
#else
return SDS_TYPE_32;
#endif
}
注:
LONG_MAX
表示long
类型整数最大值,与编译器有关。通常64位编译器值为 \(2^{63} - 1\),32位编译器为 \(2^{32}-1\)。LLONG_MAX
表示long long
类型整数最大值,通常32位和64位编译器值均为 \(2^{63} - 1\)。参考:
注:static inline 关键字是在建议编译器,以类似于宏定义的方式对待该函数,即在编译阶段,直接将该函数的相关指令插入到调用该函数的地方。这样做可减少函数调用的开销。
sdsHdrSize
函数用于计算对应存储类型的大小,代码如下:
static inline int sdsHdrSize(char type) {
switch(type&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return sizeof(struct sdshdr5);
case SDS_TYPE_8:
return sizeof(struct sdshdr8);
case SDS_TYPE_16:
return sizeof(struct sdshdr16);
case SDS_TYPE_32:
return sizeof(struct sdshdr32);
case SDS_TYPE_64:
return sizeof(struct sdshdr64);
}
return 0;
}
其中,SDS_TYPE_MASK
的值为7,二进制即为:0000 0111
,type
和它做与运算,正好得出自己的后3位的值,即类型值。
删除
删除字符串的方法有两种,一种是真正释放了内存,代码如下:
void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}
其中 s[-1]
之前也提到过,就是 flags
的值。而 s-sdsHdrSize(s[-1])
则得到了 sdshdr
结构体的起始地址。
另一种则只是将长度置为0,并没有真正释放内存,这么做的目的当然是为了下次存储字符串时,无需重新申请内存,直接再用即可。
void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}
其中,sdssetlen
函数用来设置字符串长度,代码如下:
static inline void sdssetlen(sds s, size_t newlen) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
{
unsigned char *fp = ((unsigned char*)s)-1;
*fp = SDS_TYPE_5 | (newlen << SDS_TYPE_BITS);
}
break;
case SDS_TYPE_8:
SDS_HDR(8,s)->len = newlen;
break;
case SDS_TYPE_16:
SDS_HDR(16,s)->len = newlen;
break;
case SDS_TYPE_32:
SDS_HDR(32,s)->len = newlen;
break;
case SDS_TYPE_64:
SDS_HDR(64,s)->len = newlen;
break;
}
}
追加(cat)
追加操作用于在一个字符串后面添加另一字符串,其代码如下:
sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t));
}
连接后,s
将指向连接后的字符串。
sdscatlen
函数逻辑也很简单:扩容(若需要) -> 复制追加内容 -> 修改长度。代码如下:
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
其中 sdsMakeRoomFor
为扩容函数,根据剩余可用空间大小和待追加的字符串长度决定是否扩容。它保证了其返回的指针指向的内存区域,一定能容纳追加的字符串(若内部的内存申请成功的话)。代码如下:
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* Return ASAP if there is enough space left. */
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;
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);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, newlen);
return s;
}
其基本逻辑为:
- 若剩余容量足够容纳待追加的字符串,则无需扩容(第9行)。
- 否则,算出追加后的字符串的长度
newlen
(第13行)。第13行~第17行代码还对newlen
做了调整。暂时不知道为啥要这么做。 - 之后根据
newlen
决定使用什么类型进行存储。若类型还和之前一样,则继续沿用之前的内存结构,但需要使用s_realloc
扩大之前的内存空间(第28行);否则则需要重新申请一块新的内存(第34行),将之前的数据复制到新内存中(第36行)。
连接(join)
连接操作是将一个字符串数组的所有元素串联在一起,若给定了分隔符,各元素之间还需插入分隔符。例如,若字符串数组 s = ["a", "b", "c"]
,分隔符 sep = ","
,则 join(s, sep)
的结果为:a,b,c
。其代码如下:
sds sdsjoin(char **argv, int argc, char *sep) {
sds join = sdsempty();
int j;
for (j = 0; j < argc; j++) {
join = sdscat(join, argv[j]);
if (j != argc-1) join = sdscat(join,sep);
}
return join;
}
代码很简单,就不多做说明了。其中,sdsempty()
是构造一个空串,即:sdsnewlen("",0)
分割参数
sdssplitargs
函数用于将一条命令按参数分割成一个字符串数组,代码如下:
sds *sdssplitargs(const char *line, int *argc) {
const char *p = line;
char *current = NULL;
char **vector = NULL;
*argc = 0;
while(1) {
/* skip blanks */
while(*p && isspace(*p)) p++;
if (*p) {
/* get a token */
int inq=0; /* set to 1 if we are in "quotes" */
int insq=0; /* set to 1 if we are in 'single quotes' */
int done=0;
if (current == NULL) current = sdsempty();
while(!done) {
if (inq) {
if (*p == '\\' && *(p+1) == 'x' &&
is_hex_digit(*(p+2)) &&
is_hex_digit(*(p+3)))
{
unsigned char byte;
byte = (hex_digit_to_int(*(p+2))*16)+
hex_digit_to_int(*(p+3));
current = sdscatlen(current,(char*)&byte,1);
p += 3;
} else if (*p == '\\' && *(p+1)) {
char c;
p++;
switch(*p) {
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'b': c = '\b'; break;
case 'a': c = '\a'; break;
default: c = *p; break;
}
current = sdscatlen(current,&c,1);
} else if (*p == '"') {
/* closing quote must be followed by a space or
* nothing at all. */
if (*(p+1) && !isspace(*(p+1))) goto err;
done=1;
} else if (!*p) {
/* unterminated quotes */
goto err;
} else {
current = sdscatlen(current,p,1);
}
} else if (insq) {
if (*p == '\\' && *(p+1) == '\'') {
p++;
current = sdscatlen(current,"'",1);
} else if (*p == '\'') {
/* closing quote must be followed by a space or
* nothing at all. */
if (*(p+1) && !isspace(*(p+1))) goto err;
done=1;
} else if (!*p) {
/* unterminated quotes */
goto err;
} else {
current = sdscatlen(current,p,1);
}
} else {
switch(*p) {
case ' ':
case '\n':
case '\r':
case '\t':
case '\0':
done=1;
break;
case '"':
inq=1;
break;
case '\'':
insq=1;
break;
default:
current = sdscatlen(current,p,1);
break;
}
}
if (*p) p++;
}
/* add the token to the vector */
vector = s_realloc(vector,((*argc)+1)*sizeof(char*));
vector[*argc] = current;
(*argc)++;
current = NULL;
} else {
/* Even on empty input string return something not NULL. */
if (vector == NULL) vector = s_malloc(sizeof(void*));
return vector;
}
}
err:
while((*argc)--)
sdsfree(vector[*argc]);
s_free(vector);
if (current) sdsfree(current);
*argc = 0;
return NULL;
}
以上代码总体上是在对字符串 line
逐个字符进行遍历,指针 p
做为游标指向当前访问的字符。
主体上,代码由两层 while
循环构成。外层 while
循环(第7行)条件总是成立,因此该函数要么出错结束(第102行~末尾),返回 NULL
,要么从第98行成功结束,此时的条件是 p
指针正好指向字符串末尾的 \0
,返回分割出的参数数组 vector
。而内层 while
循环执行结束后(第91行),会识别出一个参数,并会将该参数添加到 vector
数组中(第92行)。
再来看内层 while
循环是如何识别一个参数的。这段代码(第17行到89行)总体结构是 if...else if ... else ...
。进入前两分支的条件是处于双引号模式或单引号模式下,否则进入 else
分支。每轮循环结束,游标 p
向后挪动一位(第88行)。
双、单引号模式由 inq
和 insq
变量指示,前者表示当前访问的字符在单引号内(即之前已访问了起始单引号,但还未访问到闭合单引号),后者则表示是在双引号内。进入这两个模式的时机当然是遇到了双引号(第77行)或单引号(第80行)。
在非双、单引号模式下,若遇到一般字符,都会将该字符追加到 current
字符串中(current
在遍历开始是空串);若遇到 空格
、'\n'
、'\r'
、'\t'
、'\0'
,内层循环都将结束,并将 current
做为识别到的一个参数。
在双、单引号模式下,若是遇到 "\n"
、"\r"
、"\t"
、"\b"
,"\a"
(注意,这些都是两个字符而不是一个),会将其做为单字符 '\n'
、'\r'
、'\t'
、'\b'
、'\a'
加入 current
中(第41行);若字符串遍历完成都没遇到闭合的双、单引号,则会报错(第49行和第64行);闭合的双、单引号后面不是空格(若字符串后面还有其他字符),也会报错(第44行和第60行)。
另外,在双引号模式下,会将格式类似于 "\x41"
的子串,做为16进制数,转成字符类型后追加到 current
中(第19行~第28行)。该例中,"\x41"
将做为字母 A
。
以上便是 Redis 字符串操作最主要的函数,还有一些比如字符串大小写转换,比较等函数实现均非常简单,这里不再赘述,有兴趣可以去看看源码。