likes
comments
collection
share

redis之SDS字符串,到底高效在哪里?(深入分析)

作者站长头像
站长
· 阅读数 24

前言

本文参考源码为 redis6.2

Redis 只会使用 C 字符串作为字面量,在大多数情况下,Redis 使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。

SDS 的出现是为了解决 C 字符串在 Redis 三高 场景下的不足,分别有 安全性效率以及功能等方面。


一、简单动态字符串

1. SDS 是什么?

相信大家都学过 C 语言,对 C 中的字符串已经比较清楚了;在 C 中,如果我们要存储一个字符串,可以先定义一个字符数组 char[] 或者 char *p ,然后将字符串挨个存进去。

这种方式存在什么问题?

当我们想要获取字符串的实际长度时,需要遍历整个字符串 char[];你可能觉得单次操作性能应该还行,然而 redis 服务的是以万+ 的QPS,这种操作就有些问题了。

显然,直接用 C 中的字符串不太现实,但可以在其之上,做一层数据结构封装,一款适用于高并发场景下,能高效的解决 C 字符串缺陷的特殊结构,这就是 SDS。

SDS 既然是字符串,那么首先需要一个字符串指针;为了方便上层的接口调用,该结构还需要记录一些关键信息,如当前数据长度剩余容量等,数据结构如下:

struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

其中,len 表示当前字符串长度、free 表示剩余可用空间、buf[] 存储实际数据。

值得注意的是,以上结构是 redis3.2 版本之前的结构,3.2 已经做出了些许调整;至于原因,咱们继续往后看 ~

2. 基本原理

从上面可以看到,sds普通 C 字符串的区别就是新增了一层数据结构包装了原始的字符数组。

细心的你可能已经注意到了,上面的结构体命名为 sdshdr,它和 sds 有什么关系?

从源码我们找到 sds 的定义为:

typedef char *sds;

也就是说,sds 是一个指向字符的指针;实际上,sds 就指向了我们 sdshdr 结构 buf[] 数据字段,这样做的好处是,能更好的兼容 C 字符,从而使用其相关库函数。

SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面,并且为空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的,所以这个空字符对于 SDS 的使用者来说是完全透明的。

遵循空字符结尾这一惯例的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。

你可能有疑问,如何通过 sds 指针拿到 sdshdr 其他字段的信息?

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;
}

这是拿 len 字段信息,当然,拿去其他字段以及修改字段值也都是类似的方法。具体原理,咱们接着往下看~

3. 改进?

SDS与C字符串的区别:

根据传统,C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符'\0'

C 语言使用的这种简单的字符串表示方式,并不能满足 Redis 对字符串在安全性效率以及功能方面的要求

3.1 O(1)复杂度获取字符串长度

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

和 C 字符串不同,因为 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为O(1)

设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用 SDS 无须进行任何手动修改长度的工作。

通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。

例如,因为字符串键在底层使用 SDS 来实现,所以即使我们对一个非常长的字符串键反复执行 STRLEN 命令,也不会对系统性能造成任何影响,因为 STRLEN 命令的复杂度仅为O(1)

3.2 杜绝缓冲区溢出

除了获取字符串长度的复杂度高之外,C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。

例子:字符串拼接:

与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出问题。

举个例子,SDS 的 API 里面也有一个用于执行拼接操作的 sdscat 函数,它可以将一个 C 字符串拼接到给定 SDS 所保存的字符串的后面,但是在执行拼接操作之前,sdscat 会先检查给定 SDS 的空间是否足够,如果不够的话,sdscat 就会先扩展 SDS 的空间,然后才执行拼接操作。

3.3 减少修改字符串时带来的内存重分配次数

因为 C 字符串并不记录自身的长度,所以对于一个包含了 N 个字符的 C 字符串来说,这个 C 字符串的底层实现总是一个 N + 1 个字符长的数组(额外的一个字符空间用于保存空字符)。

因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个 C 字符串,程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:

  • 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出
  • 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏

因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:

  • 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。
  • 但是 Redis 作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响。

为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由 SDS 的 free(即 alloc - len) 属性记录。

通过未使用空间,SDS 实现了空间预分配惰性空间释放两种优化策略。

1)空间预分配

空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间

