Redis 数据结构之简单动态字符串(SDS)

3,903 阅读31分钟

背景

我们都知道 Redis 是使用 C 语言编写。但是 Redis 并没有用 char 来表示字符串,而是使用一种称为 简单动态字符串(Simple Dynamic Strings,SDS) 来存储字符串和整型数据。 这是为什么呢?

下面,我们带着一些问题,来理解一下为什么 Redis 要有一种新的数据结构:SDS。

问题

接下来我们带着一些问题,来开始我们今天的学习之旅。

问题一:如何高效的进行字符串长度计算

我们都知道,在 Redis 内部,字符串的长度计算特别常见,这个操作对应在 C 语言中就是 strlen 命令。

在 C 语言中,字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到代表字符串结尾的空字符串为止,整个时间复杂度为 O(N)

但是这种简单的操作不应该成为 Redis 性能的瓶颈,于是 Redis 定义了一个 len 属性,专门用于存储字符串的长度,获取字符串的长度操作的时间复杂度为 O(1),典型的空间换时间。

关于 len 属性问题,我们后续看 Redis SDS 数据结构就会明白。

问题二:二进制安全问题

从上一个问题中,我们讨论到了 Redis 获取一个字符串长度要从头遍历到结尾,那么如何确定这个这个字符串已经到结尾了呢?

那么我们需要了解一下 C 语言中字符串的定义了。

在 C 语言中,字符串实际上是使用字符 \0 终止的一维字符数组。比如说,hello world 在 C 语言中就可以表示为 "hello world\0"

所以在 C 语言获取一个字符串长度,会逐个字符遍历,直到遇到代表字符串结尾的空字符为止。

这就会导致二进制安全问题。

二进制安全:通俗的讲,C 语言中,用 “\0” 表示字符串的结束,如果字符串本身就有 “\0” 字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

虽然 Redis 一般用于保存文本数据,但使用 Redis 来保存二进制数据的场景也不少见,因此,为了确保 Redis 可以适用于各种不同的使用场景,自然不能使用 C 语言中的 char * 来定义字符串了。

实际上,使用 SDS 会保证二进制安全,因为在 SDS 中,是使用 len 属性而不是空字符串来判断字符串是否结束

问题三:缓存区溢出问题

C 语言不记录自身长度带来的另外一个问题就是容易造成缓存区溢出。

简单的来说,缓存区溢出通常指向缓存区写入了超过缓存区所能保存的最大数据量的数据。

比如我们使用 char *strcat(char *dest, const char *src); 函数来将 src 字符串中的内容拼接到 dest 字符串的末尾。

因为 C 字符串不记录自身的长度,所以 strcat 会假定用户在执行这个函数时,已经为 dest 分配足够多的内存了,可以容纳 src 字符串中的所有内容,而一旦这个假设不成立,就会产生缓存区溢出。

我们举例看一下:

比如我们在内存中有紧挨着的两个字符串 s1 和 s2,其中 s1 字符串保存 redis,s2 字符串保存 mysql,他们在内存中应该是这个样子的:

内存中连续两个 C 字符串

当我们执行 strcat(s1, " Cluster");,但是却忘记了给 s1 分配足够的内存空间时,那么在 stract 函数执行后,s1 的数据将会溢出到 s2 的空间上。导致 s2 保存的内容被意外修改,如下图所示:

s1 的内容溢出到 s2 的位置上

实际上,有时候缓存区溢出导致程序马上运行出错是幸运的,因为我们至少知道这里出错了。而不幸的情况是,如果超出 buff 的部分存储在了栈帧不属于它自己的位置,即覆盖率栈帧上存储的其他信息,就有可能导致程序在其他位置出错,造成问题难以定位。

所以在使用 strcat 拼接两个字符串时,一定要判断第一个字符串后面是否有足够的内存空间;如果不够了,就得手动扩容,这一系列判断 + 扩容操作需要开发人员自己去完成,步骤有些麻烦。

