最新redis底层数据结构之SDS(简单动态字符串)

简单动态字符串(simple dynamic string,SDS)

    简单动态字符串(simple dynamic string, 简称SDS),是redis数据类型字符串的底层数据结构,sds 有两个版本,在Redis 3.2之前和使用的是第一个版本,3.2及以后版本使用另外一个版本(今天主要讲3.2之后的SDS)

3.2版本之前的SDS

    其数据结构如下所示:

//SDS定义
struct sdshdr{
     //记录buf数组中已使用字节的数量
     //等于 SDS 保存字符串的长度  占4字节
     unsigned int len;
     //记录 buf 数组中未使用字节的数量 占4字节
     unsigned int free;
     //字节数组,用于保存字符串
     char buf[];
}
typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
    // 引用计数
    int refcount;
    // 指向实际值的指针
    void *ptr;
} robj;

        关于这个版本 ,buf[]所保存的字符串小于 39 字节时,SDS选择 embstr 编码,大于 39字节则 raw 编码(embstr 编码时3.0才出现的)。

        为什么是以39字节为临界点?
        首先参数REDIS_ENCODING_EMBSTR_SIZE_LIMIT默认值为39,其次就是内存的分配优化问题,embstr是一块连续的内存区域,由redisObject和sdshdr组成,redisObject占16个字节,当buf内的字符串长度是39时,sdshdr的大小为8+39+1=48 加1字节时buf结尾的‘\0’。加上redisObject刚好64字节,符合jemalloc内存分配器的分配策略 (分配当前值最接近2的n次方的空间,如50 就会分配64), 所以大于39 就会大于64 分配128 太浪费, 所以采取raw编码 redisObject和sdshdr 不连续的内存空间 所以这种编码要分配两次空间。

3.2及之后版本的SDS

        但是在Redis 3.2 版本中,对数据结构做出了修改,针对不同的长度范围定义了不同的结构,针对长度不同的字符串做了优化 结构如下:

typedef char *sds;      
//sdshdr5从未使用过,我们只是直接访问标志字节。然而,这里是为了记录类型5 SDS字符串的布局
struct __attribute__ ((__packed__)) sdshdr5 {     // 对应的字符串长度小于 1<<5
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {     // 对应的字符串长度小于 1<<8
    uint8_t len; /* used */                       //目前字符创的长度
    uint8_t alloc;                                //已经分配的总长度
    unsigned char flags;                          //flag用3bit来标明类型,类型后续解释,其余5bit目前没有使用
    char buf[];                                   //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 {    // 对应的字符串长度小于 1<<16
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {    // 对应的字符串长度小于 1<<32
    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 {    // 对应的字符串长度小于 1<<64
    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[];
};

最新redis底层数据结构之SDS(简单动态字符串)_第1张图片        sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 25=32B,28=256B,216=64KB,232=4GB,264约等于无穷大,但实际官方字符串value最大值为512M(可能考虑性能以及内存大小原因,具体为什么512M不太清楚,有大佬知道评论告知下)
最新redis底层数据结构之SDS(简单动态字符串)_第2张图片

        对于这个版本,buf[]所存储的字符串大于44字节时,选择raw编码,小于44字节时则选择embstr编码。

为什么以44字节为临界点?
        首先,rendis参数REDIS_ENCODING_EMBSTR_SIZE_LIMIT默认值时44,同3.2版本之前的SDS不同的是,SDS的头部信息占用空间更小了,代码中使用了uint8_t代替int,占用的内存空间更小了,sds的头部信息占用的空间大小:sdsdr8 = uint8_t(1个字节)* 2 + char(1个字节) = 3个字节 原来3.2版本前 头部是len和free 都是int 占4字节*2=8字节,这里就少了5字节 对应字符串能使用的长度就是39+5=44字节。 也可以使用sdshdr5分配32字节 也满足上面两个为什么都要64字节 因为分配32字节buf[]能用的就很少了 避免重新分配空间,官方也说sdshdr5这个从未使用过默认还是使用sdshdr8。

        以下是不同长度字符串value的编码测试案例以及不同编码的结构图:

127.0.0.1:6379> set yangsong 123456789-123456789-123456789-123456789-1234
OK
127.0.0.1:6379> object encoding yangsong
"embstr"
127.0.0.1:6379> set yangsong 123456789-123456789-123456789-123456789-12345
OK
127.0.0.1:6379> object encoding yangsong
"raw"
127.0.0.1:6379> set yangsong 1234
OK
127.0.0.1:6379> object encoding yangsong
"int"
127.0.0.1:6379>  set yangsong 1234567890123456789123456789234645645744534543
OK
127.0.0.1:6379> object encoding yangsong
"raw"

最新redis底层数据结构之SDS(简单动态字符串)_第3张图片

        我们可能以为redis在内部存储string都是用sds的数据结构实现的,其实在整个redis的数据存储过程中为了提高性能,内部做了很多优化。整体选择顺序应该是:

  • int,存储字符串长度小于21且能够转化为整数的字符串(大于8字节)。
  • embstr,存储字符串长度小于44字节的字符串(REDIS_ENCODING_EMBSTR_SIZE_LIMIT)。
  • raw,剩余情况使用raw编码进行存储。

embstr和sds的区别在于内存的申请和回收

        embstr的创建只需分配一次内存,而raw为两次(一次为sds分配对象,另一次为redisObject分配对象,embstr省去了第一次)。相对地,;embstr释放内存的次数也为一次,raw为两次。embstr的redisObject和sds放在一起,更好地利用缓存带来的优势。 redis并未提供任何修改embstr的方式,即embstr是只读的形式。对embstr的修改实际上是先转换为raw再进行修改。


什么不使用C语言字符串实现,而是使用 SDS呢

(1)常数复杂度获取字符串长度

        因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到了代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。而 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为 O(1)。通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 O(N) 降低到 O(1),这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。同理还有使用的空间以及还剩多少空间可用的时间复杂度都是 O(1)。

//根据不同header类型返回SDS已经使用过的空间字符数
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;
}
//根据不同header类型返回SDS中未使用的空间字符数
static inline size_t sdsavail(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5: {
            return 0;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            return sh->alloc - sh->len;
        }
    }
    return 0;
}
//根据不同header类型返回SDS中分配的的空间字符数
// sdsalloc() = sdsavail() + sdslen()
static inline size_t sdsalloc(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)->alloc;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->alloc;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->alloc;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->alloc;
    }
    return 0;
}


(2) 杜绝缓冲区溢出

        C 字符串容易造成缓冲区移除,C 字符串不记录自身长度,不会自动进行边界检查,所以会增加溢出的风险,当程序将数据写入缓冲区时,会超过缓冲区的边界,并覆盖相邻的内存位置。如下图

char* strcat(char* dest, const char* src);

该函数是将 src 字符串内容拼接到 dest 字符串的末尾。假如有 s1 = “Redis”,s2 = “MongoDB”,如下:
在这里插入图片描述

当执行 strcat(s1,‘Cluster’) 时,未给 s1 分配足够的内存空间,s1 的数据将会溢出到 s2 所在的内存空间,导致 s2 保存的内容被修改,如下:
最新redis底层数据结构之SDS(简单动态字符串)_第4张图片

与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓存溢出的可能性,他会按照如下步骤进行:

  1. 先检查 SDS 的空间是否满足修改所需的要求。
  2. 如果不满足要求的话,API 会自动将 SDS 的空间扩展到执行修改所需的大小。
  3. 最后才是执行实际的修改操作。
/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
 * end of the specified sds string 's'.
 *
 * After the call, the passed sds string is no longer valid and all the
 * references must be substituted with the new pointer returned by the call. */
sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);//获取s已经使用过的空间字符数

    s = sdsMakeRoomFor(s,len);//扩大s的空闲空间
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);//拷贝数据
    sdssetlen(s, curlen+len);//设置s的len
    s[curlen+len] = '\0';//最后加上空字符串
    return s;
}

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

        因为 字符串每次增长或缩短程序都总要对保存这个 字符串的数组进行一次内存重分配操作,内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以是一个比较耗时的操作。对redis造成不小性能影响,为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联;在 SDS 中,buf 数组的长度不一定就是字符串数量加一,数组里面可以包含1未使用的字节,而这些字节的数量就由 SDS 的 alloc和len属性记录。
        另外SDS 还实现了空间预分配和惰性空间释放两种优化策略来减少SDS在扩容和缩容时内存重分配次数。

