一、Redis 常用数据结构解析--字符串类型

151 阅读8分钟

Redis 字符串类型底层数据结构解析--为啥使用SDS字符串来存储String类型的数据

Redis 底层采用 C 语言来开发的, 但是由于C语言传统字符串底层实现的种种因素,让它在数据存储的某些操作中性能低下;因此,在 Redis 的字符串类型的数据结构中,并没有直接引用 C 语言中传统的字符串类型来存放数据,而是使用 SDS简单动态字符串,来作为字符串类型的数据结构的底层实现

源码内容总是枯燥无味的,对于这种内容的阅读,个人的观点一向是先看结论,然后带着结论去论证,在论证过程中寻找成就感,才不至于中途放弃,因此先说结论:

  1. 传统字符串获取自身长度信息复杂度为O(N),SDS字符串为O(1);
  2. 合并字符操作时,传统字符串在空间不足以保持新字符串的情况下,会出现缓冲区溢出,SDS字符串不会;
  3. 传统C字符串二进制不安全,SDS字符串二进制安全。

坚信.jpeg

一、C 传统字符串

作为一个Java开发者,都知道在Java中字符串的底层其实就是一个char数组,在获取字符串长度的时候就是for循环吧遍历整个数组长度,在C语言传统字符串也不例外,如下所示

1. C 传统字符串存储样式

image.png

2. C 传统字符串获取自身长度信息

并不记录自身的长度信息,必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为O(N)

image.png

3. C 传统字符串的缓冲区溢出

C 字符串不记录自身长度,在字符串拼接过程中,相邻空间的两个字符串,会因为其中一个字符串的字符拼接而造成缓冲区溢出,使另一个空间的字符串内容被覆盖,如下示例:

在C语言中,假设有两个内存相邻的字符串 s1 和 s2,其中 s1 保存了字符串 "Redis",s2 保存了字符串 "MongoDB",如图所示:

image.png

在C语言中,使用 strcat(s1,"Cluster"); 将 "Cluster" 拼接到 s1 中,由于 s1 没有分配足够的空间,s1 的数据将溢出到 s2 所在的空间中,导致 s2 保存的内容被意外修改,如图所示:

image.png

#include<stdio.h>
int main() {
    // 传统C字符串保存形式: 'H'、'e'、'l'、'l'、'o'、''、'a'、'a'、'a'、'a'、'a'、'\0'
    char str1[12] = "Hello aaaaa";
    char str2[6] = "world";

    printf("Before merging: str1 = %s, str2 = %s\n", str1, str2);

    // 将字符串 str2 合并到 str1 中
    strcat(str1, str2);
    printf("After merging: str1 = %s, str2 = %s\n", str1, str2);

    return 0;
}
4. C传统字符串二进制不安全

前面说过,在C语言中,字符串都是以'\0'结尾,而在C语言中空字符也是以'\0'的形式保存,如果使用字符串拼接过程中如果有字符串后带有空格,程序将无法正确识别和读取出完整的字符串数据,如下图所示:

image.png

假设 str1 = "Redis ",str2 = "Cluster" 两个字符串拼接后生成 str3 如上图,在C语言中如果获取str3的结果还是 Redis,长度是 5(程序识别到'\0'就直接返回,不再向下识别了);

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

int main() {
    // 使用传统 C 字符串,包含 null 字符和二进制数据
    char data[] = { 'R', 'e', 'd', 'i', 's', '\0', 'C', 'l', 'u', 's', 't', 'e', 'r', '\0'};
    printf("输出结果: %s\n", data);
    printf("字符长度: %ld\n", strlen(data));
    return 0;
}

二、SDS简单动态字符串

1. SDS动态字符串存储样式

在SDS动态字符串中,为了最大限度的可以复用原有C传统字符串的接口,在实际存储字符串的char数组保留了传统字符串的特点,都会以'\0'作为结尾,因此在C语言中实际字符串无论是SDS还是传统字符串,都要额外使用1个空间的内存来存储空字符

image.png

2. SDS动态字符串获取自身长度信息

如上图SDS结构图中所示,SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为 O(1)

STRLEN key
3. SDS 是如何避免缓冲区溢出的?
空间预分配

SDS API 对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需要的要求,如果不满足的话,API 会自动将 SDS 空间扩展至执行所需要的大小,然后再执行实际的修改操作

  • SDS 修改之后长度 < 1 MB:Redis 先分配数据实际需要的空间,同时还会分配和 len 属性同样大小的未使用空间(减少内存重分配次数),此时 SDS buf 数组的实际长度 = 未使用空间 + 已使用空间 + 1,如下图:

