Redis源码学习——简单动态字符串SDS(Simple Dynamic String)

        前两天听了学长们的交流会,偶尔接触到了redis,考虑到redis只有2W多行代码,感觉代码量不是很大,所以决心看看他的源代码。

        由于刚刚接触redis,所以就跟着大牛的文章一步一步的学下去了。

        打算按照《Redis 设计与实现》http://www.redisbook.com/en/latest/#id1 这本书慢慢的学下去,希望今天是一个良好的起点。


SDS是Redis底层使用的字符串的表示形式,因为几乎所有Redis模块都使用了SDS,所以有必要好好了解一下这个实现机制。

1、SDS用途

在介绍SDS之前,先来看看他是如何工作的,这对于更好的理解实现会有帮助。

SDS主要用两方面作用:

1.1实现字符串对像

Redis是键值对数据库,数据库的值(value)可以是字符串、集合、列表等类型对象,但是数据库键(key)总是字符串对象。

举例如下:

redis> SET name "cai"
OK

redis> GET name
"cai"
这里键值对的键和值都是字符串对象,他们都包含一个SDS值。

1.2在Redis内部作为char*的替代品

因为 char* 类型的功能单一, 抽象层次低, 并且不能高效地支持一些 Redis 常用的操作(比如追加操作和长度计算操作), 所以在 Redis 程序内部, 绝大部分情况下都会使用 SDS 而不是 char* 来表示字符串。

在 C 语言中,字符串可以用一个 \0 结尾的 char 数组来表示。比如说, hello world 在 C 语言中就可以表示为 "hello world\0" 。

这种简单的字符串表示,在大多数情况下都能满足要求,但是,它并不能高效地支持长度计算和追加(append)这两种操作:

每次计算字符串长度(strlen(s))的复杂度为 θ(N) 。

对字符串进行 N 次追加,必定需要对字符串进行 N 次内存重分配(realloc)。

在 Redis 内部, 字符串的追加和长度计算很常见, 而 APPEND 和 STRLEN 更是这两种操作,在 Redis 命令中的直接映射, 

这两个简单的操作不应该成为性能的瓶颈。稍后将介绍这两个操作如何通过SDS降低复杂度。

2、SDS的实现

在源代码sds.h中定义了sds以及sdshdr结构体。

// sds 类型
typedef char *sds;

// sdshdr 结构
struct sdshdr {

    // buf 已占用长度
    int len;

    // buf 剩余可用长度
    int free;

    // 实际保存字符串数据的地方
    char buf[];
};
从这个定义中无法看出sds与sdshdr之间的关系。

通过查看sds.c中的代码,皆能迎刃而解了。

/*
 * 创建一个指定长度的 sds 
 * 如果给定了初始化值 init 的话,那么将 init 复制到 sds 的 buf 当中
 *
 * T = O(N)
 */
sds sdsnewlen(const void *init, size_t initlen) {

    struct sdshdr *sh;

    // 有 init ?
    // O(N)
    if (init) {
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }

    // 内存不足,分配失败
    if (sh == NULL) return NULL;

    sh->len = initlen;
    sh->free = 0;

    // 如果给定了 init 且 initlen 不为 0 的话
    // 那么将 init 的内容复制至 sds buf
    // O(N)
    if (initlen && init)
        memcpy(sh->buf, init, initlen);

    // 加上终结符
    sh->buf[initlen] = '\0';

    // 返回 buf 而不是整个 sdshdr
    return (char*)sh->buf;
}
通过创建函数可以看到,函数返回值是sds,在函数中返回的是sdshdr结构体中数据指向部分。

这就可以知道在创建sds对象的时候,其实是创建了一个sdshdr结构体对象,但是通过巧妙的指针指向,实现了sds。

之后的分析还会看到这样做的好处以及巧妙之处。

3、追加指令APPEND

