深入了解Redis(底层实现)源码 (第二篇)

24 篇文章 0 订阅
11 篇文章 10 订阅
"Redis中的SDS(简单动态字符串)设计解决了C语言字符串使用''作为结束符带来的问题,如效率低下和无法保存含有''的数据。SDS额外存储了字符串长度,提供更高效的字符串操作,且支持保存任意二进制数据。通过预分配和惰性释放策略,SDS减少了内存分配次数。此外,SDS通过不同的结构体类型节省内存,并使用__attribute__((packed))确保紧凑布局。"
摘要由CSDN通过智能技术生成

为什么 Redis 不用 char*?

char字符数组的结构很简单,就是一块连续的内存空间,依次存放了字符串中的每一个字符。比如,下图显示的就是字符串“redis”的char数组结构。
在这里插入图片描述
从图中可以看到,字符数组的最后一个字符是“\0”,这个字符的作用是什么呢?其实,C 语言在对字符串进行操作时,char* 指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。

这样一来,C 语言标准库中字符串的操作函数,就会通过检查字符数组中是否有“\0”,来判断字符串是否结束。比如,strlen 函数就是一种字符串操作函数,它可以返回一个字符串的长度。这个函数会遍历字符数组中的每一个字符,并进行计数,直到检查的字符为“\0”。此时,strlen 函数会停止计数,返回已经统计到的字符个数。下图显示了 strlen 函数的执行流程:
在这里插入图片描述
我们再通过一段代码,来看下“\0”结束字符对字符串长度的影响。这里我创建了两个字符串变量 a 和 b,分别给它们赋值为“red\0is”和“redis\0”。然后,我用 strlen 函数计算这两个字符串长度,如下所示:


  #include <stdio.h>
  #include <string.h>
  int main()
  {
     char *a = "red\0is";
     char *b = "redis\0";
     printf("%lu\n", strlen(a));
     printf("%lu\n", strlen(b));
     return 0;
  }

当程序执行完这段代码后,输出的结果分别是 3 和 5,表示 a 和 b 的长度分别是 3 个字符和 5 个字符。这是因为 a 中在“red”这 3 个字符后,就有了结束字符“\0”,而 b 中的结束字符是在“redis”5 个字符后。

也就是说,char* 字符串以“\0”表示字符串的结束,其实会给我们保存数据带来一定的负面影响。如果我们要保存的数据中,本身就有“\0”,那么数据在“\0”处就会被截断,而这就不符合 Redis 希望能保存任意二进制数据的需求了。

操作函数复杂度

而除了 char* 字符数组结构的设计问题以外,使用“\0”作为字符串的结束字符,虽然可以让字符串操作函数判断字符串的结束位置,但它也会带来另一方面的负面影响,也就是会导致操作函数的复杂度增加。

我还是以 strlen 函数为例,该函数需要遍历字符数组中的每一个字符,才能得到字符串长度,所以这个操作函数的复杂度是 O(N)。

我们再来看另一个常用的操作函数:字符串追加函数 strcat。strcat 函数是将一个源字符串 src 追加到一个目标字符串的末尾。该函数的代码如下所示:


  char *strcat(char *dest, const char *src) {
     //将目标字符串复制给tmp变量
     char *tmp = dest;
     //用一个while循环遍历目标字符串,直到遇到“\0”跳出循环,指向目标字符串的末尾
     while(*dest)
        dest++;
     //将源字符串中的每个字符逐一赋值到目标字符串中,直到遇到结束字符
     while((*dest++ = *src++) != '\0' )
     return tmp;
  }

从代码中可以看到,strcat 函数和 strlen 函数类似,复杂度都很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加。另外,它在把源字符串追加到目标字符串末尾时,还需要确认目标字符串具有足够的可用空间,否则就无法追加。

所以,这就要求开发人员在调用 strcat 时,要保证目标字符串有足够的空间,不然就需要开发人员动态分配空间,从而增加了编程的复杂度。而操作函数的复杂度一旦增加,就会影响字符串的操作效率,这就不符合 Redis 对字符串高效操作的需求了。

SDS 的设计思想