而 Redis SDS 提供的所有修改字符串的 API 中,都会判断修改之后是否会有内存溢出,SDS 会帮我们处理内存扩容,无需我们开发人员手动判断 + 扩容。

问题四:如何高效的进行字符串追加

我们前面了解到,C 字符串本身不记录字符串的长度,所以对于一个包含了 N 个字符的 C 字符串来说, 这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。

因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:

  • 增长操作: 比如拼接操作(append),执行增长操作之前需要先进行内存重分配,以扩展底层数组的空间大小,如果忘了就会导致缓冲区溢出。
  • 缩短操作: 比如截断操作(trim),执行缩短操作之后需要进行内存重分配,来释放掉字符串不再使用的那部分空间,如果忘了就会导致内存泄漏。

然而内存重分配其实涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。

在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。 但是 Redis 作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响。

所以 Redis 为 SDS 设计了冗余空间,追加时只要内容不是太大,是可以不必重新分配内存的。

也就是 空间预分配惰性删除

这部分内容我们后续会讲到的。

总结

从上面的几个问题我们可以看出,对于追求速度、效率的 Redis 来说,使用 char 来存储字符串并不是一个好的选择,因此 Redis 定义了一个新的数据结构 SDS。

接下来,我们将重点研究 SDS 的数据结构,相信我们可以发现,SDS 的结构设计正好解决了上述的问题。

基本概念

这里我们对后面出现的一些名词进行解释。

size_t 类型

size_t 它是一种“整型”类型,里面保存的是一个整数,就像 int、long 那样。这种整数用来记录一个大小(size)。size_t 的全称应该是 size type,就是说“一种用来记录大小的数据类型”。

通常我们用 sizeof(XXX) 操作,这个操作所得到的结果就是 size_t 类型。

因为 size_t 类型的数据其实是保存了一个整数,所以它也可以做加减乘除,也可以转化为 int 并赋值给 int 类型的变量。

size_t 的主要是为了提高代码的可移植性。

结构体(struct)

  • 定义:在 C 语言中,结构体 指的是一种数据结构,是 C 语言中复合数据类型的一类。结构体可以被声明为变量、指针或数组等,用以实现较复杂的数据结构。结构体同时也是一些元素的集合,这些元素称为结构体的成员(member),且这些成员可以为不同的类型,成员一般用名字访问。

  • 声明:结构体的定义如下所示,struct 为结构体关键字,tag 为结构体的标志,member-list 为结构体成员列表,其必须列出其所有成员,variable-list 为此结构体声明的变量。

 struct tag { member-list } variable-list ; 
  • 存储:在内存中,编译器按照成员列表数据分别为每个结构体变量成员分配内存,当存储过程中需要满足边界对齐的要求时,编译器会在成员之间留下额外的内存空间。如果想结构体占多少存储空间,则使用关键字 sizeof,如果想得知结构体的某个特定成员在结构体的位置,则使用 offsetof 宏(定义于 stddef.h)。

结构体内存布局

结构体字节对齐的细节和具体编译器实现有关,但一般来说遵循三个准则:

  1. 结构体变量的首地址能够被其最宽基本类型成员的大小(sizeof)所整除。
  2. 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节。
  3. 结构体的总大小(sizeof)为结构体最宽基本成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

具体解释如下:

  1. 编译器在为结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,做为结构体的首地址。
  2. 为结构体的每一个成员开辟空间之前,编译器首先检查预开辟空间首地址相对于结构体首地址的偏移是否是本成员大小的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节,但有很重要的一点要注意,这里的成员为基本数据类型,不包括 char 类型数组和结构体成员,char 类型数组按 1 字节对齐,结构体成员存储的起始位置要从自身内部最大成员大小的整数倍地址开始存储。
  3. 结构体的总大小包括填充字节,最后一个成员除了满足上述两个条件外,还必须满足第三条,否则必须在最后填充一定字节以满足要求。

柔性数组

柔性数组 是 C99 引入的一个新特性,这个特性允许你在定义结构体的时候创建一个空数组,而这个数组的大小可以在程序运行的过程中根据你的需求进行更改。

