简单动态字符串(simple dynamic string, 简称SDS),是redis数据类型字符串的底层数据结构,sds 有两个版本,在Redis 3.2之前和使用的是第一个版本,3.2及以后版本使用另外一个版本(今天主要讲3.2之后的SDS)
其数据结构如下所示:
//SDS定义
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度 占4字节
unsigned int len;
//记录 buf 数组中未使用字节的数量 占4字节
unsigned int free;
//字节数组,用于保存字符串
char buf[];
}
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
关于这个版本 ,buf[]所保存的字符串小于 39 字节时,SDS选择 embstr 编码,大于 39字节则 raw 编码(embstr 编码时3.0才出现的)。
为什么是以39字节为临界点?
首先参数REDIS_ENCODING_EMBSTR_SIZE_LIMIT默认值为39,其次就是内存的分配优化问题,embstr是一块连续的内存区域,由redisObject和sdshdr组成,redisObject占16个字节,当buf内的字符串长度是39时,sdshdr的大小为8+39+1=48 加1字节时buf结尾的‘\0’。加上redisObject刚好64字节,符合jemalloc内存分配器的分配策略 (分配当前值最接近2的n次方的空间,如50 就会分配64), 所以大于39 就会大于64 分配128 太浪费, 所以采取raw编码 redisObject和sdshdr 不连续的内存空间 所以这种编码要分配两次空间。
但是在Redis 3.2 版本中,对数据结构做出了修改,针对不同的长度范围定义了不同的结构,针对长度不同的字符串做了优化 结构如下:
typedef char *sds;
//sdshdr5从未使用过,我们只是直接访问标志字节。然而,这里是为了记录类型5 SDS字符串的布局
struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1<<5
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串长度小于 1<<8
uint8_t len; /* used */ //目前字符创的长度
uint8_t alloc; //已经分配的总长度
unsigned char flags; //flag用3bit来标明类型,类型后续解释,其余5bit目前没有使用
char buf[]; //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串长度小于 1<<16
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 { // 对应的字符串长度小于 1<<32
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 { // 对应的字符串长度小于 1<<64
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[];
};
sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 25=32B,28=256B,216=64KB,232=4GB,264约等于无穷大,但实际官方字符串value最大值为512M(可能考虑性能以及内存大小原因,具体为什么512M不太清楚,有大佬知道评论告知下)
对于这个版本,buf[]所存储的字符串大于44字节时,选择raw编码,小于44字节时则选择embstr编码。
为什么以44字节为临界点?
首先,rendis参数REDIS_ENCODING_EMBSTR_SIZE_LIMIT默认值时44,同3.2版本之前的SDS不同的是,SDS的头部信息占用空间更小了,代码中使用了uint8_t代替int,占用的内存空间更小了,sds的头部信息占用的空间大小:sdsdr8 = uint8_t(1个字节)* 2 + char(1个字节) = 3个字节 原来3.2版本前 头部是len和free 都是int 占4字节*2=8字节,这里就少了5字节 对应字符串能使用的长度就是39+5=44字节。 也可以使用sdshdr5分配32字节 也满足上面两个为什么都要64字节 因为分配32字节buf[]能用的就很少了 避免重新分配空间,官方也说sdshdr5这个从未使用过默认还是使用sdshdr8。
以下是不同长度字符串value的编码测试案例以及不同编码的结构图:
127.0.0.1:6379> set yangsong 123456789-123456789-123456789-123456789-1234
OK
127.0.0.1:6379> object encoding yangsong
"embstr"
127.0.0.1:6379> set yangsong 123456789-123456789-123456789-123456789-12345
OK
127.0.0.1:6379> object encoding yangsong
"raw"
127.0.0.1:6379> set yangsong 1234
OK
127.0.0.1:6379> object encoding yangsong
"int"
127.0.0.1:6379> set yangsong 1234567890123456789123456789234645645744534543
OK
127.0.0.1:6379> object encoding yangsong
"raw"
我们可能以为redis在内部存储string都是用sds的数据结构实现的,其实在整个redis的数据存储过程中为了提高性能,内部做了很多优化。整体选择顺序应该是:
embstr的创建只需分配一次内存,而raw为两次(一次为sds分配对象,另一次为redisObject分配对象,embstr省去了第一次)。相对地,;embstr释放内存的次数也为一次,raw为两次。embstr的redisObject和sds放在一起,更好地利用缓存带来的优势。 redis并未提供任何修改embstr的方式,即embstr是只读的形式。对embstr的修改实际上是先转换为raw再进行修改。
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到了代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。而 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为 O(1)。通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 O(N) 降低到 O(1),这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。同理还有使用的空间以及还剩多少空间可用的时间复杂度都是 O(1)。
//根据不同header类型返回SDS已经使用过的空间字符数
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
case SDS_TYPE_32:
return SDS_HDR(32,s)->len;
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}
//根据不同header类型返回SDS中未使用的空间字符数
static inline size_t sdsavail(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5: {
return 0;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
return sh->alloc - sh->len;
}
}
return 0;
}
//根据不同header类型返回SDS中分配的的空间字符数
// sdsalloc() = sdsavail() + sdslen()
static inline size_t sdsalloc(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->alloc;
case SDS_TYPE_16:
return SDS_HDR(16,s)->alloc;
case SDS_TYPE_32:
return SDS_HDR(32,s)->alloc;
case SDS_TYPE_64:
return SDS_HDR(64,s)->alloc;
}
return 0;
}
C 字符串容易造成缓冲区移除,C 字符串不记录自身长度,不会自动进行边界检查,所以会增加溢出的风险,当程序将数据写入缓冲区时,会超过缓冲区的边界,并覆盖相邻的内存位置。如下图
char* strcat(char* dest, const char* src);
该函数是将 src 字符串内容拼接到 dest 字符串的末尾。假如有 s1 = “Redis”,s2 = “MongoDB”,如下:
当执行 strcat(s1,‘Cluster’) 时,未给 s1 分配足够的内存空间,s1 的数据将会溢出到 s2 所在的内存空间,导致 s2 保存的内容被修改,如下:
与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓存溢出的可能性,他会按照如下步骤进行:
/* 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已经使用过的空间字符数
s = sdsMakeRoomFor(s,len);//扩大s的空闲空间
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);//拷贝数据
sdssetlen(s, curlen+len);//设置s的len
s[curlen+len] = '\0';//最后加上空字符串
return s;
}
因为 字符串每次增长或缩短程序都总要对保存这个 字符串的数组进行一次内存重分配操作,内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以是一个比较耗时的操作。对redis造成不小性能影响,为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联;在 SDS 中,buf 数组的长度不一定就是字符串数量加一,数组里面可以包含1未使用的字节,而这些字节的数量就由 SDS 的 alloc和len属性记录。
另外SDS 还实现了空间预分配和惰性空间释放两种优化策略来减少SDS在扩容和缩容时内存重分配次数。
空间预分配用于优化 SDS 的字符串增长操作,当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间拓展的时候,程序不仅会为 SDS 分配修改所需要的空间,还会为 SDS 分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定(参数SDS_MAX_PREALLOC为1024*1024=1MB):
SDS空间小于1MB
修改后,SDS 的长度将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,如:如果修改之后SDS的len将变为20字节,那么程序也会分配20字节的未使用空间,SDS的buf[]实际长度变为20 + 20 + 1 = 41(额外一个字节用于保存结束符\n)。
SDS空间大于1MB
修改后,SDS 的长度将大于等于 1MB,那么程序会分配 1MB 的未使用空间,如:修改之后的len将变为10MB,那么程序会分配1MB的未使用空间,SDS的buf[]长度为10MB + 1MB + 1byte。
通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。
具体函数如下:
/* 扩大sds字符串末尾的空闲空间,以便调用者确定调用此函数后可以覆盖字符串末尾的addlen字节,再加上nul term的一个字节
* 注意: 这不会改变sdslen()返回的sds字符串的*length*,但只会改变我们所拥有的空闲缓冲区空间. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s); //返回SDS中未使用的空间字符数,直接alloc(分配的)减去used(使用的)
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* sds剩余空间足够的话 直接返回. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
assert(newlen > len); /* Catch size_t overflow */
//修改的长度小于SDS_MAX_PREALLOC(即1MB) 直接扩大两倍
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else//修改的长度大于等于SDS_MAX_PREALLOC(即1MB) 直接扩大1MB空间
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);
assert(hdrlen + newlen + 1 > len); /* Catch size_t overflow */
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;
}
惰性空间释放用于优化 SDS 的字符串缩短操作,当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录下来,并等待将来使用。
C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
为了确保 Redis 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是怎么样的,他被读取时就是怎么样的。通过使用二进制安全的 SDS,而不是使用 C 字符串,使得 Redis 不仅可以一保存文本数据,还可以保存任意格式的二进制数据。
虽然 SDS 的 API 都是二进制安全的,但他们一样遵循 C 字符串以空字符结尾的惯例,这些 API 总会将 SDS 保存的数据的末尾设置为空字符,并且总会在 buf 数组分配空间时多分配一个字节来容纳这个空字符,这是为了保存文本数据的 SDS 可以宠用一部分
函数名称 | 作用 | 复杂度 |
---|---|---|
sdsnew | 创建一个包含给定C字符串的SDS | O(N),N为给定C字符串的长度 |
sdsempty | 创建一个不包含任何内容的空SDS | O(1) |
sdsfree | 释放给定的SDS | O(N),N为被释放SDS的长度 |
sdslen | 返回SDS已经使用过的空间字符数 | O(1),直接读取SDS的len属性来直接获得 |
sdsavail | 返回SDS中未使用的空间字符数 | O(1),直接读取SDS的free属性来直接获得 |
sdsdup | 创建一个给定SDS的副本 | O(N),N为给定SDS的长度 |
sdsclear | 清空SDS中字符串保存的内容 | 因为惰性空间释放策略,复杂的为O(1) |
sdscat | 将C字符串拼接到SDS字符串末尾 | O(N),N为被拼接C字符串的长度 |
sdscatsds | 将SDS拼接到另一个SDS中 | O(N),N为被拼接SDS字符串的长度 |
sdscpy | 将给定的C字符串复制并覆盖到SDS中的字符串 | O(N),N为被复制C字符串的长度 |
sdsgrowzero | 用空字符将SDS扩展至给定的长度 | O(N),N为扩展新增的字节数 |
sdsrange | SDS区间内的数据保留, | 区间之外的数据覆盖或清除 |
sdstrim | 接受一个SDS和一个C字符串作为参数, | 从移除SDS中移除所有在C字符串中出现过的字符 |
sdscmp | 对比两个SDS字符串是否相等 | O(N),N为两个SDS中较短的那个SDS的长度 |
sdsRemoveFreeSpace | 重新分配sds字符串,使其在末尾没有空闲空间 | O(N) |
sdsMakeRoomFor | 扩大sds字符串末尾的空闲空间 | O(N) |
参考资料:
极客时间-蒋德钧《Redis核心技术与实战》
https://blog.csdn.net/meser88/article/details/109339670
https://juejin.cn/post/6854573221237719048
https://segmentfault.com/q/1010000002388947
https://blog.csdn.net/qq_33996921/article/details/105226259
https://blog.csdn.net/yangbodong22011/article/details/78419966