其中,额外分配的未使用空间数量由以下规则决定:

  • 对SDS 进行修改之后,如果其的长度 newlen < 1MB,那么总分配长度 alloc 将等于 2 * newlen,即 free = len。举个例子,如果进行修改之后,SDS 的 len 将变成13字节,那么程序也会分配 13 字节的未使用空间,SDS的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
  • 对 SDS 进行修改之后,如果其的长度 newlen >= 1MB,那么将会会分配 1MB 的未使用空间。举个例子,如果进行修改之后,SDS 的 len 变成 30MB,那么程序会分配 1MB 的未使用空间,SDS 的 buf 数组的实际长度将为 30MB + 1MB + 1byte。

源码 sdsMakeRoomFor 中的代码片段:

    reqlen = newlen = (len+addlen);
    // SDS_MAX_PREALLOC = 1024 * 1024,即 1MB
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。

2)惰性空间释放

惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。

空间释放时机?

在源码 object.c 中:

void trimStringObjectIfNeeded(robj *o) {
    if (o->encoding == OBJ_ENCODING_RAW &&
        sdsavail(o->ptr) > sdslen(o->ptr)/10)
    {
        o->ptr = sdsRemoveFreeSpace(o->ptr);
    }
}

可以看出,这里释放 未使用空间(free) 的条件是,free > 10% * len,这样的目的就是为了防止多次 apend 这样的操作导致的多次 realloc调用。

再往上溯源,我们发现,最终调用之一是 t_string.c 中解析并处理客户端命令时触发该方法调用;也就说,释放多余空间这个操作也是由客户端触发的,所以叫惰性

需要注意的是redis3.2 之前的版本使用字段叫做 free,表示未使用空间长度;而 redis3.2 以及之后的版本已经改成了 alloc 字段,表示分配的总空间大小;为了表述方便,文中仍使用 free 字段表示未使用空间。

3.4 二进制安全

什么是二进制安全?通俗地讲,C语言中,用“\0”表示字符串的结束,如果字符串中本身就有“\0”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

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

虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保 Redis 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全的(binary-safe),所有SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。

这也是我们将 SDS 的 buf 属性称为字节数组的原因——Redis 不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

例如,使用 SDS 来保存之前提到的特殊数据格式就没有任何问题,因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束

通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,还可以保存任意格式的二进制数据。

兼容部分C字符串函数

虽然 SDS 的 API 都是二进制安全的,但它们一样遵循 C 字符串以空字符结尾的惯例:这些 API 总会将 SDS 保存的数据的末尾设置为空字符,并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的 SDS 可以重用一部分<string.h>库定义的函数。

4. 应用

我们知道,redis 中的基本数据结构用于支持上层数据类型实现;自然,SDS 也不例外,比如,上层数据类型 string 的底层实现中对 SDS 的使用就非常广泛。

另外,在 Redis 内部,SDS 应用也十分广泛;比如,内部异常信息的封装、数据元素使用 SDS 存储等等。

因此,SDS 对于 redis 来说,是最基本的数据结构之一,值得深入研究。

二、底层实现

1. 数据结构

前面我们聊到, redis3.2 版本之前 sds 的数据结构长这样:

struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

我们来看看 redis3.2 及之后的版本改进之路

不同长度的字符串是否有必要占用相同大小的头部?

一个 int 占4字节,在实际应用中,存放于 Redis 中的字符串往往没有这么长,每个字符串都用4字节存储未免太浪费空间了。我们考虑三种情况:

  • 短字符串,len 和 free 的长度为 1 字节就够了;
  • 长字符串,用 2 字节或 4 字节;
  • 更长的字符串,用 8 字节。

按照这个改进,我们看看需要进一步解决的问题:

  • 问题1:这三种情况如何区分?

  • 问题2:对于超短字符串,能否小于 len + free = 2 字节?

针对问题1,我们可以考虑使用最小一个字节的 flags 字段标识,并且将其放置在 buf[] 之前,这个可以通过 buf[] 的偏移量 减一 即可快速定位。

针对问题2,由于 len 已经是最小的1字节了,再压缩只能考虑用位来存储长度了。

结合两个问题,5 种类型(长度 1 字节、2 字节、4 字节、8 字节、小于1 字节)的 SDS 至少要用 3 位来存储类型(2^3 = 8),1 个字节 8 位,剩余的 5 位存储长度,可以满足长度小于 32 的短字符串。我们用如下结构来存储长度小于 32 的短字符串:

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 前3位存储类型,后5位存储长度 */
    char buf[]; /* 存储实际数据 */
};