特别注意的一点是:这个空数组必须声明为结构体的最后一个成员,并且还要求这样的结构体至少包含一个其他类型的成员

柔性数组不占用内存,可以用来构造内存缓冲区,同时由于是使用多少就申请多少,也起到了减少内存碎片化的作用。

柔性数组并不是标识结构体结束,而是作为结构体的一种扩展。同时也可以理解为柔型数组为结构体的一个偏移地址,这使得结构体的大小可以进行动态的变化。

和指针的区别:首先柔性数组不占用内存,而指针则不然,此外柔性数组在使用上是直接访问,形式上更加直观,而指针需要经过声明再进行动态内存分配,在效率上和柔型数组相比也稍微低一些。

柔性数组成员(flexible array member),也叫做伸缩性性数组成员,只能被放在结构体的末尾。包含柔性数组成员的结构体,通过 malloc 函数为柔性数组动态分配内存。之所以用柔性数组存放字符串,是因为柔性数组的地址和结构体是连续的,这样查找内存更快(因为不需要额外通过指针找到字符串的位置);可以很方便地通过柔性数组的首地址偏移得到结构体首地址,进而能很方便地获取其余变量

3.2 版本之前 SDS 数据结构

为什么要单独提一下 Redis 3.2 版本之前 SDS 数据结构呢?是因为在 3.2 版本中,Redis 对 SDS 结构进行了优化,因此我们很有必要对之前的数据结构进行学习,无论是为了理解 Redis SDS 的设计思想,还是为了理解现在 SDS 数据结构的由来。

SDS 数据结构

struct sdshdr {
    unsigned int len; //buf 中已占字节数,len 的长度不包括 \0
    unsigned int free; //buf 中剩余空闲字节数
    char buf[]; //一个 char 类型的字符数组,用于存储实际字符串的内容,以 \0 结尾,可以使用 C 语言中现成的库函数
};

优点(解决的问题)

可以看到的是 SDS 数据结构的设计正好解决了上面我们提到的几个问题:

  • 使用 len 来存储字符数组长度,因此获取字符串长度的时间复杂度为 O(1) (C 语言中获取字符串长度的时间复杂度为 O(N)。),同时读写字符串不依赖 \0保证了二进制安全
  • 使用 free 来存储未使用的字节,来避免频繁的内存分配,减少耗时
  • 使用 buff 来存储字符串内容。字符串仍然以 \0 作为结尾,是为了和 C 语言字符串保持一致,这样就可以复用 C 语言字符串相关函数,避免重新造轮子
  • 使用柔性数组 来存储 buf。SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接指向柔性数组 buf 的指针。上层可像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。

频繁内存分配问题处理

我们前面提到过,每次增长或者缩短一个 C 字符串,程序都需要对保存这个 C 字符串的数组进行一次内存重新分配操作。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。

为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联。通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。

其中,额外分配的未使用空间数量由以下公式决定:

  • 如果对 SDS 进行修改之后,SDS 的长度将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间。
  • 如果对 SDS 进行修改之后,SDS 的长度将大于等于 1MB,那么程序会分配 1MB 的未使用空间。

在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。

通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。

惰性空间释放

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

通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。

与此同时,SDS 也提供了响应的 API,让我们可以在有需要时,真正的释放 SDS 里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

源码分析

下面,我们通过分析 Redis 源码,来深入了解 SDS 数据结构。其中主要涉及 sds.csds.h 这两个文件。我们需要将分支切换到 3.0 分支,来查看上述结构。

变量

typedef char *sds; //字符数组指针

struct sdshdr { //sds 结构体
    unsigned int len;
    unsigned int free;
    char buf[];
};

sds.h 文件中,我们发现只有两个变量,一个是 sds,另一个是 sdshdr

sds 其实就是一个字符数组的指针,即结构中的 buf。这样设计的好处在于直接对上层提供了字符串内容指针,兼容了部分 C 函数,且通过偏移能迅速定位到 SDS 结构体的各处成员变量。

