那些年背过的题:Redis SDS和C字符串对比

189 阅读7分钟

Redis 的简单动态字符串(SDS,Simple Dynamic String)是一种用于表示动态字符串的抽象数据结构。它在 Redis 中被广泛使用,主要用来替代传统的 C 字符串(以 \0 结尾的字符数组)。

以下是它们的主要差异:

1. 数据结构

C 字符串

  • 存储方式:C 字符串是一个以 null ('\0') 结尾的字符数组。
  • 长度计算:必须遍历整个字符串直到遇到 '\0' 才能知道字符串的长度。
char str[] = "Hello, World!";

Redis SDS

  • 存储方式:SDS 是一种自包含的数据结构,不仅存储字符数据,还存储相关元数据。
  • 长度计算:通过元数据中的长度字段快速获取字符串的长度,而无需遍历字符数组。
struct sdshdr {
    int len;     // 当前字符串的长度
    int free;    // 剩余可用空间的长度
    char buf[];  // 实际保存字符串的字符数组
};

2. 内存管理

C 字符串

  • 固定分配:内存大小在分配时确定,后续需要手动管理内存(如重新分配)。
  • 缓冲区溢出:容易产生缓冲区溢出错误,因为没有额外的机制防止写入超出分配空间。

Redis SDS

  • 动态扩展:当字符串长度增加时,SDS 可以自动扩展内存,同时保留一定的预分配空间来减少频繁的内存操作。
  • 内存预分配:SDS 采用了惰性空间释放的策略,当字符串长度减少时,不会立即缩小内存,而是保持一定的冗余空间。

3. 性能

C 字符串

  • 操作效率低:由于每次获取字符串长度都需要遍历整个字符串,对于长字符串,这个操作开销很大。
  • 安全性低:容易出错,需要开发者自己管理内存和考虑边界条件。

Redis SDS

  • 操作效率高:字符串长度存储在元数据中,获取长度时间复杂度为 O(1)。
  • 安全性高:内置了内存管理机制,避免了常见的缓冲区溢出等问题。

4. 可变性

C 字符串

  • 不可变(伪) :C 字符串的大小一旦分配,很难动态调整。在需要修改大小的时候,需要进行复杂的内存重新分配操作。

Redis SDS

  • 可变:SDS 本质上是动态可变的,可以方便地增加或减少字符串的长度。并且提供了一些方便的 API 来处理字符串操作。

5. 示例对比

假设我们要将一个字符串 "Hello" 扩展为 "Hello, World!"。

使用 C 字符串

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 初始分配
    char *str = (char *)malloc(6);
    strcpy(str, "Hello");
    
    // 扩展字符串,需要重新分配内存
    str = (char *)realloc(str, 14);
    strcat(str, ", World!");
    
    printf("%s\n", str); // 输出: Hello, World!
    
    // 释放内存
    free(str);
    return 0;
}

使用 Redis SDS

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 假设已经定义了 Redis 的 SDS 结构和函数

typedef struct sdshdr {
    int len;     // 当前字符串的长度
    int free;    // 剩余可用空间的长度
    char buf[];  // 实际保存字符串的字符数组
} sdshdr;

// 创建新 SDS
sdshdr* sdsnew(const char* init) {
    size_t initlen = strlen(init);
    sdshdr *sh = (sdshdr*)malloc(sizeof(sdshdr) + initlen + 1);
    sh->len = initlen;
    sh->free = 0;
    memcpy(sh->buf, init, initlen + 1);
    return sh;
}

// 扩展 SDS
sdshdr* sdscat(sdshdr* s, const char* t) {
    size_t len = strlen(t);

    // 如果当前的 free 空间不足以容纳新字符串,重新分配内存
    if (s->free < len) {
        size_t newlen = s->len + len;
        s = realloc(s, sizeof(sdshdr) + newlen + 1);
        s->free = newlen - s->len;
    }

    // 将新字符串添加到现有字符串尾部
    memcpy(s->buf + s->len, t, len + 1);
    s->len += len;
    s->free -= len;

    return s;
}

