redis中字符串的数据结构 SDS
前言
字符串
C语言中的字符串和redis中的字符串对比:
C中char*的劣势:
操作效率低:获取长度需遍历,O(N)复杂度
空间分配上:手动检查和分配字符串空间,增加代码开发的工作量。
限制性:图片等数据还无法用字符串保存。
二进制不安全:无法存储包含 \0 的数据。
简单动态字符串(Simple Dynamic String,SDS)
对比redis中设计的好处:
提升字符串的操作效率,并且可以用来保存二进制数据。
SDS 的优势:
- 操作效率高:获取长度无需遍历,O(1)复杂度
- 二进制安全:因单独记录长度字段,所以可存储包含 \0 的数据
- 兼容 C 字符串函数,可直接使用字符串 API
为什么C的会要手动检查和分配字符串空间???
举例:字符串追加函数 strcat
char *strcat(char *dest, const char *src) {
//将目标字符串复制给tmp变量
char *tmp = dest;
//用一个while循环遍历目标字符串,直到遇到“\0”跳出循环,指向目标字符串的末尾
while(*dest)
dest++;
//将源字符串中的每个字符逐一赋值到目标字符串中,直到遇到结束字符
while((*dest++ = *src++) != '\0' )
return tmp;
}
首先,需要先遍历一次;
其次,确保目标字符串有足够的可用的空间。否则,还得手动的动态分配空间。
所以,这样不符合Redis 对字符串高效操作。
那为什么会有限制呢????
举例:strlen字符串长度测量
strlen是通过’\0’检测长度的。
#include <stdio.h>
#include <string.h>
int main()
{
char *a = "red\0is";\\3
char *b = "redis\0";\\5
printf("%lu\n", strlen(a));
printf("%lu\n", strlen(b));
return 0;
}
不符合 Redis 希望能保存任意二进制数据的需求.
SDS数据结构
包含:一个字符数组 buf[];
字符数组现有长度 len;
分配给字符数组的空间长度 alloc;
以及 SDS 类型 flags这四部分内容。
redis中sds的定义:
typedef char *sds;
从上可以看出,SDS 本质还是字符数组,只是在字符数组基础上增加了额外的元数据。
同时,在创建新的字符串时,Redis 会调用 SDS 创建函数 sdsnewlen。sdsnewlen 函数会新建 sds 类型变量(也就是 char* 类型变量),并新建 SDS 结构体,把 SDS 结构体中的数组 buf[] 赋给 sds 类型变量。最后,sdsnewlen 函数会把要创建的字符串拷贝给 sds 变量。下面的代码就显示了 sdsnewlen 函数的这个操作逻辑,你可以看下。
sds sdsnewlen(const void *init, size_t initlen) {
void *sh; //指向SDS结构体的指针
sds s; //sds类型变量,即char*字符数组
...
sh = s_malloc(hdrlen+initlen+1); //新建SDS结构,并分配内存空间
...
s = (char*)sh+hdrlen; //sds类型变量指向SDS结构体中的buf数组,sh指向SDS结构体起始位置,hdrlen是SDS结构体中元数据的长度
...
if (initlen && init)
memcpy(s, init, initlen); //将要传入的字符串拷贝给sds变量s
s[initlen] = '\0'; //变量s末尾增加\0,表示字符串结束
return s;
看redis中 的 追加字符串操作:
sds.c文件中sdscatlen函数操作
sds sdscatlen(sds s, const void *t, size_t len) {
//获取目标字符串s的当前长度
size_t curlen = sdslen(s);
//根据要追加的长度len和目标字符串s的现有长度,判断是否要增加新的空间
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
//将源字符串t中len长度的数据拷贝到目标字符串结尾
memcpy(s+curlen, t, len);
//设置目标字符串的最新长度:拷贝前长度curlen加上拷贝长度
sdssetlen(s, curlen+len);
//拷贝后,在目标字符串结尾加上\0
s[curlen+len] = '\0';
return s;
}
通过分析这个函数的源码,我们可以看到 sdscatlen 的实现较为简单,
-
其执行过程分为三步:首先,获取目标字符串的当前长度,并调用 sdsMakeRoomFor 函数,根据当前长度和要追加的长度,判断是否要给目标字符串新增空间。这一步主要是保证,目标字符串有足够的空间接收追加的字符串。
-
其次,在保证了目标字符串的空间足够后,将源字符串中指定长度 len 的数据追加到目标字符串。
-
最后,设置目标字符串的最新长度。
对比C语言优点:
SDS 通过记录字符数组的使用长度和分配空间大小,避免了对字符串的遍历操作,降低了操作开销,进一步就可以帮助诸多字符串操作更加高效地完成,比如创建、追加、复制、比较等。
空间检查和扩容封装在了 sdsMakeRoomFor 函数中。
封装思想:
直接让函数内部检查是否需要分配内存,避免开发人员忘记检查和分配内存而造成 的 内存溢出。
另外 Redis 在操作 SDS 时,为了避免频繁操作字符串时,每次「申请、释放」内存的开销,还做了这些优化:
- 内存预分配:SDS 扩容,会多申请一些内存(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容)
- 多余内存不释放:SDS 缩容,不释放多余的内存,下次使用可直接复用这些内存
这种策略,是以多占一些内存的方式,换取「追加」操作的速度。
这个内存预分配策略,详细逻辑可以看 sds.c 的 sdsMakeRoomFor 函数。
SDS数据类型:
SDS 一共设计了 5 种类型,分别是 sdshdr5(不再使用)、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
主要区别就在于:它们数据结构中的字符数组现有长度 len 和分配空间长度 alloc,这两个元数据的数据类型不同。
紧凑型字符串结构的编程技巧
以sdshdr8为例:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 字符数组现有长度*/
uint8_t alloc; /* 字符数组的已分配空间,不包括结构体和\0结束字符*/
unsigned char flags; /* SDS类型*/
char buf[]; /*字符数组*/
};
uint8_t 是 8 位无符号整型,会占用 1 字节的内存空间。当字符串类型是 sdshdr8 时,它能表示的字符数组长度(包括数组最后一位\0)不会超过 256 字节(2 的 8 次方等于 256)。
以此类推:sdshdr16、sdshdr32、sdshdr64 三种类型来说,它们的 len 和 alloc 数据类型分别是 uint16_t、uint32_t、uint64_t,即它们能表示的字符数组长度,分别不超过 2 的 16 次方、32 次方和 64 次方。这两个元数据各自占用的内存空间在 sdshdr16、sdshdr32、sdshdr64 类型中,则分别是 2 字节、4 字节和 8 字节。
SDS 之所以设计不同的结构头(即不同类型),是为了能灵活保存不同大小的字符串,从而有效节省内存空间。
为了避免操作系统自动对齐,在定义数据结构时使用:
struct __attribute__ ((__packed__)) sdshdr8
作用就是告诉编译器,在编译 sdshdr8 结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。
课后题:SDS 字符串在 Redis 内部模块实现中也被广泛使用,你能在 Redis server 和客户端的实现中,找到使用 SDS 字符串的地方么?
1、Redis 中所有 key 的类型就是 SDS(详见 db.c 的 dbAdd 函数)
2、Redis Server 在读取 Client 发来的请求时,会先读到一个缓冲区中,这个缓冲区也是 SDS(详见 server.h 中 struct client 的 querybuf 字段)
课后题:SDS 字符串在 Redis 内部模块实现中也被广泛使用,你能在 Redis server 和客户端的实现中,找到使用 SDS 字符串的地方么?
1、Redis 中所有 key 的类型就是 SDS(详见 db.c 的 dbAdd 函数)
2、Redis Server 在读取 Client 发来的请求时,会先读到一个缓冲区中,这个缓冲区也是 SDS(详见 server.h 中 struct client 的 querybuf 字段)
3、写操作追加到 AOF 时,也会先写到 AOF 缓冲区,这个缓冲区也是 SDS (详见 server.h 中 struct client 的 aof_buf 字段)