太长不看版
- redis内部使用sds(其实就是在字符串前边加了额外的信息(sdshdr))作为默认字符串类型
- sdshdr中len字段记录了字符串长度,可以常数复杂度获取长度
- sds使用sdshdr中的len字段来判断字符串是否结束,实现了二进制安全
- 修改sds时会按需分配内存大小,杜绝缓冲区溢出
- sds空间扩展时会进行预申请,减少内存分配次数
预申请策略: 1m以内, 每次为len * 2,超过1m每次为 len + 1m- sds支持部分c语言标准库的里的字符串操作函数(因为末尾特意加了’\0’)
本篇解析基于redis 5.0.0版本,涉及源码文件为sds.c与sds.h。
redis内部实现中没有直接使用c语言中原始的字符串(字符数组),而是对原始字符串进行了封装,使用名为sds(simple dynamic string)的抽象类型作为默认字符串类型。
sds定义:
// sds定义
typedef char *sds;
// sds的创建函数
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
// ...
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */
// 申请内存为 头部信息长度 + 字符串长度 + 1
// sh 指向整体数据开头
sh = s_malloc(hdrlen+initlen+1);
// ...
// s实际类型为char* 指向原始字符串开头
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
switch(type) {
// ...
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
// ...
if (initlen && init)
memcpy(s, init, initlen);
// 此处末尾加上'\0'是为了能够使用标准库里的一些操作函数
s[initlen] = '\0';
// 注意,此处返回的是指向原始字符串开始的指针
return s;
}
从上述定义可以看到,sds实际上是对char*的重定义。而从创建函数的操作可以看到,sds实际上就是在原始字符串的前边加上了一些头部信息。
举个例子:
sds 字符串 hello的数据结构(使用sdshdr8):
头部信息(sdshdr) | 字符串 | \0 |
---|---|---|
len(4) alloc(8) flags(1) | h e l l o | \0 |
而初始化函数返回的以及代码中使用的是指向原始字符串的指针,所以经常会在代码里看到s[-1](*(s - 1的语法糖))这种操作来取头部类型flags字段。
而获取字符串头部信息则是用s指针前移至头部信息开始来获取,定义如下:
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
看完了sds的定义,我们再来看看提了半天的头部信息结构到底是怎样的。
sds头部信息(sdshdr)定义
/* 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[];
};
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[];
};
sds的头部信息一共有5个,根据长度选取对应的类型。
其中sdshdr5因为没有空间存储alloc字段(长度信息和字符串信息存在一起),所以只用于不存在变更需求的短字符(小于32字节)中,比如小于32字节的键值。具体详情戳这里,
而attribute ((packed))则是为了告诉gcc取消优化对齐。
例如:
struct sdshdr {char flag; int len;};
//sizeof(sdshdr_test) == 8;
struct attribute ((packed)) sdshdr {char flag; int len;};
//sizeof(sdshdr_test) == 5;
以上的操作归根结底都是为了减少head的实际长度。戳这里了解详情
概括一下sds头部信息定义(实际上初版的sds头部信息定义就是这样的):
struct sdshdr {
unsigned int len; /* 字符串长度 */
unsigned int alloc; /* 实际分配内存长度 */
unsigned char flags; /* sds类型 */
char buf[]; /* 实际的数据 */
};
开始我们说了, sds和原始的字符串信息区别就是加了上述的这些头部信息。那么接下来我们再来看看费了这么多劲加这些额外的信息有什么用?
sdshdr的引入解决了什么问题
1. 二进制安全
维基百科:
Binary-safe is a computer programming term mainly used in connection with string manipulating functions.A binary-safe function is essentially one that treats its input as a raw stream of data without any specific format. It should thus work with all 256 possible values that a character can take (assuming 8-bit characters).
二进制安全是一种主要用于字符串操作函数相关的计算机编程术语。一个二进制安全功能(函数),其本质上将操作输入作为原始的、无任何特殊格式意义的数据流。其在操作上应包含一个字符所能有的256种可能的值(假设为8为字符)
众所周知,c语言中使用字符数组作为字符串实现,使用’\0’表示结尾,因为’\0’这个‘魔数’的存在,使得c语言原始的字符串是不符合二进制安全的。
sds使用sdshdr中的len字段来判断字符串是否结束,实现二进制安全(二进制中包含\0,原生字符串会截断)。
2. 缓冲区溢出
缓冲区溢出指当某个数据超过了处理程序限制的范围时,程序出现的异常操作。
c语言中字符串处理,字符串指针对应的内存都是由编码者自己控制,保证已经分配了足够多的内存。 然而一旦当分配的内存不足,就出现缓存区溢出。
sds的修改函数在执行修改前会进行判断内存,动态的分配内存,杜绝缓冲区溢出的可能。
例如sds拷贝函数:
/* Destructively modify the sds string 's' to hold the specified binary
* safe string pointed by 't' of length 'len' bytes. */
sds sdscpylen(sds s, const char *t, size_t len) {
//如果目标sds已分配的内存长度小于需要拷贝的长度
if (sdsalloc(s) < len) {
//则进行内存重新分配
s = sdsMakeRoomFor(s,len-sdslen(s));
if (s == NULL) return NULL;
}
memcpy(s, t, len);
//字符串尾部加上'\0',是为了可以使用部分标准库函数,减少工作量
s[len] = '\0';
sdssetlen(s, len);
return s;
}
3. 频繁的内存分配
C字符串如果一直使用与长度匹配的内存,会导致每次字符串长度变化时,就必须进行内存的重新分配。内存重分配设计复杂算法,同时可能执行系统调用,会很耗时。对于redis作为一个内存数据库,势必会面对数据频繁改变的场景,所以如果使用原生的字符串数据,会引发频繁的内存重分配,这个显然是不可接受的。
所以sds每次内存分配时,会通过内存的预申请减少因为修改字符串而引发的内存重分配次数。具体策略是1M(1024 * 1024)以内,每次len * 2, 超过1M每次 len + 1M 。
/* Enlarge the free space at the end of the sds string so that the caller
* is sure that after calling this function can overwrite up to addlen
* bytes after the end of the string, plus one more byte for nul term.
*
* Note: this does not change the *length* of the sds string as returned
* by sdslen(), but only the free buffer space we have. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
//当前sds可用的内存长度,申请的 - 已用的
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);
// #define SDS_MAX_PREALLOC (1024*1024)
if (newlen < SDS_MAX_PREALLOC)
// 小于1m, 实际申请内存长度为当前申请的2倍
newlen *= 2;
else
// 大于1m, 实际申请内存长度为当前申请 + 1m
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;
}
4. O(n)复杂度的长度获取
原生的c语言字符串,获取长度需要遍历字符串直到遇到’\0’,需要O(n)复杂度的操作。
sds头部信息中len字段记录了字符串长度,可以常数复杂度获取长度。
static inline void sdssetlen(sds s, size_t newlen) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
{
// sdshdr5 没有len字段,所以长度信息和头部信息类型放在一起
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;
}
}
/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
* end of the specified sds string 's'.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */
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;
}