获取字符串长度

static inline size_t sdslen(const sds s) {
    struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
    return sh->len;
}

首先说一下 inline 关键字。

inline 关键字用于定义一个类的内联函数。inline 关键字会将函数展开,把函数的代码复制到每一个调用处。这样在调用函数的过程中,就可以直接执行函数代码,而不发生跳转、压栈等一般性函数操作,用于解决一些频繁调用的函数大量消耗栈空间(栈内存)的问题。

inline 是一种“用于实现”的关键字,而不是一种“用于声明”的关键字。这意味着关键字 inline 必须与函数定义放在一起才能使函数成为内联函数,仅仅将 inline 放在函数声明前面不起任何作用。

我们再来看一下上述代码,这段代码很简单,就是通过字符数组指针 s 获取结构体 sh,然后获取其成员变量 len 并返回。

那么我们是如何获取到结构体变量呢?主要是下面这一行代码。

struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));

在结构体 sdshdr 中,buf 是柔性数组成员,在计算结构体大小的时候是不计入在内的。因此结构体 sdshdr 的大小实际是其他成员变量的大小,也就是说 sizeof(struct sdshdr) = sizeof(unsigned int) + sizeof(unsigned int)

s - (sizeof(struct sdshdr)) 则表示将指针向前移动 (sizeof(struct sdshdr)) 大小,即移动到结构体的首地址,从而得到一个指向 sdshdr 结构的指针。

然后,通过访问成员变量 sh->len 来获取字符串长度。

获取字符串剩余可用空间

static inline size_t sdsavail(const sds s) {
    struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
    return sh->free;
}

该方法同获取字符串长度方法一致,不再赘述。

字符串创建

sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh; //声明一个结构体变量

    if (init) { //根据是否有初始化内容,选择适当的内存分配方式
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1); //zmalloc 不初始化所分配的内存
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1); //zcalloc 将分配的内存全部初始化为 0 
    }
    if (sh == NULL) return NULL; //内存分配失败,返回

    sh->len = initlen; //设置初始化长度
    sh->free = 0; //新 SDS 不预留任何空间
    if (initlen && init) 
        memcpy(sh->buf, init, initlen); //如果有指定初始化内容,将他们复制到 sdshdr 中的 buf 中
    sh->buf[initlen] = '\0'; //以 \0 结尾
    return (char*)sh->buf; //返回一个指向 buf 的指针
}

zmalloczcalloc 两个方法定义在 zmalloc.czmalloc.h 文件中,这两个文件负责 redis 的内存管理。

zmalloc,zcalloc,zrealloc 和 zfree 分别对应 c 库中的 malloc,calloc,realloc 和 free 函数。

函数 zmalloc() 不初始化所分配的内存空间,而函数 zcalloc() 会将所分配的内存空间中的每一位都初始化为 0。

该函数最终返回了一个指向柔型数组 buf 的指针。

字符串扩容(空间预分配)

sds sdsMakeRoomFor(sds s, size_t addlen) { // 参数:sds 字符串 s 和 扩容长度 addlen
    struct sdshdr *sh, *newsh; //定义两个 sdshdr 结构体指针
    size_t free = sdsavail(s); // 获取 s 目前空闲空间长度
    size_t len, newlen; // 定义两个长度变量,一个用于存储扩展前 sds 字符串长度,一个用于存储扩展后 sds 字符串长度

    if (free >= addlen) return s; // 如果 s 目前的剩余空闲空间已经足够,无需再进行扩展,直接返回
    len = sdslen(s); // 获取 s 目前已占用空间的长度
    sh = (void*) (s-(sizeof(struct sdshdr))); //结构体指针赋值
    newlen = (len+addlen); // 字符串数组 s 最少需要的长度
    // 根据新长度,为 s 分配新空间所需的大小
    if (newlen < SDS_MAX_PREALLOC) // 如果新长度小于 SDS_MAX_PREALLOC,那么为它分配两倍于所需长度的空间
        newlen *= 2; 
    else
        newlen += SDS_MAX_PREALLOC; // 否则,分配长度为目前长度加上 SDS_MAX_PREALLOC
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;

    newsh->free = newlen - len;
    return newsh->buf;
}

