Redis 源码探秘-字符串实现原理

97 阅读10分钟

本文正在参加「技术专题19期 漫谈数据库技术」活动

字符串在我们平时的应⽤开发中⼗分常⻅,⽽对于 Redis 来说,键值对中的键是字符串,值有时也是字符串。我们在 Redis 中写⼊⼀条⽤⼾信息,记录了 ⽤⼾姓名、性别、所在城市等,这些都是字符串。此外,Redis 实例和客⼾端交互的命令和数据,也都是⽤字符串表⽰的。 接下来我们通过分析 Redis 源码来探索 Redis 中字符串实现的奥秘。

字符串的使⽤场景十分⼴泛和关键,就使得我们在实现字符串时,需要尽量满⾜以下三个要求:

  • 能支持丰富且高效的字符串操作,比如获取字符串长度、字符串追加、比较等操作。
  • 能保存各种形式的数据,例如二进制等。
  • 尽可能节省内存开销等。

redis 使用 C 语言开发的,在 C 语言中可以使用 char* 字符数组来实现字符串,且 C 语⾔标准库 string.h 中也定义了多种字符串的操作函数,那么 Redis 是不是复⽤ C 语⾔中对字符串的实现呢。实际上,我们在使⽤ C 语⾔字符串时,经常需要⼿动检查和分配字符串空间,⽽且,图⽚等数据还⽆法⽤字符串保存,也就限制了应⽤范围。

针对以上问题,Redis 设计了简单动态字符串(Simple Dynamic String,SDS 的结构,⽤来表⽰字符串。我们下面来了解为什么 Redis 没有复⽤ C 语⾔的字符串实现⽅法。

C 语言字符串的缺点

操作函数复杂度

我们以 strlen 为例,因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)

缓冲区溢出

我们再来看另⼀个常⽤的操作函数:字符串追加函数 strcatstrcat 函数是将⼀个源字符串 src 追加到⼀个⽬标字符串的末尾。该函数的代码如下所⽰:

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

假设程序里有两个在内存中紧邻着的 C 字符串 s1s2 ,其中 s1 保存了字符串 "redis",而 s2 则保存了字符串 "test",如下图所示: redis strcat.png

当执行以下代码时

strcat(s1, " ohno");

s1 的内容修改为 "redis ohno",但没有在执行 strcat 之前为 s1 分配足够的空间,那么在 strcat 函数执行之后,s1 的数据将溢出到 s2 所在的空间中,导致 s2 保存的内容被意外地修改,如下图所示 redis strcat2.png

二进制安全

C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。

SDS的设计思想

因为 Redis 使⽤ C 语⾔开发的,为了保证能尽量复⽤ C 标准库中的字符串操作函数,Redis 保留了使⽤字符数组来保存实际的数据。但是,和 C 语⾔仅⽤字符数组不同,Redis 还专⻔设计了 SDS(即简单动态字符串)的数据结构。

SDS 结构设计

无标题-2022-09-07-0951.png

SDS 本质还是字符数组,只是在字符数组基础上增加了额外的元数据。⾸先,SDS 结构⾥包含了⼀个字符数组buf[],⽤来保存实际数据。同时,SDS 结构⾥还包含了三个元数据,分别是字符数组现有⻓度 len、分配给字符数组的空间⻓度 alloc,以及 SDS 类型 flags

同时 Redis 为字符数组定义了别名,在需要⽤到字符数组时可以直接使⽤ sds 这个别名。

typedef char *sds;

SDS 创建

在创建新的字符串时,Redis 会调⽤ SDS 创建函数 sdsnewlensdsnewlen 函数会新建 sds 类型变量 (也就是 char* 类型变量),并新建 SDS 结构体,把 SDS 结构体中的数组 buf[] 赋给 sds 类型变量。最后,sdsnewlen 函数会把要创建的字符串拷⻉给 sds 变量。具体的代码逻辑如下所示:

sds sdsnewlen(const void *init, size_t initlen) {
    // 指向 SDS 结构体的指针
    void *sh;
    // sds 类型变量,即 char* 字符数组
    sds s;
  
     ...
    // 新建 SDS 结构,并分配内存空间
    sh = s_malloc(hdrlen+initlen+1);
    ...
    // sds类型变量指向SDS结构体中的buf数组指针地址
    s = (char*)sh+hdrlen;
    ...
    if (initlen && init)
        // 将要传⼊的字符串拷⻉给 sds 类型的变量 s
        memcpy(s, init, initlen);
    // 变量 s 末尾增加 \0,表⽰字符串结束
    s[initlen] = '\0';
    return s;
}

SDS 追加

在了解了 SDS 结构的定义后,我们再来看看 SDS 追加操作的改进之处。Redis 中实现字符串追加的函数是 sds.c ⽂件中的 sdscatlen 函数。这个函数的参数⼀共有三个,分别是⽬标字符串 s、源字符串 t 和要追加的⻓度 len,源码如下所⽰:

