Redis 动态字符串 SDS 源码解析

本文做者: Pushy
本文连接: http://pushy.site/2019/12/21/redis-sds/
版权声明: 本博客全部文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!java

1. 什么是 SDS

众所周知,在 Redis 的五种数据解构中,最简单的就是字符串:redis

redis> set msg "Hello World"

而 Redis 并无直接使用 C 语言传统的字符串表示,而是本身构建了一个名为简单动态字符串(Simple dynamic string,即 SDS)的抽象数据结构。数据库

执行上面的 Redis 命令,在 Server 的数据库中将建立一个键值对,即:数组

  • 键为 “msg” 的 SDS;
  • 值为 “Hello World” 的 SDS。

咱们再来看下 SDS 的定义,在 Redis 的源码目录 sds.h 头文件中,定义了 SDS 的结构体:安全

struct sdshdr {
    // 记录 buf 数组中当前已使用的字节数量
    unsigned int len;
    // 记录 buf 数组中空闲空间长度
    unsigned int free;
    // 字节数组
    char buf[];

};

能够看到,SDS 经过 lenfree 属性值来描述字节数组 buf 当前的存储情况,这样在以后的扩展和其余操做中有很大的做用,还能以 O(1) 的复杂度获取到字符串的长度(咱们知道,C 自带的字符串自己并不记录长度信息,只能遍历整个字符串统计)网络

那么为何 Redis 要本身实现一套字符串数据解构呢?下面慢慢来研究!数据结构

2. SDS 的优点

杜绝缓冲区溢出

除了获取字符串长度的复杂度为较高以外,C 字符串不记录自身长度信息带来的另外一个问题就是容易形成内存溢出。举个例子,经过 C 内置的 strcat 方法将字符串 motto 追加到 s1 字符串后边:curl

void wrong_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = s a question.

可是输出却出乎意料,咱们只想修改 s1 字符串的值,而 s2 字符串也被修改了。这是由于 strcat 方法假定用户在执行前已经为 s1 分配了足够的内存,能够容纳 motto 字符串中的内容。而一旦这个假设不成立,就会产生缓冲区溢出函数

经过 Debug 咱们看到,s1 变量内存的初始位置为 94458843619936 (10进制), s2 初始位置为 94458843619968,是一段相邻的内存块:性能

wrong_strcat.png

因此一旦经过 strcat 追加到 s1 的字符串 motto 的长度大于 s1 到 s2 的内存地址间隔时,将会修改到 s2 变量的值。而正确的作法应该是在 strcat 以前为 s1 从新调整内存大小,这样就不会修改 s2 变量的值了:

void correct_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    // 为 s1 变量扩展内存,扩展的内存大小为 motto * sizeof(char) + 空字符结尾(1)
    s1 = realloc(s1, (strlen(motto) * sizeof(char)) + 1);
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = World

能够看到,扩容后的 s1 变量内存地址起始位置变为了 94806242149024(十进制),s2 起始地址为 94806242148992。这时候 s1 与 s2 内存地址的间隔大小已经足够 motto 字符串的存放了:

correct_strcat.png

而与 C 字符串不一样, SDS 的空间分配策略彻底杜绝了发生缓冲区溢出的可能性,具体的实如今 sds.c 中。经过阅读源码,咱们能够明白之因此 SDS 能杜绝缓冲区溢出是由于再调用 sdsMakeRoomFor 时,会检查 SDS 的空间是否知足修改所需的要求(即 free >= addlen 条件),若是知足 Redis 将会将 SDS 的空间扩展至执行所需的大小,在执行实际的 concat 操做,这样就避免了溢出发生:

// 与 C 语言 string.h/strcat 功能相似,其将一个 C 字符串追加到 sds
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}

sds sdscatlen(sds s, const char *t, size_t len) {
    struct sdshdr *sh;
    size_t curlen = sdslen(s);  // 获取 sds 的 len 属性值

    s = sdsMakeRoomFor(s, len);
    if (s == NULL) return NULL;
    // 将 sds 转换为 sdshdr,下边会介绍
    sh = (void *) (s - sizeof(struct sdshdr));
    // 将字符串 t 复制到以 s+curlen 开始的内存地址空间
    memcpy(s + curlen, t, len);
    sh->len = curlen + len;     // concat后的长度 = 原先的长度 + len
    sh->free = sh->free - len;  // concat后的free = 原来 free 空间大小 - len
    s[curlen + len] = '\0';     // 与 C 字符串同样,都是以空字符 \0 结尾
    return s;
}

