Redis基本数据结构之string

47 阅读8分钟

1. 前言

本文的内容是基于 redis-7.0.0 的源码,具体的源码可以参阅 redis 7.0.0。本文也不会对源文件 sds.h/sds.c 中所有的源码进行解释,只会讲解其中的主干部分,如果有兴趣可以参阅 sds.hsds.c

2. C 语言中的字符串

在 c 语言中字符串常量的形式如下:

"redis"

虽然字符串的内容有了,但是不确定的是字符串的长度。所以字符串常量在内存中的存储会在字符串末尾添加字符 '\0' 作为结尾。其内存结构示意图如下所示:

Redis基本数据结构之string_1.png

在大多数情况下,我们会通过 char * 类型的指针来操作字符串常量,定义该类型的指针 c 来间接访问字符串常量:

char *c = "redis";

其内存示意图如下:

Redis基本数据结构之string_2.png

C 语言中的字符串有如下几个缺陷:

  1. 计算字符串长度时,比如 strlen() 函数的时间复杂度为 O(n)
  2. 因为使用 '\0' 在内存中作为字符串的结尾,也就是说判断是否到达字符串末尾,是通过按照顺序扫描字符串中的每一个字符是否为 '\0' 来判断的。如果字符串本身就有 '\0' 字符,那么这样就会使得字符串"提前终止",比如计算字符串长度的 strlen() 函数或者打印字符串 printf() 函数。这种规则也使得 C 语言中的字符串是二进制不安全的(关于二进制安全可以参阅维基百科词条 Binary-safe)。
  3. 对 C 语言中的字符串进行拼接,每次都需要对字符串进行内存再分配。

3. Redis 中的 string

3.1 定义

redis 中的 string,定义了五种类型的 string,分别为 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64,其中 sdshdr5 不被使用。源代码如下:

sds.h
/* 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__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    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__)) sdshdr16 {
    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 {
    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[];
};
//sds.h
//定义 redis string 的类型,对应于 `sdshdr5`,`sdshdr8`,
//`sdshdr16`,`sdshdr32` 和 `sdshdr64。

#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

sdshdr5 之外,其余四种 sdshdr 类型的 string 都有 lenallocflagsbuf 字段。

  • len 代表 buf 中字符串的大小。
  • alloc 代表为 buf 分配的内存大小。
  • flags 记录当前 string 的 SDS_TYPE
  • buf 代表真实存储的字符串(buf 指针就可以看作指向 C 语言中的字符串 char * 指针)

sdshdr 是通过设计不同 sdshdr 类型来表示不同大小的字符串,并使用 attribute ((packed))这个编程小技巧,来实现紧凑型内存布局,达到节省内存的目的。

其中类型为 sdshdr32 的字符串的内存布局如下:

Redis基本数据结构之string_3.png

Redis string 的定义解决了如下几个问题:
  1. 计算 string 长度的时间复杂度为 O(1)
  2. 二进制安全,访问字符串不依赖于 buf 数组内容(不包括最后结尾添加的 '\0')中是否存在 '\0',而是根据 len 字段判断是否达到字符串末尾。
  3. 在一些字符串操作情况下,不必重新分配内存空间。

3.2 创建一个 redis string

创建 redis string 的源码如下: 定于类型别名 sds

typedef char *sds;
/* 使用 init 指针 和 initlen 创建一个新的 sds 字符串。
 * 如果 init 被设置成 NULL,则字符串将会被初始化为零字节。
 * 如果使用 SDS_NOINIT,则缓冲区则保持未初始化状态。
 * 字符串始终以空字符结尾,即使您使用以下方式创建了一个sds字符串:
 * mystring = sdsnewlen("abc",3);
 * 你可以使用 printf() 函数打印该字符串,因为该字符串有一个隐式的 '\0'。
 * 但是该字符串是而禁止安全的,可以在字符中间包含 '\0' 字符,因为长度存储在 len 字段中。 */

/* Create a new sds string with the content specified by the 'init' pointer
 * and 'initlen'.
 * If NULL is used for 'init' the string is initialized with zero bytes.
 * If SDS_NOINIT is used, the buffer is left uninitialized;
 *
 * The string is always null-terminated (all the sds strings are, always) so
 * even if you create an sds string with:
 *
 * mystring = sdsnewlen("abc",3);
 *
 * You can print the string with printf() as there is an implicit \0 at the
 * end of the string. However the string is binary safe and can contain
 * \0 characters in the middle, as the length is stored in the sds header. */
 
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
    void *sh;
    sds s;
    // 根据 initlen 确定 sdshdr 的类型
    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 pointer. */
    size_t usable;

    assert(initlen + hdrlen + 1 > initlen); /* Catch size_t overflow */
    sh = trymalloc?
        s_trymalloc_usable(hdrlen+initlen+1, &usable) :
        s_malloc_usable(hdrlen+initlen+1, &usable);
    if (sh == NULL) return NULL;
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    s = (char*)sh+hdrlen;
    fp = ((unsigned char*)s)-1;
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    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 = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;
}

该函数执行逻辑如下:

  1. 根据 initlen 确定 sdshdr 类型。
  2. 根据 sdshdr 类型计算 hdrlen。这里需要注意的是 hdrlen 并不包括 buf 字段的大小,只包括 len + alloc + flasg 的字段大小。C99 中引入了可变长数组,可以参阅 Array of variable length in a structure
  3. 分配内存空间,大小为 initlen + hdrlen + 1。
  4. 根据 sdshdr 类型 确定 flags 字段的值。
  5. 根据 initlen 确定 len 字段的值。
  6. 根据 useable 的值确认 alloc 字段的值。
  7. buf 字段作为函数的返回值,返回类型为 sds。