这个函数就是上面我们提到的 动态扩容(空间预分配) 策略,以便于字符串高效追加。

SDS_MAX_PREALLOC 是在 sds.h 中定义的宏

#define SDS_MAX_PREALLOC (1024*1024)

其大小为 1M。

这就是我们空间预分配策略中 1M 的来源。

在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。

通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。

字符串释放(惰性空间释放)

SDS 提供了直接释放内存的方法——sdsfree,该方法通过对 s 的偏移,可定位到 SDS 结构体的首部,然后调用 s_free 释放内存。

void sdsfree(sds s) {
    if (s == NULL) return;
    zfree(s-sizeof(struct sdshdr));
}

为了优化性能(减少申请内存的开销),SDS 提供了不直接释放内存,而是通过重置统计值达到清空目的的方法——sdsclear。该方法仅将 SDS 的 len 归零,此处已存在的 buf 并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存。

void sdsclear(sds s) {
    struct sdshdr *sh = (void*) (s-(sizeof(struct sdshdr)));
    sh->free += sh->len;
    sh->len = 0;
    sh->buf[0] = '\0';
}

通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。

字符串拼接

拼接字符串操作本身不复杂,可用 sdscatsds 来实现,代码如下:

sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}

sdscatsds 是暴露给上层的方法,其最终调用的是 sdscatlen。由于其中可能涉及 SDS 的扩容,sdscatlen 中调用 sdsMakeRoomFor 对待拼接的字符串 s 容量做检查,若无须扩容则直接返回 s;若需要扩容,则返回扩容好的新字符串 s。函数中的 len、curlen 等长度值是不含结束符的,而拼接时用 memcpy 将两个字符串拼接在一起,指定了相关长度,故该过程保证了二进制安全。最后需要加上结束符。

sds sdscatlen(sds s, const void *t, size_t len) {
    struct sdshdr *sh;
    size_t curlen = sdslen(s); //原有字符串长度

    s = sdsMakeRoomFor(s,len); //扩展 sds 空间
    if (s == NULL) return NULL;
    sh = (void*) (s-(sizeof(struct sdshdr)));
    memcpy(s+curlen, t, len); //复制 t 中的内容到字符串后部
    sh->len = curlen+len; //更新当前字符串长度
    sh->free = sh->free-len; //更新剩余空间长度
    s[curlen+len] = '\0'; //添加结尾符号 \0
    return s;
}

其余 API

SDS 还为上层提供了许多其他 API,在这里不过多介绍,感兴趣的可以去阅读相关源码学习。学习时需要注意以下两点:

  1. SDS 暴露给上层的是指向柔性数组 buf 的指针;
  2. 读操作的复杂度多为 O(1),直接读取成员变量;涉及修改的写操作,则可能会触发扩容。
函数名说明
sdsempty创建一个空字符串,长度为 0,内容为 ""
sdsnew根据给定的 C 字符串创建 SDS
sdsup复制给定的 SDS
sdsupdatelen手动刷新 SDS 的相关统计值
sdsRemoveFreeSpace与 sdsMakeRoomFor 相反,对空闲过多的 SDS 进行缩容
sdsAllocSize返回给定 SDS 当前占用内存的大小
sdsgrowzero将 SDS 扩容到指定长度,并用 0 填充新增内容
sdscpylen将 C 字符串复制到给定 SDS 中
sdstrim从 SDS 两端清除所有给定的字符
sdscmp比较俩给定 SDS 的实际大小
sdssplitlen按给定的分隔符对 SDS 进行切分

Redis 3.2 之前 SDS 的设计存在的问题及解决方案

