一、基本概念

结构体

基本结构如下:

struct sdshdr {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    int len;
    //记录buf数组中未使用字节的数量
    int free;
    //字节数组,用于保存字符串
    char buf[];
};

这种数据结构体的优势:

  • 常数复杂度获取字符串长度:SDS 通过单独的 len 字段,将获取字符串内容长度的时间复杂度从 O(N) 降到了 O(1)

  • 杜绝缓冲区溢出:原始的 C 语言中,如果出现对字符串进行拼接的操作(如常见的char *strcat(char *dest,const char *src)),需要提前分配内存空间,否则拼接的内容可能会“溢出”覆盖到临近已经存放有其他内容的缓冲区。相对的,SDS API 如果需要增减字符串的长度,会自动按照特定策略进行内存分配,从根源上杜绝了缓冲区溢出的风险;

  • 避免了频繁分配(释放)内存操作:SDS 通过每次对内存空间“预分配”,避免了对字符串长度增减时候,每次都操作内存的问题(虽然内存操作理论上都是很快的,但 Redis 为了追求极致的速度和效率,尽量需要减少内存的分配次数);

  • 二进制安全:传统C字符串通过空字符串来识别字符串结尾,SDS 则通过 len 字段来识别,因此 SDS 理论上可以保存包含空字符串的二进制数据(图片、音视频等);

  • 兼容一些C字符串方法:虽然 SDS 对传统C字符串做了改进,但承载的字符串仍然是遵循C字符串以空字符结尾的惯例,这样就保证了 SDS 同样可以直接调用C底层库中的一些字符串操作函数;

空间预分配

对 SDS 字符串执行“增长” X 个字节的操作时候,如果检测到“剩余空间”不足,按照以下算法来分配新的内存空间:

  • 如果“增长”后的 SDS 长度小于 1MB(即 SDS->len + X < 1MB),则直接对 SDS 分配和增长后的长度相等的内存空间,即:SDS->free += SDS->len + X

  • 如果“增长”后的 SDS 长度大于或等于 1MB,则直接对 SDS 分配1MB的内存,即:SDS->free += 1MB

可以看出,按照这种分配策略,虽然 SDS 可能只需要 X 个字节的空间来存放新增的部分字符串,但 Redis 会直接分配超过需求长度的内存来提供给 SDS 使用,这样做的优点是避免了后续字符串“多次增长”后重复分配的情况,随之带来的缺点是,造成了内存空间的“浪费”(相信根据 Redis 在大规模实践场景的验证,这种算法带来的空间浪费应该足够抵消频繁分配内存导致的开销了)。

惰性释放内存

与字符串增长的情况相对应,如果 SDS 需要执行对字符串长度缩短的操作,SDS 并不会立即回收内存,而是将多出的空间记录到 free 字段。这样做的好处了,避免了字符串缩短后又增长时候再次重新分配内存。这种策略显然也会浪费一些内存空间,但 SDS 同样提供了可以直接释放内存的 API,这就给上层程序根据具体情况来决定是否立即释放内存空间的权利。

二、源码分析

结构体优化

SDS 源码定义在:https://github.com/redis/redis/blob/7.2.4/src/sds.h

可以看出,经过多年的优化迭代,作者对 SDS 的结构做了一些优化改动,SDS 的定义不再是统一通过一个结构体来实现,而是分成了五个结构类似的结构体:sdshdr5sdshdr8sdshdr16sdshdr32