结构图如下: redis之SDS字符串,到底高效在哪里?(深入分析)

而长度大于31的字符串,1个字节依然存不下。我们按之前的思路,将len和free单独存放。sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的结构相同,sdshdr16 结构如图: redis之SDS字符串,到底高效在哪里?(深入分析)

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 已使用长度,2字节 */
    uint16_t alloc; /* 总长度,2字节,不包括头部和空结束符 */
    unsigned char flags; /* 低三位存储类型,高5位预留 */
    char buf[];
};

值得注意的是,redis3.2 之前的版本使用的是 free 字段,而之后的版本已经改成了 alloc 字段。

其他结构定义如下:

/* 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__)) sdshdr8 {
    uint8_t len; /* 已使用长度,1字节 */
    uint8_t alloc; /* 总长度,1字节,不包括头部和空结束符 */
    unsigned char flags; /* 低三位存储类型,高5位预留 */
    char buf[]; /* 存放实际数据 */
};
struct __attribute__ ((__packed__)) sdshdr32 {
    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 {
    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[];
};

源码中的__attribute__((packed))需要重点关注

它的作用是,告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

一般情况下,结构体会按其所有变量大小的最小公倍数做字节对齐,而用 packed 修饰后,结构体则变为按1字节对齐。

以 sdshdr32 为例,修饰前按4字节对齐大小为 12(4×3) 字节;修饰后按1字节对齐,注意 buf 是个 char 类型的柔性数组,地址连续,始终在 flags 之后。packed 修饰前后示意如图所示: redis之SDS字符串,到底高效在哪里?(深入分析) 这样做有以下两个好处:

  • 节省内存,例如 sdshdr32 可节省3个字节(12-9)
  • SDS 返回给上层的,不是结构体首地址,而是指向内容的 buf 指针。因为此时按1字节对齐,故 SDS 创建成功后,无论是 sdshdr8、sdshdr16 还是 sdshdr32,都能通过 (char*)sh + hdrlen 得到 buf 指针地址(其中 hdrlen 是结构体长度,通过 sizeof 计算得到)。修饰后,无论是 sdshdr8、sdshdr16 还是 sdshdr32,都能通过 buf[-1] 找到 flags,因为此时按 1 字节对齐。若没有 packed 的修饰,还需要对不同结构进行处理,实现更复杂。

2. SDS API

// 创建 sds
sds sdsnew(const char *init);
sds sdsnewlen(const void *init, size_t initlen);
// 创建一个空字符串,长度为0,内容为 ""
sds sdsempty(void);
// 复制给定的 sds
sds sdsdup(const sds s);
// 释放 sds 字符串
void sdsfree(sds s);
// 将 sds 扩容到指定长度,并用 0 填充
sds sdsgrowzero(sds s, size_t len);
// 拼接字符串
sds sdscatsds(sds s, const sds t);
sds sdscatlen(sds s, const void *t, size_t len);
// 将字符串复制到 sds 中
sds sdscpy(sds s, const char *t);
sds sdscpylen(sds s, const char *t, size_t len);

// 从 sds 两端清除所有给定的字符
sds sdstrim(sds s, const char *cset);
// 更新 sds 相关值
void sdsupdatelen(sds s);
// 与 sdsMakeRoomFor 正好相反,对空闲过多的 sds 做缩容
sds sdsRemoveFreeSpace(sds s);
void sdsclear(sds s);
// 比较给定的两 sds 实际大小
int sdscmp(const sds s1, const sds s2);
// 按给定的分隔符对 sds 进行切分
sds *sdssplitlen(const char *s, ssize_t len, const char *sep, int seplen, int *count);

1)SDS 暴露给上层的是指向数组 buf 的指针。

2)读操作的复杂度多为 O(1),直接读取成员变量;涉及修改的写操作,则可能会触发扩容。

3. 基本操作

3.1 创建字符串

Redis通过 sdsnewlen 函数创建 SDS。在函数中会根据字符串长度选择合适的类型,初始化完相应的统计值后,返回指向字符串内容的指针,根据字符串长度选择不同的类型:

sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    char type = sdsReqType(initlen);
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp; /* flags 指针,并以此给flags赋值 */

    sh = s_malloc(hdrlen+initlen+1);
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    if (sh == NULL) return NULL;
    // 这里实际指向 buf[] 
    s = (char*)sh+hdrlen;
    // buf[] 往前一个字节就是 flags 地址
    fp = ((unsigned char*)s)-1;
    // 给 flags, len, alloc 赋值
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    // 初始化
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;
}

整体上比较简单,先通过 malloc 分配空间,再进行相关的初始化赋值

值得注意的是:对于 sdshdr5 类型,在创建空字符串时会强制转换为 sdshdr8。原因是创建空字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为 sdshdr8。

结合我们实际的操作场景就是,先创建空字符串,然后不断的 append 字符串,这种提前扩容,就是一种预判行为。

另外,方法的返回结构为 sds,其定义为:

typedef char *sds;

也就是说,sds 是指向字符的指针(即首地址,在 C++ 这种就表示一个字符串);在这里,指向我们 sdshdr 结构中的 buf[]。

这样设计的好处在于直接对上层提供了字符串内容指针,兼容了部分 C 函数,且通过偏移能迅速定位到 SDS 结构体的各处成员变量。

3.2 释放字符串

SDS 提供了直接释放内存的方法——sdsfree,该方法通过对 s 的偏移,可定位到 SDS 结构体的首部,然后调用 s_free 释放内存:

void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}

为了优化性能(减少申请内存的开销), SDS 提供了不直接释放内存,而是通过重置统计值达到清空目的的方法——sdsclear。该方法仅将 SDS 的 len 归零,此处已存在的 buf 并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存

void sdsclear(sds s) {
    sdssetlen(s, 0);
    s[0] = '\0';
}

3.3 拼接字符串

1)sdscatsds

sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}

sdscatsds 是暴露给上层的方法,其最终调用的是 sdscatlen。由于其中可能涉及 SDS 的扩容,sdscatlen 中调用 sdsMakeRoomFor 对带拼接的字符串 s 容量做检查,若无须扩容则直接返回 s;若需要扩容,则返回扩容好的新字符串 s。

函数中的 len、curlen 等长度值是不含结束符的,而拼接时用 memcpy 将两个字符串拼接在一起,指定了相关长度,故该过程保证了二进制安全,最后需要加上结束符。

2)sdscatlen

sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);

    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);
    sdssetlen(s, curlen+len);
    s[curlen+len] = '\0';
    return s;
}

3)sdsMakeRoomFor

如果有必要的话,会将空间进一步扩容

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // 拿到可用长度,即 sh->alloc - sh->len
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;

    /* 空间足够就直接返回 */
    if (avail >= addlen) return s;
    len = sdslen(s);
    // 定位到 sdshdr 首地址
    sh = (char*)s-sdsHdrSize(oldtype);
    // 拼接后的新长度
    newlen = (len+addlen);
    // #define SDS_MAX_PREALLOC (1024*1024)
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    /* 不使用 sdshdr5(不支持扩容), 因为用户持续的 apend 操作可能引起频繁的扩容*/
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {
       // 注意,这里用的是 s_realloc,仅将 buf[] 空间扩大了
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        /* 因为 header 大小已经改变, 字符串需要移动,不能使用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;
}

Redis 的 sds 中有如下扩容策略

  • 若 sds 中剩余空闲长度 avail 大于新增内容的长度 addlen,直接在数组 buf 末尾追加即可,无须扩容
  • 若 sds 中剩余空闲长度 avail 小于或等于新增内容的长度 addlen,则分情况讨论:新增后总长度 len+addlen <1MB 的,按新长度的 2倍 扩容;新增后总长度 len+addlen > 1MB 的,按新长度加上 1MB 扩容。

总结

Redis 只会使用 C 字符串作为字面量,在大多数情况下,Redis 使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。

比起 C 字符串,SDS 具有以下优点:

  • 常数复杂度获取字符串长度。
  • 杜绝缓冲区溢出。
  • 减少修改字符串长度时所需的内存重分配次数。
  • 二进制安全。
  • 兼容部分C字符串函数。

相关参考:

  • Redis 5设计与源码分析 【陈雷】
  • Redis 设计与实现 【黄健宏】