redis最核心的数据结构String之SDS

什么是SDS?

SDS名称叫做(simple dynamic string)翻译过来就叫简单的动态字串。顾名思义,它主要就是用来存字串数据的。类似于java的String对象。

它主要的作用就是来解决传统C语言中字符串的一些限制,比如内存缓冲区溢出和内存泄漏问题,同时还针对性的提供了一些String相关的性能优化。

为什么会有SDS存在,而不是直接使用Char[]数组?

字串长度问题?

我们都知道。 Redis 底层是C语言编写的。但是在C语言中是没有类似于Java里面的String类型,所以在C语言中实现字串显示,只能是靠自己的char[]来实现。

那么基于这种存储方式,如果我们想要获取 「Redis」的长度,就需要从头开始遍历,直到遇到 ‘\0’ 为止(\0是C语言中的一个结束符号)。这样就会有一个问题,redis本身就是以快为核心的,而通过这种方式来获取长度,每次都遍历的话,那它也就跟快不沾边了。

所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串。

也就是说。SDS动态字符串,肯定是有单独维护长度的属性的。这样获取长度的时间复杂度就从O(N)到了O(1)。

二进制安全问题?

C语言获取一个字符的长度,是通过遍历char[]数组直到遇到’\0’为止,这样来判断一个字符的长度的,但是对于很多的图片数据、音视频数据等二进制文件,很多都会存在 ‘\0’ 。对于这类 数据就会存在数据安全的问题。如下

对于sds来说。无论字串数据是什么样的,它不关心。他对于所有的数据都会有单独的长度字段来存放数据的长度。所以就解决了这个问题。但实际上对比C语言来说,sds的结构体在空间方面占用量肯定就比c语言多了很多。

SDS的结构体是什么样的?

我们下载一下redis的源码包。这个官方都有提供。在源码包下可以看到有很多的库。其中我们在src目录下就可以看到有关于sds的包,大家可以看一下sds.h这个文件 。下面代码我将关键结构体也已经复制出来了。

typedef char *hisds;

/* 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__)) hisdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr16 {
    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__)) hisdshdr32 {
    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__)) hisdshdr64 {
    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[];
};

#define HI_SDS_TYPE_5  0
#define HI_SDS_TYPE_8  1
#define HI_SDS_TYPE_16 2
#define HI_SDS_TYPE_32 3
#define HI_SDS_TYPE_64 4
#define HI_SDS_TYPE_MASK 7
#define HI_SDS_TYPE_BITS 3
#define HI_SDS_HDR_VAR(T,s) struct hisdshdr##T *sh = (struct hisdshdr##T *)((s)-(sizeof(struct hisdshdr##T)));
#define HI_SDS_HDR(T,s) ((struct hisdshdr##T *)((s)-(sizeof(struct hisdshdr##T))))
#define HI_SDS_TYPE_5_LEN(f) ((f)>>HI_SDS_TYPE_BITS)

上面是原码,下面是其核心的结构体如下图

一个实际的数据在内存中是如何存储的呢? 我们以name为例子

存储的字串为name,对应的sds中存的结构体就包含了下面的这4部分。

说明一下下面和字段的意思:

len : 表示 buf数组已用空间的长度,占 4 个字节,不包括 ‘\0’;

alloc:表示 buf数组 的实际分配长度,占 4 个字节,不包括 ‘\0’;

flags:标记当前字节数组是 sdshdr8/16/32/64 中的哪一种,占 1 个字节;

buf:表示字节数组,保存实际数据。Redis 会在数组最后加一个’\0’表示结束,这也是与C语言相同的,需要额外占用 1 个字节的开销;

在看一个存储值为redis的

什么情况下不适合使用SDS?

第一:对于一个key的数据量来说,如果特别大,则不建议使用SDS。一般建议小于10K

第二:对于大量的同一业务数据来说,不建议使用,建议使用hash/set等。我们了解sds的结构后可以明显知道,一个key就会多一些额外的空间来存储长度之类的。批量的同一业务数据则会产生很多额外的空间浪费。

动态扩容

动态扩容是一个结构体最复杂地方。因为涉及到内存的分配。

我们来详细分析一下sds的动态扩容机制。例如我们给一个key设置一个内容为“hi”,那么它的存储结构为:

当我们将hi重新赋值为“hi,Amy”,那sds内存是如何执行的呢?

首先redis会申请新内存空间,然后对字串大小进行判断:

如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;

如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。(称为内存预分配)

1M是可以存储的数据量很大了。所以当大于1M时,说明文件存储的量可能会很大,所以就会预分配1M的数据量。

我们看一下扩容源码:大家可以参考一下

sds sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);  // 获取当前字符串可用的额外空间大小
    size_t len, newlen, reqlen;
    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);   /* 检查 size_t 是否溢出 */
    if (greedy == 1) {  // 如果采用贪婪策略
        if (newlen < SDS_MAX_PREALLOC)
            newlen *= 2;  // 如果需要的长度小于最大预分配长度,则将长度翻倍
        else
            newlen += SDS_MAX_PREALLOC;  // 否则增加最大预分配长度
    }

    type = sdsReqType(newlen);  // 计算新字符串所需的类型

    /* 不使用类型 5:因为用户正在追加字符串,而类型 5 无法记住空闲空间,
     * 因此每次追加操作都需要调用 sdsMakeRoomFor()。*/
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);  // 获取新字符串的头长度
    assert(hdrlen + newlen + 1 > reqlen);  /* 检查 size_t 是否溢出 */
    if (oldtype==type) {  // 如果新旧类型相同
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);  // 重新分配内存
        if (newsh == NULL) return NULL;  // 如果分配失败,则返回 NULL
        s = (char*)newsh+hdrlen;  // 更新字符串指针
    } else {
        /* 由于头大小发生变化,需要将字符串向前移动,
         * 不能使用 realloc */
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);  // 分配新内存
        if (newsh == NULL) return NULL;  // 如果分配失败,则返回 NULL
        memcpy((char*)newsh+hdrlen, s, len+1);  // 复制原字符串内容
        s_free(sh);  // 释放原内存
        s = (char*)newsh+hdrlen;  // 更新字符串指针
        s[-1] = type;  // 更新字符串类型
        sdssetlen(s, len);  // 更新字符串长度
    }
    usable = usable-hdrlen-1;  // 计算可用空间大小
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);  // 如果可用空间大于类型最大大小,则限制为最大大小
    sdssetalloc(s, usable);  // 更新字符串分配的大小
    return s;  // 返回更新后的字符串
}

总结

文章介绍了SDS设计的初衷及为什么要这样设计,也介绍了String在哪些情况下不太适合。还有扩容的机制。

可以说 SDS 是redis的一种很核心也很优秀的设计结构,它基于C语言确也优化了 C语言对于字符串的一些问题。

redis是最优秀的缓存技术之一,离不开它本身的这种设计结构。

原文阅读