struct __attribute__ ((__packed__)) sdshdr8 {
   // 字符串长度,这里类型会根据不同的结构体,采用不同位数的整型
    uint8_t len;
        // 剩余空间,同样在不同SDS结构体中使用不同整型
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

作者在注释中提到,sdshr5 已经废弃不用,这里不再展开讨论:

/* 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[];
};

分析其他几种 SDS 结构体之前,先来考虑这种情况:在64位操作系统中,按照旧的结构体定义,len 和 free 两个字段固定要占用4个字节的空间,最长可以记录的字符串长度为:2^32,在承载的字符串长度比较长的时候没有什么问题,但是如果承载的字符串数组 buf[] 的长度很短,例如只有一个字节长度的字符串,压根就不需要用4个字节来记录字符串长度!

正是基于优化上面这种情况的考虑,最新的 SDS 结构体引入了一个字节长度(8bit)的flags字段,flags 字段的前3个bit(最多可以记录2^3=8种 type)用来标识当前结构体具体是哪种 SDS,每种 Type 对应的 SDS 的 len 字段分别使用不一样长度的 int,上层可以根据需要承载的数据最大长度分情况使用各个SDS,目前使用的 SDS TYPE 有:

# 对应 sdshdr5,已废弃
#define SDS_TYPE_5  0

# 对应 sdshdr8,sds->len 类型为 uint8_t,允许最大承载长度:2^8
#define SDS_TYPE_8  1

# 对应 sdshdr16,sds->len 类型为 uint16_t,允许最大承载长度:2^16
#define SDS_TYPE_16 2

# 对应 sdshdr32,sds->len 类型为 uint32_t,允许最大承载长度:2^32
#define SDS_TYPE_32 3

# 对应 sdshdr64,sds->len 类型为 uint64_t,允许最大承载长度:2^64
#define SDS_TYPE_64 4

内存预分配

SDS 内存分配使用方法_sdsMakeRoomFor,源码:https://github.com/redis/redis/blob/7.2.4/src/sds.c#L240

/*
* s 为原始SDS,addlen为需要扩容长度
* greedy参数表示是否使用贪婪算法,这个参数会影响需求长度大于1MB的情况
*/
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
    void *sh, *newsh;
        // sdsavail: s->alloc - s->len, 获取 SDS 的剩余长度
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
        // 根据 flags 获取 SDS 的类型
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    // 空间足够则无需分配
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    reqlen = newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
        //是否使用贪婪算法,获得实际扩容后的newlen
    if (greedy == 1) {
            // SDS_MAX_PREALLOC:1MB,新长度小于1MB则直接乘2,否则分配1MB
        if (newlen < SDS_MAX_PREALLOC)
            newlen *= 2;
        else
            newlen += SDS_MAX_PREALLOC;
    }

   // 根据扩容后长度计算出新的 SDS TYPE
    type = sdsReqType(newlen);

    // TYPE5废弃后,计算出的TYPE5直接使用TYPE8
    /* 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;

    //计算header部分(结构体中的len、alloc和flags)长度
    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > reqlen);  /* Catch size_t overflow */

        //扩容后TYPE是否变化
    if (oldtype==type) {
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        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_usable(hdrlen+newlen+1, &usable);
        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);
    }

        //获取buf总长,这里还不是最终长度,还要根据是否超出最大长度来执行截断操作
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);

        //最终可以用长度写入到结构体后返回
    sdssetalloc(s, usable);
    return s;
}

其实 Redis 提供了两种不同的分配方法,可以根据上层程序具体需求单独调用:

  • 贪婪分配(默认):每次尽量分配多的内存,避免反复分配
/* Enlarge the free space at the end of the sds string more than needed,
 * This is useful to avoid repeated re-allocations when repeatedly appending to the sds. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
    return _sdsMakeRoomFor(s, addlen, 1);
}
  • 保守分配:每次需要多少就分配多少,会引起频繁分配内存的状况
/* Unlike sdsMakeRoomFor(), this one just grows to the necessary size. */
sds sdsMakeRoomForNonGreedy(sds s, size_t addlen) {
    return _sdsMakeRoomFor(s, addlen, 0);
}

内存惰性释放

sdstrim(sds s, const char *cset) 为例,这个函数会删除 SDS 承载字符串中所有出现 cset 指向字符串中的字符,参考源码:https://github.com/redis/redis/blob/7.2.4/src/sds.c#L735

/* Remove the part of the string from left and from right composed just of
 * contiguous characters found in 'cset', that is a null terminated C string.
 *
 * After the call, the modified sds string is no longer valid and all the
 * references must be substituted with the new pointer returned by the call.
 *
 * Example:
 *
 * s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");
 * s = sdstrim(s,"Aa. :");
 * printf("%s\n", s);
 *
 * Output will be just "HelloWorld".
 */
sds sdstrim(sds s, const char *cset) {
    char *end, *sp, *ep;
    size_t len;

    sp = s;
    ep = end = s+sdslen(s)-1;
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (ep-sp)+1;
    if (s != sp) memmove(s, sp, len);
    s[len] = '\0';
    sdssetlen(s,len);
    return s;
}

可以发现,这个函数在对 SDS 字符串进行裁剪之后,并没释放空出来的部分内存,只是通过 sdssetlen 函数更新了原始 SDS 里面的 len 字段,这样后续如果又出现对这个 SDS 扩容的操作,就可以直接利用这次空出来的空间,并不用再次分配内存了。