这里需要注意的是 _sdsnewlen 返回的类型为 sds(char *),该指针值为 sdshdr 中的 buf 字段。这就意味着你可以和使用 C 语言中的字符串的方式来使用 redis string。那么我该如何通过 sds 来得到它相对应的 sdshdr 对象的首地址呢?答案是通过计算偏移量。

定义宏,计算对应的 sdrhds 对象的首地址。

//返回一个匿名的 sdshdr##T 指针,指向 sds 所对应的 sdshdr##T 对象。
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
定义一个类型为 sdshdr##T *的指针,指向 sds 所对应的 sdshdr##T 对象。
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));

3.3 主要的方法

3.3.1 sdsIncrLen

void sdsIncrLen(sds s, ssize_t incr)

/* Increment the sds length and decrements the left free space at the
 * end of the string according to 'incr'. Also set the null term
 * in the new end of the string.
 *
 * This function is used in order to fix the string length after the
 * user calls sdsMakeRoomFor(), writes something after the end of
 * the current string, and finally needs to set the new length.
 *
 * Note: it is possible to use a negative increment in order to
 * right-trim the string.
 *
 * Usage example:
 *
 * Using sdsIncrLen() and sdsMakeRoomFor() it is possible to mount the
 * following schema, to cat bytes coming from the kernel to the end of an
 * sds string without copying into an intermediate buffer:
 *
 * oldlen = sdslen(s);
 * s = sdsMakeRoomFor(s, BUFFER_SIZE);
 * nread = read(fd, s+oldlen, BUFFER_SIZE);
 * ... check for nread <= 0 and handle it ...
 * sdsIncrLen(s, nread);
 */
void sdsIncrLen(sds s, ssize_t incr) {
    unsigned char flags = s[-1];
    size_t len;
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5: {
            unsigned char *fp = ((unsigned char*)s)-1;
            unsigned char oldlen = SDS_TYPE_5_LEN(flags);
            assert((incr > 0 && oldlen+incr < 32) || (incr < 0 && oldlen >= (unsigned int)(-incr)));
            *fp = SDS_TYPE_5 | ((oldlen+incr) << SDS_TYPE_BITS);
            len = oldlen+incr;
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            assert((incr >= 0 && sh->alloc-sh->len >= incr) || (incr < 0 && sh->len >= (unsigned int)(-incr)));
            len = (sh->len += incr);
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            assert((incr >= 0 && sh->alloc-sh->len >= incr) || (incr < 0 && sh->len >= (unsigned int)(-incr)));
            len = (sh->len += incr);
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            assert((incr >= 0 && sh->alloc-sh->len >= (unsigned int)incr) || (incr < 0 && sh->len >= (unsigned int)(-incr)));
            len = (sh->len += incr);
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            assert((incr >= 0 && sh->alloc-sh->len >= (uint64_t)incr) || (incr < 0 && sh->len >= (uint64_t)(-incr)));
            len = (sh->len += incr);
            break;
        }
        default: len = 0; /* Just to avoid compilation warnings. */
    }
    s[len] = '\0';
}

3.3.3 sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy)

sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) 扩大字符串的空间,如果空闲空间的大小 >= addlen, 则不进行重新分配的操作,直接返回。

/* Enlarge the free space at the end of the sds string so that the caller
 * is sure that after calling this function can overwrite up to addlen
 * bytes after the end of the string, plus one more byte for nul term.
 * If there's already sufficient free space, this function returns without any
 * action, if there isn't sufficient free space, it'll allocate what's missing,
 * and possibly more:
 * When greedy is 1, enlarge more than needed, to avoid need for future reallocs
 * on incremental growth.
 * When greedy is 0, enlarge just enough so that there's free space for 'addlen'.
 *
 * Note: this does not change the *length* of the sds string as returned
 * by sdslen(), but only the free buffer space we have. */
 
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
    //获得之前的 sdshdr 类型
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    /* 如果剩余空间 >= addlen 直接返回*/
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    /* 计算新的字符串长度 */
    reqlen = newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    /* 参数 greedy == 1,多分配空间 */
    if (greedy == 1) {
        if (newlen < SDS_MAX_PREALLOC)
            newlen *= 2;
        else
            newlen += SDS_MAX_PREALLOC;
    }
    //根据新字符串长度来得到对应的新的 sdshdr type
    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;
    //获取头部字段长度 len + alloc + flags 
    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > reqlen);  /* Catch size_t overflow */
    //如果新的 sdshdr type 和旧的一样
    if (oldtype==type) {
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        //如果新的 sdshdr type 和旧的不一样
        //需要拷贝字符串
        /* Since the header size changes, need to move the string forward,
         * and can't use realloc */
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        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);
    }
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    //设置 alloc 字段的值
    sdssetalloc(s, usable);
    return s;
}

4.总结

  1. 计算 string 长度的时间复杂度为 O(1)
  2. 二进制安全,访问字符串不依赖于 buf 数组内容(不包括最后结尾添加的 '\0')中是否存在 '\0',而是根据 len 字段判断是否达到字符串末尾。
  3. 在一些字符串操作情况下,不必重新分配内存空间。
  4. sdshdr 是通过设计不同 sdshdr 类型来表示不同大小的字符串,并使用 attribute ((packed))这个编程小技巧,来实现紧凑型内存布局,达到节省内存的目的。

5.引用