通过上述内容的学习,相信我们已经对 SDS 结构有了一个更深的认识。SDS 的数据结构是优秀的,它能以 O(1) 的时间复杂度得到字符串的长度,并解决了二进制安全问题和缓存区溢出的问题。同时能够高效的对字符串进行追加操作,避免了频繁的扩容和缩容。

那么,该结构还有没有什么改进的空间呢?

我们从一个简单的问题思考:不同长度的字符串是否有必要占用相同大小的头部?一个 int 占 4 字节,在实际应用中,存放于 Redis 的字符串往往没有这么长,每个字符串都用 4 字节存储未免太浪费空间了。

我们考虑三种情况:短字符串,len 和 free 的长度为 1 字节就够了;长字符串,用 2 字节或 4 字节;更长的字符串,用 8 字节。

这样确实更省内存,但依然存在以下问题。

  1. 如何区分三种情况?
  2. 对于短字符串来说,头部还是太长了。以长度为 1 字节的字符串为例,len 和 free 本身就占了 2 字节,能不能进一步压缩呢?

对于问题 1,我们考虑增加一个字段 flags 来标识类型,用最小的 1 字节来存储,且把 flags 加在柔性数组 buf 之前,这样虽然多了 1 字节,但是通过偏移柔性数组的指针即能快速定位 flags,区分类型,也可以接受;对于问题 2,由于 len 已经是最小的 1 字节了,再压缩只能考虑用位来存储长度了。

结合两个问题,5 种类型(长度 1 字节、2 字节、4 字节、8 字节、小于 1 字节)的 SDS 至少需要 3 位来存储类型(2^3=8),1 个字节 8 位,剩余的 5 位存储长度,可以满足长度小于 32 的短字符串。

Redis 3.2 之后 SDS 结构就是这样设计的,接下来我们来一起研究一下。

Redis 5 中 SDS 数据结构

在 Redis 5.0 中,我们用如下结构来存储长度小于 32 的短字符串。

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低 3 位存储类型,高 5 位存储长度 */
    char buf[];
};

sdshdr5 结构如下,flags 占 1 个字节,其低三位(bit)表示 type,高 5 位(bit)表示长度。能表示的长度区间为 0~31(2^5-1)。flags 后面就是字符串的内容。

sdshdr5 结构

而长度大于 31 的字符串,1 个字节依然存不下。我们按之前的思路,将 len 和 free 单独存放。sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的结构相同,以 sdshdr8 为例,结构如下。

sdshdr8 结构

其中“表头”共占用了 S[1(len)+1(alloc)+1(flags)] 个字节。flags 的内容与 sdshdr5 类似,依然采用 3 位存储类型,但剩余 5 位不存储长度。

在 Redis 源码中,对类型的宏定义如下:

#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

在 Redis 5.0 中,sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的数据结构如下:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 已使用长度,用 1 字节存储 */
    uint8_t alloc; /* 总长度,用 1 字节存储 */
    unsigned char flags; /* 低 3 位存储类型,高 5 位预留 */
    char buf[]; /* 柔性数组,存放实际内容 */
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 已使用长度,用 2 字节存储 */
    uint16_t alloc; /* 总长度,用 2 字节存储 */
    unsigned char flags; /* 低 3 位存储类型,高 5 位预留 */
    char buf[]; /* 柔性数组,存放实际内容 */
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* 已使用长度,用 4 字节存储 */
    uint32_t alloc; /* 总长度,用 4 字节存储 */
    unsigned char flags; /* 低 3 位存储类型,高 5 位预留 */
    char buf[]; /* 柔性数组,存放实际内容 */
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* 已使用长度,用 8 字节存储  */
    uint64_t alloc; /* 总长度,用 8 字节存储 */
    unsigned char flags; /* 低 3 位存储类型,高 5 位预留 */
    char buf[]; /* 柔性数组,存放实际内容 */
};

可以看到,这 4 种结构的成员变量类似,唯一的区别是 len 和 alloc 的类型不同。结构体中 4 个字段的具体含义分别如下:

  • len:表示 buf 中已占用字节数。
  • alloc:表示 buf 中已分配字节数,不同于 free,记录的是为 buf 分配的总长度。
  • flags:标识当前结构体的类型,低 3 位用作标识位,高 5 位预留。
  • buf:柔性数组,真正存储字符串的数据空间。

