最新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[];
};

在这里插入图片描述        sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 25=32B,28=256B,216=64KB,232=4GB,264约等于无穷大,但实际官方字符串value最大值为512M(可能考虑性能以及内存大小原因,具体为什么512M不太清楚,有大佬知道评论告知下)
在这里插入图片描述

        对于这个版本,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在内部存储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 保存的内容被修改,如下:
在这里插入图片描述

与 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 可以宠用一部分 <string.h> 库定义的函数。

SDS常用API

函数名称作用复杂度
sdsnew创建一个包含给定C字符串的SDSO(N),N为给定C字符串的长度
sdsempty创建一个不包含任何内容的空SDSO(1)
sdsfree释放给定的SDSO(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为扩展新增的字节数
sdsrangeSDS区间内的数据保留,区间之外的数据覆盖或清除
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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
### 回答1: Redis 字符串Redis 中最基本的数据类型。它是一种键值对存储方式,键是字符串类型,值也是字符串类型。 Redis 字符串底层实现是基于双向链表和字典(dictionary)的。在 Redis 中,所有的键值对都存储在一个字典中,字典中的每一个节点都是一个键值对,同时也是一个双向链表的节点。字典本身是一个哈希表,用于快速查找和插入键值对。 当 Redis 中的一个字符串被修改时,Redis 会将旧的字符串值从字典中删除,然后将新的字符串值插入到字典中。这样,就可以保证 Redis 字符串的原子性,同时也保证了字符串的高效存储。 总结一下,Redis 字符串底层原理就是基于字典和双向链表实现的键值对存储方式。 ### 回答2: Redis 字符串底层实现原理是基于简单动态字符串SDS)和字典(dict)。 简单动态字符串SDS)是 Redis 底层字符串实现,它是一个动态分配的字符数组,并且可以在 O(1) 复杂度下进行字符串长度的获取和修改。SDS 的结构体中包含字符串指针、字符串长度、已分配内存长度等字段,通过这些字段可以方便地对字符串进行操作。 字典(dict)是 Redis 底层用于存储字符串键值对的数据结构。在 Redis 字符串中,键相当于字符串的名字,值则是存储的实际数据。字典采用哈希表作为底层实现,使用哈希函数将键映射到哈希桶中,以提高查找效率。在 Redis 中,哈希表的长度会根据实际数据的增加和删除进行动态扩容和缩容,以保证哈希表的平均负载因子不超过一个特定的值。 Redis 字符串底层实现成为一个 SDS 字符串结构,它与字典结构之间是相互独立的。当一个字符串被确定为一个键或值时,它会被存储在一个 SDSDICT 字典中,其中键为字符串本身,值则是一个指向 SDS 结构的指针。 总结来说,Redis 字符串底层实现原理是基于简单动态字符串SDS)和字典(dict)。SDS 是一个动态分配的字符数组,可以方便地进行字符串长度的获取和修改。而字典用于存储字符串键值对,通过哈希表提高查找效率。在 Redis 中,字符串被存储在一个 SDSDICT 字典中,其中键为字符串本身,值为指向 SDS 结构的指针。 ### 回答3: Redis字符串底层原理是通过使用简单动态字符串(简称SDS)实现的。SDSRedis自己实现的以C字符串结构为基础的字符串库,它解决了C字符串的一些限制,使得Redis可以支持更多的操作和功能。 在Redis中,每个字符串对象都由一个redisObject结构表示,该结构包含了一个指向SDS的指针和其他元数据。SDS结构由以下几部分组成: 1. len:记录字符串的长度,即字节数。 2. free:记录SDS结尾未使用的字节数,方便扩展字符串时无需重新分配内存。 3. buf:实际的字符数组,用于存储字符串的内容。 Redis字符串对象的底层原理有以下几个特点: 1. 动态扩展:SDS提供了高效的内存扩展机制,当字符串长度增加时,可以动态调整内存大小,避免了频繁的内存重新分配操作,提高了性能。 2. O(1)时间复杂度:SDS支持通过偏移量来直接访问字符串的某一位置的字符,所以读取和修改字符串的某一位置的操作时间复杂度为O(1)。 3. 惰性空间释放:当从字符串中删除部分字符时,SDS并不立即释放所占用的内存,而是通过将free字段增加相应的值来标记该内存已被释放,以备将来再次使用。 4. 兼容C字符串SDS结构与C字符串之间可以相互转换,方便Redis与其他系统进行兼容。 总的来说,Redis字符串底层原理是通过使用SDS实现的,SDS提供了高效的内存扩展和访问机制,使得Redis可以高效地处理字符串操作,提高了性能和灵活性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值