// 打印和释放 SDS
void sdsprint(sdshdr* s) {
    printf("%s\n", s->buf);
}

void sdsfree(sdshdr* s) {
    free(s);
}

int main() {
    // 创建一个新的 SDS 字符串
    sdshdr* s = sdsnew("Hello");

    // 扩展字符串
    s = sdscat(s, ", World!");

    // 打印结果
    sdsprint(s); // 输出: Hello, World!

    // 释放内存
    sdsfree(s);

    return 0;
}

主要区别总结

通过上面的对比和示例,可以更清晰地看到 C 字符串和 Redis SDS 的主要区别:

  1. 数据结构

    • C 字符串:简单的一维字符数组,以 null ('\0') 结尾。
    • SDS:包含元数据(长度和空闲空间)的结构体,更便于管理和操作。
  2. 内存管理

    • C 字符串:需要手动管理内存分配和释放,容易发生缓冲区溢出。
    • SDS:自动管理内存扩展和缩减,通过元数据快速获取字符串信息。
  3. 性能和效率

    • C 字符串:获取长度需要遍历整个字符串,时间复杂度为 O(n)。
    • SDS:长度存储在元数据中,获取长度时间复杂度为 O(1)。
  4. 使用场景

    • C 字符串:适合简单、固定长度的字符串操作。
    • SDS:适合需要频繁修改、动态扩展的字符串操作,具有更高的安全性和性能。

总结

Redis 的 SDS 数据结构相比传统的 C 字符串,在内存管理、性能、安全性等方面都有显著优势。这些特点使得 SDS 非常适合在高性能、高并发的环境中使用,比如 Redis 的内部实现。了解这些差异对于理解 Redis 内部工作机制以及优化相关应用是非常重要的。

SDS动态扩容机制

1. 预分配空间策略

当 SDS 增长时,它会预先分配比所需更多的内存,以便将来可能的增长。这种预分配策略基于两种不同的情况:

  • 小于1MB的字符串:如果现有字符串的长度小于1MB,当它需要扩展时,SDS 会分配当前长度的两倍大小的内存。例如,如果当前字符串长度为 100 字节,并且需要再追加 50 字节,那么 SDS 会将总容量扩展到 300 字节(150 * 2)。
  • 大于等于1MB的字符串:如果现有字符串的长度大于等于1MB,当它需要扩展时,SDS 会一次性分配当前长度加上 1MB 的内存。例如,如果当前字符串长度为 2MB,并且需要再追加 100 字节,那么 SDS 会将总容量扩展到 3MB + 100 字节。

这种预分配策略避免了每次小额增加都重新分配内存,提高了效率。

2. 具体实现

下面是 SDS 扩容的一些关键代码片段和逻辑:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen;

    // 如果剩余空间已经足够,不需要扩容
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s - sizeof(struct sdshdr);

    newlen = (len + addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2; // 小于1MB,则扩展至两倍
    else
        newlen += SDS_MAX_PREALLOC; // 大于等于1MB,则扩展1MB

    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL;

    s = (char*)newsh + sizeof(struct sdshdr);
    sdssetalloc(s, newlen);
    return s;
}

3. 示例说明

假设我们有一个 SDS 字符串,初始内容为 "hello",然后我们进行一系列的追加操作:

sds s = sdsnew("hello"); // 创建一个初始字符串
s = sdscat(s, ", world!"); // 追加字符串,触发扩容

如果 s 初始情况下只有 5 个字符,加上结尾的 \0,总共需要 6 字节。当我们追加 ", world!" 时,总长度变为 13 字节(12字节的字符+1字节的结尾)。

根据上述扩容规则,扩容后容量:12 * 2 + 1 = 25(1字节保存结尾空字符串)。此时内部结构可能如下:

+------+------+
| len  | free |
+------+------+
|  13  |  11  |
+---------------------------+
| h | e | l | l | o | , |   | w | o | r | l | d | ! | \0 | ... (11) ... |
+---------------------------+

通过这种机制,SDS 可以高效地应对大量的小幅度字符串增长需求,减少频繁的内存分配带来的开销。