SDS(即简单动态字符串)
首先,SDS 结构里包含了一个字符数组 buf[],用来保存实际数据。同时,SDS 结构里还包含了三个元数据,分别是字符数组现有长度 len、分配给字符数组的空间长度 alloc,以及 SDS 类型 flags。其中,Redis 给 len 和 alloc 这两个元数据定义了多种数据类型,进而可以用来表示不同类型的 SDS,稍后我会给你具体介绍。下图显示了 SDS 的结构,你可以先看下。

在这里插入图片描述
另外,如果你在 Redis 源码中查找过 SDS 的定义,那你可能会看到,Redis 使用 typedef 给 char* 类型定义了一个别名,这个别名就是 sds,如下所示:

typedef char *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 函数。这个函数的参数一共有三个,分别是目标字符串 s、源字符串 t 和要追加的长度 len,源码如下所示


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 的数据追加到目标字符串。

最后,设置目标字符串的最新长度。

我画了一张图,显示了 sdscatlen 的执行过程,你可以看下。
在这里插入图片描述

紧凑型字符串结构的编程技巧

SDS 结构中有一个元数据 flags,表示的是 SDS 类型。事实上,SDS 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。这 5 种类型的主要区别就在于,它们数据结构中的字符数组现有长度 len 和分配空间长度 alloc,这两个元数据的数据类型不同。


struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符数组现有长度*/
    uint8_t alloc; /* 字符数组的已分配空间,不包括结构体和\0结束字符*/
    unsigned char flags; /* SDS类型*/
    char buf[]; /*字符数组*/
};

我们可以看到,现有长度 len 和已分配空间 alloc 的数据类型都是 uint8_t。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 字节。

为了节省内存,Redis 在这方面的设计上可以说是精打细算的。所以,Redis 采用了__attribute__ ((packed))属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

其实这里,attribute ((packed))的作用就是告诉编译器,在编译 sdshdr8 结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。这是因为在默认情况下,编译器会按照 8 字节对齐的方式,给变量分配内存。也就是说,即使一个变量的大小不到 8 个字节,编译器也会给它分配 8 个字节。

为了方便你理解,我给你举个例子。假设我定义了一个结构体 s1,它有两个成员变量,类型分别是 char 和 int,如下所示


  #include <stdio.h>
  int main() {
     struct s1 {
        char a;
        int b;
     } ts1;
     printf("%lu\n", sizeof(ts1));
     return 0;
  }

虽然 char 类型占用 1 个字节,int 类型占用 4 个字节,但是如果你运行这段代码,就会发现打印出来的结果是 8。这就是因为在默认情况下,编译器会给 s1 结构体分配 8 个字节的空间,而这样其中就有 3 个字节被浪费掉了。

为了节省内存,Redis 在这方面的设计上可以说是精打细算的。所以,Redis 采用了__attribute__ ((packed))属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

比如,我用__attribute__ ((packed))属性定义结构体 s2,同样包含 char 和 int 两个类型的成员变量,代码如下所示:


  #include <stdio.h>
  int main() {
     struct __attribute__((packed)) s2{
        char a;
        int b;
     } ts2;
     printf("%lu\n", sizeof(ts2));
     return 0;
  }

当你运行这段代码时,你可以看到,打印的结果是 5,表示编译器用了紧凑型内存分配,s2 结构体只占用 5 个字节的空间。

好了,总而言之,如果你在开发程序时,希望能节省数据结构的内存开销,就可以把__attribute__ ((packed))这个编程方法用起来。

小结

C 语言中使用 char* 实现字符串的不足,主要是因为使用“\0”表示字符串结束,操作时需遍历字符串,效率不高,并且无法完整表示包含“\0”的数据,因而这就无法满足 Redis 的需求。

Redis 中字符串的设计思想与实现方法。Redis 专门设计了 SDS 数据结构,在字符数组的基础上,增加了字符数组长度和分配空间大小等元数据。这样一来,需要基于字符串长度进行的追加、复制、比较等操作,就可以直接读取元数据,效率也就提升了。而且,SDS 不通过字符串中的“\0”字符判断字符串结束,而是直接将其作为二进制数据处理,可以用来保存图片等二进制数据。

SDS 中是通过设计不同 SDS 类型来表示不同大小的字符串,并使用__attribute__ ((packed))这个编程小技巧,来实现紧凑型内存布局,达到节省内存的目的。字符串看起来简单。

C 语言字符串的实现方法和 SDS 的联系与区别,也是 Redis 面试时经常会被问到的问题,掌握好它俩的区别。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小高求学之路

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值