注意:结构最后的 buf 依然是柔性数组,通过对数组指针作“减一”操作,能方便地定位到 flags。

另外,我们还需要注意一下源码中的 __attribute__((__packed__))。从上面我们已经知道,结构体会按照所有变量中最宽的基本数据类型做字节对齐。但是用 packed 修饰后,结构体则变为按 1 字节对齐,以 sdshdr32 为例,修饰前按 4 字节对齐大小为 12(4*3) 字节;修饰后按 1 字节对齐,共 9 字节。注意 buf 是个 char 类型的柔型数组,地址连续,始终在 flags 之后,packed 修饰前后如下图所示。

packed 修饰前后示意

这样做有两个好处:

  1. 节省内存:例如 sdshdr32 可节省 3 个字节(12-9)。
  2. SDS 返回给上层的,不是结构体首地址,而是指向内容的 buf 指针。 因为此时按 1 字节对齐,故 SDS 创建成功后,无论是 sdshdr8、sdshdr16 还是 sdshdr32,都能通过 (char*)sh+hdrlen 得到 buf 指针地址(其中 hdrlen 是结构体长度,通过 sizeof 计算得到)。修饰后,无论是 sdshdr8、sdshdr16 还是 sdshdr32,都能通过 buf[-1] 找到 flags,因为此时按 1 字节对齐。若没有 packed 的修饰,还需要对不同结构进行处理,实现更复杂

API 对比介绍

创建字符串

sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    char type = sdsReqType(initlen); //根据字符串长度选择不同的类型
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8; //SDS_TYPE_5 强制转化为 SDS_TYPE_8
    int hdrlen = sdsHdrSize(type); //计算不同头部所需的长度
    unsigned char *fp; //指向 flags 的指针

    sh = s_malloc(hdrlen+initlen+1); //分配内存,头部长度+字符串长度+结束符长度
    if (sh == NULL) return NULL;
    if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    s = (char*)sh+hdrlen; //s 是指向 buf 的指针
    fp = ((unsigned char*)s)-1; //s 是柔型数组 buf 的指针,-1 即指向 flags
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS); //sdshdr5 的前 5 位保存长度,后 3 位保存 type
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s); //获取 sdshdr 指针
            sh->len = initlen; //设置 len
            sh->alloc = initlen; //设置 alloc
            *fp = type; //flags 指针赋值
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;
}

在创建字符串时,Redis 会根据字符串的长度选择不同的类型。然后根据类型的不同,分配的头部的大小也不同。

注意:Redis 3.2 后的 SDS 结构由 1 种增至 5 种,但是对于 sdshdr5 类型,在创建空字符串时会强制转化为 sdshdr8。原因可能是创建空字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为 sdshdr8

再来看一下,根据不同类型给成员组赋值的代码。

我们可以很明显的看到,SDS_TYPE_5 和其它类型不同,只有一个 flags 标志位。我们来分析一下下面这行代码。

*fp = type | (initlen << SDS_TYPE_BITS); 

可以看到,initlen << SDS_TYPE_BITS 表示左移 3 位,即低三位为 0,高 5 位为字符串长度,然后长度和类型执行异或操作,即为 flags 的值。

而其它类型,增加了 lenalloc 字段,直接赋值为字符串初始长度。因为其它类型的高五位是预留的,故 *fp 直接赋值为 type。

总结一下创建 SDS 的大致流程:首先计算好不同类型的头部和初始长度,然后动态分配内存。需要注意以下三点:

  1. 创建空字符串时,SDS_TYPE_5 被强制转化为 SDS_TYPE_8。
  2. 长度计算时有“+1”操作,是为了算上结束符“\0”。
  3. 返回值是指向 SDS 结构 buf 的指针(一直强调)。

字符串释放