sds sdscatlen(sds s, const void *t, size_t len) {
    // 获取目标字符串 s 的长度
    size_t curlen = sdslen(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的数据追加到⽬标字符串。
  • 最后,设置⽬标字符串的最新⻓度。

我们来重点看一下 sdsMakeRoomFor 函数,这个函数是扩容的主要方法。下面我们来看一下源码:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    
    // 获取当前可用空间长度。如果当前可用空间长度满足要求,则直接返回
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
    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);
    reqlen = newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    
    // 如果追加后的长度小于 1M,则申请 2 倍的追加后的内存长度
    if (newlen < SDS_MAX_PREALLOC) 
        newlen *= 2;
    else
     // 如果追加后的长度大于 1M,则申请追加后的内存长度 + 1M的内存空间
        newlen += SDS_MAX_PREALLOC;
    // 根据新长度获取 SDS 类型
    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 > reqlen);  /* Catch size_t overflow */
    // 如果扩容后sds还是同一类型,则使用s_realloc函数申请内存。否则,由于sds结构已经变动,必须移动整个sds,直接分配新的内存空间,并将原来的字符串内容复制到新的内存空间。
    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;
}

通过分析这个函数的源码,我们可以看到 sdsMakeRoomFor 的过程主要是:

  • 获取当前可用空间长度。如果当前可用空间长度满足新增字符长度的要求,则直接返回。
  • 如果对 SDS 进行修改之后,SDS 的长度小于 1MB,那么程序分配 2 倍修改后 SDS 长度的内存。如果大于 1MB,则申请追加后的内存长度 + 1MB 的内存空间。
  • 根据新长度获取 SDS 类型。
  • 如果扩容后 SDS 还是同一类型,则使用 s_realloc 函数申请内存。否则,由于 SDS 结构已经变动,必须移动整个 SDS,直接分配新的内存空间,并将原来的字符串内容复制到新的内存空间。

sdsMakeRoomFor.png

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

在分析 sds.h 时可以看到定义了五种 sdshdr,分别是 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64。这 5 种类型的主要区别就在于,它们数据结构中的字符数组现有⻓度 len 和分配空间⻓度 alloc,这两个元数据的数据类型不同。 因为 sdshdr5 这⼀类型 Redis 已经不再使⽤了,所以我们这⾥主要来了解下剩余的 4 种类型。以 sdshdr8为例,它的定义如下所⽰:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符数组使用的长度 */
    uint8_t alloc; /* 字符数组申请的长度 */
    unsigned char flags; /* sds 类型 */
    char buf[]; /* 字符数组 */
};

我们可以看到,现有⻓度 len 和已分配空间 alloc 的数据类型都是 uint8_tuint8_t 表示 8 位⽆符号整型,会占⽤ 1 字节的内存空间。当字符串类型是 sdshdr8 时,它能表⽰的字符数组⻓度(包括数组最后⼀位 \0)不会超过 256 字节。

⽽对于 sdshdr16sdshdr32sdshdr64 三种类型来说,它们的 lenalloc 数据类型分别是uint16_tuint32_tuint64_t,即它们能表⽰的字符数组⻓度。这两个元数据占⽤的内存空间在 sdshdr16sdshdr32sdshdr64类型中,则分别是 2 字节、4 字节和 8 字节。

SDS 之所以设计不同的结构头(即不同类型),是为了能灵活保存不同⼤⼩的字符串,从⽽有效节省内存空间。因为在保存不同⼤⼩的字符串时,结构头占⽤的内存空间也不⼀样,这样⼀来,在保存⼩字符串时,结构头占⽤空间也⽐较少。

好了,除了设计不同类型的结构头,Redis 在编程上还使⽤了专⻔的编译优化来节省内存空间。在刚才介绍的 sdshdr8 结构定义中,我们可以看到,在 structsdshdr8 之间使⽤了 __attribute__ ((__packed__))。 其实这⾥,__attribute__ ((__packed__)) 的作⽤就是告诉编译器,在编译 sdshdr8 结构时,不要使⽤字节对⻬的⽅式,⽽是采⽤紧凑的⽅式分配内存。这是因为在默认情况下,编译器会按照 8 字节对⻬的⽅式,给变量分配内存。也就是说,即使⼀个变量的⼤⼩不到 8 个字节,编译器也会给它分配 8 个字节。

小结

通过上文分析我们了解到和 C 语⾔中的字符串操作相⽐,SDS 通过记录字符数组的使⽤⻓度和分配空间⼤⼩,避免了对字符串的遍历操作,降低了操作开销,进⼀步就可以帮助诸多字符串操作更加⾼效地完成,⽐如创建、追加、复制、⽐较等等操作。 此外,SDS把⽬标字符串的空间检查和扩容封装在了 sdsMakeRoomFor 函数中,并且在涉及字符串空间变化的操作中,如追加、复制等,会直接调⽤该函数。 这⼀设计实现,就避免了开发⼈员因忘记给⽬标字符串扩容,⽽导致操作失败的情况。最后通过 SDS 类型的设计以及编译器优化等体现出 Redis 对内存使用的精打细算。

2MBdq.gif

参考资料

《Redis设计与实现》

Redis 源码剖析与实战

redis 源码