利用 sdshdr 结构,可以用 θ(1) 复杂度获取字符串的长度,还可以减少追加(append)操作所需的内存重分配次数。

举例如下:

redis> SET msg "hello world"
OK

redis> APPEND msg " again!"
(integer) 18

redis> GET msg
"hello world again!"
首先, SET 命令创建并保存 hello world 到一个 sdshdr 中,这个 sdshdr 的值如下:
struct sdshdr {
    len = 11;
    free = 0;
    buf = "hello world\0";
}
当执行 APPEND 命令时,相应的 sdshdr 被更新,字符串 " again!" 会被追加到原来的 "hello world" 之后:
struct sdshdr {
    len = 18;
    free = 18;
    buf = "hello world again!\0                  ";     // 空白的地方为预分配空间,共 18 + 18 + 1 个字节
}
当调用 SET 命令创建 sdshdr 时, sdshdr 的 free 属性为 0 , Redis 也没有为 buf 创建额外的空间,

而在执行 APPEND 之后, Redis 为 buf 创建了多于所需空间一倍的大小。

在这个例子中, 保存 "hello world again!" 共需要 18 + 1 个字节, 但程序却为我们分配了 18 + 18 + 1 = 37 个字节 ,

这样一来, 如果将来再次对同一个 sdshdr 进行追加操作,只要追加内容的长度不超过 free 的值, 就不需要对 buf 进行内存重分配。

举例如下:

redis> APPEND msg " again!"
(integer) 25
struct sdshdr {
    len = 25;
    free = 11;
    buf = "hello world again! again!\0           ";     // 空白的地方为预分配空间,共 18 + 18 + 1 个字节
}
理解了SET和APPEND机制,就能知道为什么使用SDS能够降低获取长度和追加的复杂度了。


sds.c中的sdsMakeRoomFor函数说明了这种内存预分配优化策略。

/* 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.
 * 
 * Note: this does not change the *size* of the sds string as returned
 * by sdslen(), but only the free buffer space we have. */
/* 
 * 对 sds 的 buf 进行扩展,扩展的长度不少于 addlen 。
 *
 * T = O(N)
 */
sds sdsMakeRoomFor(
    sds s,
    size_t addlen   // 需要增加的空间长度
) 
{
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);
    size_t len, newlen;

    // 剩余空间可以满足需求,无须扩展
    if (free >= addlen) return s;
    
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // 目前 buf 长度
    len = sdslen(s);
    // 新 buf 长度
    newlen = (len+addlen);
    // 如果新 buf 长度小于 SDS_MAX_PREALLOC 长度
    // 那么将 buf 的长度设为新 buf 长度的两倍
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    // 扩展长度
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);

    if (newsh == NULL) return NULL;

    newsh->free = newlen - len;

    return newsh->buf;
}
如下代码就巧妙的利用了指针的指向,找到sds对应的sdshdr结构体。
sh = (void*) (s-(sizeof(struct sdshdr)));


SDS_MAX_PREALLOC 的值为 1024 * 1024 , 当大小小于 1MB 的字符串执行追加操作时, sdsMakeRoomFor 就为它们分配多于所需大小一倍的空间; 当字符串的大小大于 1MB , 那么 sdsMakeRoomFor 就为它们额外多分配 1MB 的空间。

4、后记

这样的内存分配是否有效?


确实会带来一部分内存的浪费

执行过 APPEND 命令的字符串会带有额外的预分配空间, 这些预分配空间不会被释放, 除非该字符串所对应的键被删除, 或者等到关闭 Redis 之后, 再次启动时重新载入的字符串对象将不会有预分配空间。

浪费相对于性能可以接受
因为执行 APPEND 命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一般并不算什么问题。

必要的措施
如果执行 APPEND 操作的键很多, 而字符串的体积又很大的话, 那可能就需要修改 Redis 服务器, 让它定时释放一些字符串键的预分配空间, 从而更有效地使用内存。






你可能感兴趣的:(数据库)