image.png

(1)修改前

image.png

(2)执行拼接字符串操作后

image.png

(3)再次执行拼接字符串操作后

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

// 定义SDS结构
typedef struct sds {
    // 字符串的长度
    int len;

    // 剩余的未使用空间
    int free;

    // 字符串缓冲区,使用柔性数组
    char buf[];
} sds;

// 初始化 SDS 字符串
sds* sdsnew(const char* init) {
    // 初始化字符长度
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    sds* s = malloc(sizeof(sds) + initlen + 1);
    if (s == NULL) return NULL;

    // 给 len 赋值 = 当前字符串长度
    s->len = initlen;

    // 给 free 赋予初始值 0
    s->free = 0;

    if (initlen) memcpy(s->buf, init, initlen);
    s->buf[initlen] = '\0';
    return s;
}

// 拼接字符串到现有的SDS字符串
sds* sdscat(sds* s, const char* t) {
    // 原字符串长度
    size_t totlen, curlen = s->len;
    // 拼接的字符串长度
    size_t tlen = strlen(t);

    if (s->free < tlen) {
        // free 空间不够,需要重新分配空间
        totlen = curlen + tlen;
        s = realloc(s, sizeof(sds) + totlen + 1);
        // 设置 free 为新的总长度
        s->free = totlen;
    }

    memcpy(s->buf + curlen, t, tlen);
    s->len = curlen + tlen;
    s->buf[s->len] = '\0';
    return s;
}

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

    // 获取SDS字符串的长度
    printf("旧字符串 len 值: %d\n", mystring->len);

    // 获取SDS字符串的当前分配空间
    printf("SDS 总分配空间: %zu\n", mystring->len + mystring->free + 1);

    // 计算剩余未使用空间
    printf("剩余未使用空间: %d\n", mystring->free);

    printf("------------------- \n");

    // 拼接字符串
    mystring = sdscat(mystring, "World");

    // 获取SDS字符串的长度
    printf("拼接新字符串后 len 值: %d\n", mystring->len);

    // 获取SDS字符串的当前分配空间
    printf("拼接新字符串后 SDS 总分配空间: %zu\n", mystring->len + mystring->free + 1);

    // 计算剩余未使用空间
    printf("拼接新字符串后 SDS 剩余未使用空间: %d\n", mystring->free);

    // 释放SDS字符串的内存空间
    free(mystring);

    return 0;
}
  • SDS 修改后的长度 >= 1MB:修改之后的 len 将变成 30 MB,同时分配 1 MB 的未使用空间,SDS 的实际长度 = 30 MB +1 MB + 1 byte
惰性空间释放

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

image.png

(1)截取部分字符之前

image.png

(2)截取部分字符串之后

4.SDS 为什么是二进制安全的?

SDS 使用 len 属性记录的长度来判断字符是否结束,而不是通过空字符('\0')来判断,因此,程序读到 '\0' 时还会往下识别,直到len指定的长度为止,如图所示:

image.png

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

// 定义 SDS 结构体
typedef struct sds {
    int len; // 字符串长度
    int free; // 剩余空间
    char buf[]; // 字符数组
} sds;

// 创建 SDS 字符串
sds* sds_new(const char* init, int len) {
    sds* s = malloc(sizeof(sds) + len); // 加上结构体本身大小
    if (!s) return NULL;
    memcpy(s->buf, init, len);
    s->len = len;
    s->free = 0;
    return s;
}

// 释放 SDS 字符串
void sds_free(sds* s) {
    free(s);
}

// 输出 SDS 字符串
void sds_print(const sds* s) {
    printf("Length: %d, Free: %d, Content: ", s->len, s->free);
    for (int i = 0; i < s->len; ++i) {
        printf("%c", s->buf[i]);
    }
    printf("\n");
}

int main() {
    // 使用 SDS 字符串
    const char data[] = { 'H', 'e', 'l', 'l', 'o', '\0', 'w' }; // 包含 null 字符和二进制数据
    sds* s = sds_new(data, sizeof(data)); // 创建 SDS 字符串
    sds_print(s); // 输出 SDS 字符串
    sds_free(s); // 释放 SDS 字符串

    return 0;
}

Snipaste_2025-04-03_18-04-54.png

结尾寄语:本章内容源自于《Redis 数据库设计与实现》书籍阅读心得,个人水平有限,如有不足之处,诚心接受各位大神指正与批评,谢谢🌹🌹🌹!