// 确保有足够的空间容纳加入的 C 字符串, 而且还会分配额外的未使用空间
// 这样就杜绝了发生缓冲区溢出的可能性
sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);  // 当前 free 空间大小
    size_t len, newlen;

    if (free >= addlen) {
        /* 若是空余空间足够容纳加入的 C 字符串大小, 则直接返回, 不然将执行下边的代码进行扩展 buf 字节数组 */
        return s;
    }
    len = sdslen(s);  // 当前已使用的字节数量
    sh = (void *) (s - (sizeof(struct sdshdr)));
    newlen = (len + addlen);  // 拼接后新的字节长度

    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL; // 申请内存失败

    /* 新的 sds 的空余空间 = 新的大小 - 拼接的 C 字符串大小 */
    newsh->free = newlen - len;
    return newsh->buf;
}

另外,在看源码时我对 sh = (void *) (s - sizeof(struct sdshdr)); 一脸懵逼,若是不懂能够看:Redis(一)之 struct sdshdr sh = (void) (s-(sizeof(struct sdshdr)))讲解

减小修改字符带来的内存重分配次数

对于包含 N 个字符的 C 字符串来讲,底层老是由 N+1 个连续内存的数组来实现。因为存在这种关系,所以每次修改时,程序都须要对这个 C 字符串数组进行一次内存重分配操做:

  • 若是是拼接操做:扩展底层数组的大小,防止出现缓冲区溢出(前面提到的);
  • 若是是截断操做:须要释放不使用的内存空间,防止出现内存泄漏

Redis 做为频繁被访问修改的数据库,为了减小修改字符带来的内存重分配的性能影响,SDS 也变得很是须要。由于在 SDS 中,buf 数组的长度不必定就是字符串数量 + 1,能够包含未使用的字符,经过 free 属性值记录。经过未使用空间,SDS 实现了如下两种优化策略:

Ⅰ、空间预分配

空间预分配用于优化 SDS 增加的操做:当对 SDS 进行修改时,而且须要对 SDS 进行空间扩展时,Redis 不只会为 SDS 分配修改所必须的空间,还会对 SDS 分配额外的未使用空间

在前面的 sdsMakeRoomFor 方法能够看到,额外分配的未使用空间数量存在两种策略:

  • SDS 小于 SDS_MAX_PREALLOC:这时 len 属性值将会和 free 属性相等;
  • SDS 大于等于 SDS_MAX_PREALLOC:直接分配 SDS_MAX_PREALLOC 大小。
sds sdsMakeRoomFor(sds s, const char *t, size_t len) {
    ...
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL;
    newsh->free = newlen - len;
    return newsh->buf;
}

经过空间预分配策略,Redis 能够减小连续执行字符串增加操做所需的内存重分配次数。

Ⅱ、惰性空间释放

惰性空间释放用于优化 SDS 字符串缩短操做,当须要缩短 SDS 保存的字符串时,Redis 并不当即使用内存重分配来回收缩短多出来的字节,而是使用 free 属性将这些字节记录起来,并等待来使用

举个例子,能够看到执行完 sdstrim 并无当即回收释放多出来的 22 字节的空间,而是经过 free 变量值保存起来。当执行 sdscat 时,先前所释放的 22 字节的空间足够容纳追加的 C 字符串 11 字节的大小,也就不须要再进行内存空间扩展重分配了。

#include "src/sds.h"

int main() {
    // sds{len = 32, free = 0, buf = "AA...AA.a.aa.aHelloWorld     :::"}
    s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");  
    // sds{len = 10, free = 22, buf = "HelloWorld"}
    s = sdstrim(s, "Aa. :");  
    // sds{len = 21, free = 11, buf = "HelloWorld! I'm Redis"}
    s = sdscat(s, "! I'm Redis");   
    return 0;
}

经过惰性空间释放策略,SDS 避免了缩短字符串时所需内存重分配操做,并会未来可能增加操做提供优化。与此同时,SDS 也有相应的 API 真正地释放 SDS 的未使用空间。

二进制安全

C 字符串必须符合某种编码,而且除了字符串的末尾以外,字符串不能包含空字符(\0),不然会被误认为字符串的末尾。这些限制致使不能保存图片、音频等这种二进制数据。

可是 Redis 就能够存储二进制数据,缘由是由于 SDS 是使用 len 属性值而不是空字符来判断字符串是否结束的。

兼容部分 C 字符串函数

咱们发现, SDS 的字节数组有和 C 字符串类似的地方,例如也是以 \0 结尾(可是不是以这个标志做为字符串的结束)。这就使得 SDS 能够重用 <string.h> 库定义的函数:

#include <stdio.h>
#include <strings.h>
#include "src/sds.h"

int main() {
    s = sdsnew("Cat");
    // 根据字符集比较大小
    int ret = strcasecmp(s, "Dog");
    printf("%d", ret);
    return 0;
}

3. 总结

看完 Redis 的 SDS 的实现,终于知道 Redis 只因此快,确定和 epoll 的网络 I/O 模型分不开,可是也和底层优化的简单数据结构分不开。

SDS 精妙之处在于经过 len 和 free 属性值来协调字节数组的扩展和伸缩,带来了较比 C 字符串太多的性能更好的优势。什么叫牛逼?这就叫牛逼!

相关文章
相关标签/搜索