1. 空间预分配

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

其中,额外分配的未使用空间数量由以下公式决定(参数SDS_MAX_PREALLOC为1024*1024=1MB):

  • SDS空间小于1MB
      修改后,SDS 的长度将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,如:如果修改之后SDS的len将变为20字节,那么程序也会分配20字节的未使用空间,SDS的buf[]实际长度变为20 + 20 + 1 = 41(额外一个字节用于保存结束符\n)。

  • SDS空间大于1MB
      修改后,SDS 的长度将大于等于 1MB,那么程序会分配 1MB 的未使用空间,如:修改之后的len将变为10MB,那么程序会分配1MB的未使用空间,SDS的buf[]长度为10MB + 1MB + 1byte。

        通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。
        具体函数如下:

/* 扩大sds字符串末尾的空闲空间,以便调用者确定调用此函数后可以覆盖字符串末尾的addlen字节,再加上nul term的一个字节
 * 注意: 这不会改变sdslen()返回的sds字符串的*length*,但只会改变我们所拥有的空闲缓冲区空间. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s); //返回SDS中未使用的空间字符数,直接alloc(分配的)减去used(使用的)
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;

    /* sds剩余空间足够的话 直接返回. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    //修改的长度小于SDS_MAX_PREALLOC(即1MB) 直接扩大两倍
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else//修改的长度大于等于SDS_MAX_PREALLOC(即1MB) 直接扩大1MB空间
        newlen += SDS_MAX_PREALLOC;

    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 > len);  /* Catch size_t overflow */
    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;
}
2. 惰性空间释放

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

(4) 二进制安全

        C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
        为了确保 Redis 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是怎么样的,他被读取时就是怎么样的。通过使用二进制安全的 SDS,而不是使用 C 字符串,使得 Redis 不仅可以一保存文本数据,还可以保存任意格式的二进制数据。

(5) 兼容部分 C 字符串函数

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

SDS常用API

函数名称 作用 复杂度
sdsnew 创建一个包含给定C字符串的SDS O(N),N为给定C字符串的长度
sdsempty 创建一个不包含任何内容的空SDS O(1)
sdsfree 释放给定的SDS O(N),N为被释放SDS的长度
sdslen 返回SDS已经使用过的空间字符数 O(1),直接读取SDS的len属性来直接获得
sdsavail 返回SDS中未使用的空间字符数 O(1),直接读取SDS的free属性来直接获得
sdsdup 创建一个给定SDS的副本 O(N),N为给定SDS的长度
sdsclear 清空SDS中字符串保存的内容 因为惰性空间释放策略,复杂的为O(1)
sdscat 将C字符串拼接到SDS字符串末尾 O(N),N为被拼接C字符串的长度
sdscatsds 将SDS拼接到另一个SDS中 O(N),N为被拼接SDS字符串的长度
sdscpy 将给定的C字符串复制并覆盖到SDS中的字符串 O(N),N为被复制C字符串的长度
sdsgrowzero 用空字符将SDS扩展至给定的长度 O(N),N为扩展新增的字节数
sdsrange SDS区间内的数据保留, 区间之外的数据覆盖或清除
sdstrim 接受一个SDS和一个C字符串作为参数, 从移除SDS中移除所有在C字符串中出现过的字符
sdscmp 对比两个SDS字符串是否相等 O(N),N为两个SDS中较短的那个SDS的长度
sdsRemoveFreeSpace 重新分配sds字符串,使其在末尾没有空闲空间 O(N)
sdsMakeRoomFor 扩大sds字符串末尾的空闲空间 O(N)

参考资料:

        极客时间-蒋德钧《Redis核心技术与实战》
        https://blog.csdn.net/meser88/article/details/109339670
        https://juejin.cn/post/6854573221237719048
        https://segmentfault.com/q/1010000002388947
        https://blog.csdn.net/qq_33996921/article/details/105226259
        https://blog.csdn.net/yangbodong22011/article/details/78419966

你可能感兴趣的:(reids,redis,数据结构,字符串)