void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}
#define SDS_TYPE_MASK 7
static inline int sdsHdrSize(char type) {
    switch(type&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return sizeof(struct sdshdr5);
        case SDS_TYPE_8:
            return sizeof(struct sdshdr8);
        case SDS_TYPE_16:
            return sizeof(struct sdshdr16);
        case SDS_TYPE_32:
            return sizeof(struct sdshdr32);
        case SDS_TYPE_64:
            return sizeof(struct sdshdr64);
    }
    return 0;
}

释放函数也没有什么特别的,只是通过 s 向前移动一位,然后获取到 type 类型,根据不同的类型释放不同空间大小。

字符串扩容

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    if (type == SDS_TYPE_5) type = SDS_TYPE_8; //type 5 结构不支持扩容,强制转化为 type 8

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) { //无需更改类型,通过 realloc 扩大柔性数组即可,注意这里指向 buf 的指针 s 被更新了
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else { //扩展后数组类型和头部长度发生了变化,此时不再进行 realloc 操作,而是直接重新开辟内存,拼接完内容后,释放旧指针。
        newsh = s_malloc(hdrlen+newlen+1); //按新长度重新开辟内存
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1); //将原 buf 内容移动到新位置
        s_free(sh); //释放旧指针 
        s = (char*)newsh+hdrlen; //偏移 sds 结构的起始地址,得到字符串起始地址
        s[-1] = type; //为 flags 赋值
        sdssetlen(s, len); //为 len 属性赋值
    }
    sdssetalloc(s, newlen); //为 alloc 属性赋值
    return s;
}

下面我们对一些关键语句进行解释。

    char type, oldtype = s[-1] & SDS_TYPE_MASK;

SDS_TYPE_MASK 的宏定义为 7,二进制位 00000111,和 flags 位执行 & 操作,即可获得该 sds 类型。

if (type == SDS_TYPE_5) type = SDS_TYPE_8

type5 结构不支持扩容的原因是,Redis 认为 type5 结构容易发生扩容行为,因此直接将类型变为 type8。

扩容逻辑和 3.2 之前没有变化。

最后根据新长度重新选择存储类型,并分配空间。此处若无需更改类型,通过 realloc 扩大柔性数组即可;否则需要重新开辟内存,并将原字符串的 buf 内容移动到新位置。

SDSMakeRoomFor 的实现过程如下:

SDSMakeRoomFor 的实现过程

总结

今天我们学习 Redis 一个基本数据结构之一:简单动态字符串(SDS)。

首先我们讲到了 3.2 之前的 SDS 结构,包括字符串长度 len,剩余可用空间 free,以及柔性数组 buf。上层调用时,SDS 直接返回 buf,由于 buf 是直接指向内容的指针,故兼容 C 语言函数,而当真正读取内容时,SDS 通过偏移得到 len 来限制读取长度,而非 \0,保证了二进制安全,同时读取字符串的时间复杂度为 O(1),也可以避免缓存区溢出问题。

SDS 通过空间预分配策略,在每次空间分配时,给字符串多分配一些空闲空间,避免了频繁扩容问题。在进行字符串追加时,只要内容不是太大,是可以不必重新分配内存的。

通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。

然后我们介绍了,为了进一步压缩空间,在 3.2 之后,SDS 变成了 5 中结构。其中 sdshdr5 结构没有 len 属性和 alloc 属性。是因为 Redis 进一步压缩了 sdshdr5 结构。

一般情况下,小字符串的存储更普遍,故 Redis 进一步压缩了 sdshdr5 的数据结构,将 sdshdr5 的类型和长度放在了同一属性中,用 flags 的低 3 位存储类型,高 5 位存储长度。但是创建空字符串时,sdshdr5 会被 sdshdr8 所替代,因为 Redis 认为可能会发生扩容操作,所以就直接定义成 sdshdr8 类型。

最后,我们着重谈了一下扩容操作,SDS 在涉及字符串修改处会调用 sdsMakeroomFor 函数进行检查,根据不同情况动态扩容,该操作对上层透明。

参考文档