Redis源码之SDS

163 阅读9分钟

1.引入

要阅读redis源码redis的数据结构SDS是绕不开的,本文就来聊聊redis数据结构之SDS。

2.SDS 简介

(1)Redis中的字符串不是原生C语言的字符串,而是封装的简单动态字符串类型(SDS)

(2)SDS和C语言字符串的区别:

  • SDS获取字符串长度的时间复杂度为O(1),而C语言需要遍历字符串
  • SDS拥有自动扩容机制
  • SDS可以惰性释放空间,减少内存重分配次数
  • SDS不同于C语言,它是二进制安全的

(3)SDS字符串限制为512 MB

3.SDS数据结构

3.1 数据结构

redis3.x 数据结构

struct sdshdr {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    unsigned int len;

    //记录buf数组中未使用字节的数量
    unsigned int free;

    //char数组,用于保存字符串
    char buf[];
};
  • len ,表示这个 SDS 保存了一个长度为5的字符串;
  • free ,表示这个 SDS 没有剩余空间;
  • buf 是个char类型的数组,注意末尾保存了一个空字符'\0'。

buf 尾部自动追加一个'\0'字符并不会计算在 SDS 的len中,这是为了遵循 C 字符串以空字符串结尾的惯例,使得 SDS 可以直接使用一部分string.h库中的函数

redis6.x 数据结构

// 注意:sdshdr5从未被使用,Redis中只是访问flags。
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低3位存储类型, 高5位存储长度 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 已使用 */
    uint8_t alloc; /* 总长度,用1字节存储 */
    unsigned char flags; /* 低3位存储类型, 高5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 已使用 */
    uint16_t alloc; /* 总长度,用2字节存储 */
    unsigned char flags; /* 低3位存储类型, 高5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* 已使用 */
    uint32_t alloc; /* 总长度,用4字节存储 */
    unsigned char flags; /* 低3位存储类型, 高5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* 已使用 */
    uint64_t alloc; /* 总长度,用8字节存储 */
    unsigned char flags; /* 低3位存储类型, 高5位预留 */
    char buf[];
};

数据结构和我们分析的差不多嘛!也是加一个标识字段而已,并且不是int类型,而是1字节的char类型,使用其中的3位表示具体的类型。

同时,Redis 中也声明了5个常量分别表示五种类型的 SDS。

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

3.2 数据结构优化

在 Redis3.x 版本中不同长度的字符串占用的头部是相同的,如果某一字符串很短但是头部却占用了更多的空间,这未免太浪费了。所以我们将 SDS 分为三种级别的字符串:

  • 短字符串(长度小于32),len和free的长度用1字节即可;
  • 长字符串,用2字节或者4字节;
  • 超长字符串,用8字节。

共有五种类型的SDS(长度小于1字节、1字节、2字节、4字节、8字节)

优化方案:

在 SDS 中新增一个 type 字段来标识类型,但是没必要使用一个 4 字节的int类型去做!可以使用 1 字节的char类型,通过位运算(3位即可标识2^3种类型)来获取类型。**

如下所示为短字符串(长度小于32)的优化形式:

image.png

低三位存储类型,高5位存储长度,最多能标识的长度为32,所以短字符串的长度必定小于32。

无需free字段了,32-len即为free

4.SDS优点

4.1 O(1)时间复杂度获取字符串长度

由于C字符串不记录自身的长度,所以为了获取一个字符串的长度程序必须遍历这个字符串,直至遇到'0'为止,整个操作的时间复杂度为O(N)。而我们使用SDS封装字符串则直接获取len属性值即可,时间复杂度为O(1)。

4.2 二进制安全

什么是二进制安全?

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

C字符串中的字符除了末尾字符为'\0'外其他字符不能为空字符,否则会被认为是字符串结尾(即使实际上不是)。

这限制了C字符串只能保存文本数据,而不能保存二进制数据。而SDS使用len属性的值判断字符串是否结束,所以不会受'\0'的影响。

4.3 杜绝缓冲区溢出

字符串的拼接操作是使用十分频繁的,在C语言开发中使用char *strcat(char *dest,const char *src)方法将src字符串中的内容拼接到dest字符串的末尾。由于C字符串不记录自身的长度,所有strcat方法已经认为用户在执行此函数时已经为dest分配了足够多的内存,足以容纳src字符串中的所有内容,而一旦这个条件不成立就会产生缓冲区溢出,会把其他数据覆盖掉。

// strcat 源码
char * __cdecl strcat (char * dst, const char * src)
{
    char * cp = dst;
 
    while( *cp )
        cp++; /* 找到 dst 的结尾 */
 
    while( *cp++ = *src++ ) ; /* 无脑将 src 复制到 dst 中 */
 
    return( dst ); /* 返回 dst */
}    

如下图所示为一次缓冲区溢出:

image.png

与C字符串不同,SDS 的自动扩容机制完全杜绝了发生缓冲区溢出的可能性:

当SDS API需要对SDS进行修改时,API会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题。 

SDS 的sds sdscat(sds s, const char *t)方法在字符串拼接时会进行扩容相关操作。

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

/* s: 源字符串
 * t: 待拼接字符串
 * len: 待拼接字符串长度
 */
sds sdscatlen(sds s, const void *t, size_t len) {
    // 获取源字符串长度
    size_t curlen = sdslen(s);
  // SDS 分配空间(自动扩容机制)
    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    // 将目标字符串拷贝至源字符串末尾
    memcpy(s+curlen, t, len);
    // 更新 SDS 长度
    sdssetlen(s, curlen+len);
    // 追加结束符
    s[curlen+len] = '\0';
    return s;
}
自动扩容机制——sdsMakeRoomFor方法

strcatlen中调用sdsMakeRoomFor完成字符串的容量检查及扩容操作,重点分析此方法:

/* s: 源字符串
 * addlen: 新增长度
 */
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // sdsavail: s->alloc - s->len, 获取 SDS 的剩余长度
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
    // 根据 flags 获取 SDS 的类型 oldtype
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    /* 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);   /* 防止数据溢出 */
    // SDS_MAX_PREALLOC = 1024*1024, 即1MB
    if (newlen < SDS_MAX_PREALLOC)
        // 新增后长度小于 1MB ,则按新长度的两倍扩容
        newlen *= 2;
    else
        // 新增后长度大于 1MB ,则按新长度加上 1MB 扩容
        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. */
    // 不使用 sdshdr5 
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;
    // 获取新的 header 大小
    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > reqlen);  /* Catch size_t overflow */
    if (oldtype==type) {
        // 类型没变
        // 调用 s_realloc_usable 重新分配可用内存,返回新 SDS 的头部指针
        // usable 会被设置为当前分配的大小
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL; // 分配失败直接返回NULL
        // 获取指向 buf 的指针
        s = (char*)newsh+hdrlen;
    } else {
        // 类型变化导致 header 的大小也变化,需要向前移动字符串,不能使用 realloc
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        // 将原字符串copy至新空间中
        memcpy((char*)newsh+hdrlen, s, len+1);
        // 释放原字符串内存
        s_free(sh);
        s = (char*)newsh+hdrlen;
        // 更新 SDS 类型
        s[-1] = type;
        // 设置长度
        sdssetlen(s, len);
    }
    // 获取 buf 总长度(待定)
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        // 若可用空间大于当前类型支持的最大长度则截断
        usable = sdsTypeMaxSize(type);
    // 设置 buf 总长度
    sdssetalloc(s, usable);
    return s;
}

自动扩容机制总结:

扩容阶段:

  • 若 SDS 中剩余空闲空间 avail 大于新增内容的长度 addlen,则无需扩容;
  • 若 SDS 中剩余空闲空间 avail 小于或等于新增内容的长度 addlen:
    • 若新增后总长度 len+addlen < 1MB,则按新长度的两倍扩容;
    • 若新增后总长度 len+addlen > 1MB,则按新长度加上 1MB 扩容。

内存分配阶段:

  • 根据扩容后的长度选择对应的 SDS 类型:
    • 若类型不变,则只需通过 s_realloc_usable扩大 buf 数组即可;
    • 若类型变化,则需要为整个 SDS 重新分配内存,并将原来的 SDS 内容拷贝至新位置。

自动扩容流程图如下所示:

image.png

扩容后的 SDS 不会恰好容纳下新增的字符,而是多分配了一些空间(预分配策略),这减少了修改字符串时带来的内存重分配次数

4.3 内存重分配次数优化

(1) 空间预分配策略

因为 SDS 的空间预分配策略, SDS 字符串在增长过程中不会频繁的进行空间分配。

通过这种分配策略,SDS 将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。

(2) 惰性空间释放机制

空间预分配策略用于优化 SDS 增长时频繁进行空间分配,而惰性空间释放机制则用于优化 SDS 字符串缩短时并不立即使用内存重分配来回收缩短后多出来的空间,而仅仅更新 SDS 的len属性,多出来的空间供将来使用。

SDS 中调用sdstrim方法来缩短字符串:

/* sdstrim 方法删除字符串首尾中在 cset 中出现过的字符
 * 比如:
 * s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");
 * s = sdstrim(s,"Aa. :");
 * printf("%s\n", s);
 *
 * SDS 变成了 "HelloWorld"
 */
sds sdstrim(sds s, const char *cset) {
    char *start, *end, *sp, *ep;
    size_t len;

    sp = start = s;
    ep = end = s+sdslen(s)-1;
    // strchr()函数用于查找给定字符串中某一个特定字符
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (sp > ep) ? 0 : ((ep-sp)+1);
    if (s != sp) memmove(s, sp, len);
    s[len] = '\0';
    // 仅仅更新了len
    sdssetlen(s,len);
    return s;
}

参考:《敖丙Redis源码之SDS》