Redis动态字符串

Q: 什么是 SDS

A:SDS 是 Redis 在实现过程中使用的一种「动态字符串」。由于 Redis 的代码基本都是通过 C 语言来实现的,所以 SDS 在最底层还是依赖于char buf[]来存储数据。SDS 对象的数据结构大致如下图所示

可以看出,SDS 结构体成员中有三个属性:len,free,buf。其中 len 标识一个 SDS 对象管理的字符串有效字符是多少个,而 free 则代表这个 SDS 在不扩充空间的前提下还可以存储多少个有效字符,buf 则是一个char[]类型的指针,它指向一段连续的内存空间,这里才是真正存储字符串的地方(有效字符串是指除\0以外的字符串集合)。

Q: 有了 C 字符串,为什么还需要 SDS?

A:通过阅读相关数据以及对 Redis 文档的查阅,可以总结出以下几点使用 SDS 而不适用原生 C 字符串的好处

* 更高效的获取一个 SDS 对象内保存的字符串的长度
* 杜绝缓冲区溢出
* 减少因字符串的修改导致的频繁分配和回收内存空间操作
* 二进制安全
* 和 C 语言有关字符串的库函数有一个更高的兼容性

其实看到这里,如果你之前使用其他语言中的「普通数组」实现过一个「动态数组」的话,那么除了「二进制安全」这一条好处可能不太理解之外,其余的应该都比较熟悉。下面我们就来分别说一下这几个好处。

Q: 如何更高效的获取字符串的长度?

A:这个问题在传统的 C 字符串中算是一个痛点。在一个线性的数据结构中,我们都只能通过遍历这个数据结构中所有的有效元素才能够获取它准确的长度,这个操作的时间复杂度是 O(N) 级别。但是当我们只是把 C 字符串作为 SDS 这个数据结构中的一个成员时,我们就可以通过增加另外一个成员len来实时的计算字符串的准确长度。计算的方式也很简单,就是在字符串做「新增元素」的操作时对len+1,做「减少元素」的操作时对len-1。这样一来,就可以通过访问len来获取 SDS 内存储的字符串的长度。类似于这样的实现:

void add(char a){
    buf[len++] = a;
}

void sub(char a){
    len--;
}

int length(char a){
    return len;
}

Q: 如何杜绝缓冲区溢出?

A:缓冲区溢出换成另外一种更加直白的说法:篡改了内存中本不属于你的空间中的数据。这种现象在字符串拼接以及字符串的新增字符的操作中比较常见。处理这种问题的办法也很简单:在内存容量允许的情况下,当一个字符串需要更多的内存空间的时候,重新分配1块「更大」的连续空间,将原来空间中的有效数据 copy 过去。其中,检测是否超出剩余空间,完全可以使用free属性的值,因为它代表了数组中现在还有多少可用的空间。 如果你认真的阅读了上一段的内容,就可以发现,在防止缓冲区溢出的过程中有几个「丑陋」的步骤:

  1. 可能多次在内存中分配一段连续的空间
  2. 可能多次将原来空间中的有效数据 copy 到新的空间中
  3. 分配出去的空间如果没有回收,一直在持续分配,可能会出现内存泄漏

针对于新出现的问题,我们采取了以下办法来解决:

  1. 按照一定的策略分配新的内存空间,尽量减少分配次数
  2. 当空闲空间达到一定阈值的时候,回收多余的内存空间

在 Redis 中,通过两个步骤来确定「预分配」空间的大小:

  1. 如果修改之后的字符串长度(len)小于1MB,除了分配必要的空间之外,还需要分配大小等同于len的空闲空间。例如,修改之后的字符串长度为10(len=10),那么在修改之后,新的内存空间大小为=10+10+1=21。
  2. 如果修改之后的字符串长度(len) 大于1MB,除了分配必要的空间之外,还需要分配大小等同于1MB的空闲空间。

在 SDS 相关的修改操作中,会先对可用空间和实际所需要的空间进行对比,若超出,则会分配新的空间,否则使用旧的空间。通过上面的策略,基本上可以把「重新分配内存空间」和「将原来空间中的有效数据 copy 到新的空间中 」的次数由每次必定发生,降低到最多发生 N 次(N 为修改操作进行的次数)。

这里插入一个笔者小的心得:很多程序员在解决问题的时候都倾向于找到一个完美的解决方案,若是笔者的话,可能在看到这个问题的时候也会想,能否有一个完美的办法来解决上面的问题。但是,我们可以看到,在 Redis 这种工业级的项目中,它采取的方案仍然是很普通的,甚至是我们平时做练习就会用到的「实现」。一个看似「延迟让风险发生」的办法,有的时候就是最「完美」的办法。程序员更多的应该关注如何解决问题,而不是如何「完美」的解决问题。

除了通过分配「预留空间」的方式来减少「分配」操作的次数之外,我们还担心的一点就是,如果一直无限制的进行分配,那么内存终有耗尽的时候。这就是我们常说的内存泄漏问题。想解决它也很简单,就是按照一定的策略回收已经分配的内存空间。比如:当一个 SDS 绑定的内存空间的使用量已经低于25%,那么我们就将它的内存空间缩小为原来的一半。至于为什么只缩小原来的一般而不是全部将空余空间回收,仔细思考一下就知道,如果回收的方式过于极端,那么就将「预分配」空间的优势全部抹杀了(增加内存分配的次数)。

所以,在 SDS 相关的修改(主要是删除元素)操作中,不会立刻对空闲的空间进行回收,而是将它们作为「预留空间」。为了防止「内存泄漏」,Redis 提供了专门的 API,真正的对内存空间进行释放。

Q: 如何保证 SDS 是二进制安全的?

A:「二进制安全」听起来是个比较陌生的词,但是如果你综合了 C 语言字符串的特点和二进制内容的特点就可以知道,二进制安全主要是防止它的内容中出现像\0这种特殊字符,干扰了对原字符串的正确解释。听起来比较高大上的问题,往往解决它的方案都是比较简单的。在 Redis 中,为了保证「二进制安全」,不在使用 C 语言字符串的\0字符作为其所存储的字符串的边界,而是使用len 这个属性,标识字符串中有效字符的个数。

虽然,为了保证「二进制安全」我们可以无视 C 语言字符串以\0作为字符串结尾的事实。但是,多数情况下大家还是会使用 Redis 储存「文本信息」(符合 C 语言字符串规则的,内容中不含有\0)。此时,对他们的操作可能要依赖于 C 语言和字符串相关的库函数,所以,在 SDS 的实现中会保持这样两个惯例:

  1. 给字符串分配内存空间时会考虑多分配1byte 的空间给\0
  2. 在修改字符串内容的时候,都会在最后追加一个\0字符

Redis 中的字符串

在 C 语言中,字符串可以用一个 \0 结尾的 char 数组来表示。比如说, hello world 在 C 语言中就可以表示为 "hello world\0" 。这种简单的字符串表示,在大多数情况下都能满足要求,但是,它并不能高效地支持长度计算和追加(append)这两种操作:

  • 每次计算字符串长度(strlen(s))的复杂度为 θ(N)θ(N) 。
  • 对字符串进行 N 次追加,必定需要对字符串进行 N 次内存重分配(realloc)。

在 Redis 内部, 字符串的追加和长度计算很常见, 而 APPEND 和 STRLEN 更是这两种操作,在 Redis 命令中的直接映射, 这两个简单的操作不应该成为性能的瓶颈。另外, Redis 除了处理 C 字符串之外, 还需要处理单纯的字节数组, 以及服务器协议等内容, 所以为了方便起见, Redis 的字符串表示还应该是二进制安全的: 程序不应对字符串里面保存的数据做任何假设, 数据可以是以 \0 结尾的 C 字符串, 也可以是单纯的字节数组, 或者其他格式的数据。

考虑到这两个原因, Redis 使用 sds 类型替换了 C 语言的默认字符串表示: sds 既可高效地实现追加和长度计算, 同时是二进制安全的。

sds 的实现

在前面的内容中, 我们一直将 sds 作为一种抽象数据结构来说明, 实际上, 它的实现由以下两部分组成:

typedef char *sds;

struct sdshdr 
{
    int len;    // buf 已占用长度
    int free;   // buf 剩余可用长度
    char buf[]; // 实际保存字符串数据的地方
};

其中,类型 sds 是 char * 的别名(alias),而结构 sdshdr 则保存了 len 、 free 和 buf 三个属性。

作为例子,以下是新创建的,同样保存 hello world 字符串的 sdshdr 结构:

struct sdshdr {
    len = 11;
    free = 0;
    buf = "hello world\0";  // buf 的实际长度为 len + 1
};

通过 len 属性, sdshdr 可以实现复杂度为 θ(1)θ(1) 的长度计算操作。

另一方面, 通过对 buf 分配一些额外的空间, 并使用 free 记录未使用空间的大小, sdshdr 可以让执行追加操作所需的内存重分配次数大大减少, 下一节我们就会来详细讨论这一点。

当然, sds 也对操作的正确实现提出了要求 —— 所有处理 sdshdr 的函数,都必须正确地更新 len 和 free 属性,否则就会造成 bug 。

优化追加操作

在前面说到过,利用 sdshdr 结构,除了可以用 θ(1)θ(1) 复杂度获取字符串的长度之外,还可以减少追加(append)操作所需的内存重分配次数,以下就来详细解释这个优化的原理。

为了易于理解,我们用一个 Redis 执行实例作为例子,解释一下,当执行以下代码时, Redis 内部发生了什么:

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 进行内存重分配。

比如说, 执行以下命令并不会引起 buf 的内存重分配, 因为新追加的字符串长度小于 18 :

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

再次执行 APPEND 命令之后, msg 的值所对应的 sdshdr 结构可以表示如下:

struct sdshdr {
    len = 25;
    free = 11;
    buf = "hello world again! again!\0           ";     // 空白的地方为预分配空间,共 18 + 18 + 1 个字节
}

sds.c/sdsMakeRoomFor 函数描述了 sdshdr 的这种内存预分配优化策略, 以下是这个函数的伪代码版本:

def sdsMakeRoomFor(sdshdr, required_len):

    # 预分配空间足够,无须再进行空间分配
    if (sdshdr.free >= required_len):
        return sdshdr

    # 计算新字符串的总长度
    newlen = sdshdr.len + required_len

    # 如果新字符串的总长度小于 SDS_MAX_PREALLOC
    # 那么为字符串分配 2 倍于所需长度的空间
    # 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
    if newlen < SDS_MAX_PREALLOC:
        newlen *= 2
    else:
        newlen += SDS_MAX_PREALLOC

    # 分配内存
    newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)

    # 更新 free 属性
    newsh.free = newlen - sdshdr.len

    # 返回
    return newsh

在目前版本的 Redis 中(笔者手中的版本为redis-6.0.9), 

#ifndef __SDS_H
#define __SDS_H

#define SDS_MAX_PREALLOC (1024*1024)
extern const char *SDS_NOINIT;

#include <sys/types.h>
#include <stdarg.h>
#include <stdint.h>

typedef char *sds;

....

void *sds_malloc(size_t size);
void *sds_realloc(void *ptr, size_t size);
void sds_free(void *ptr);

#ifdef REDIS_TEST
int sdsTest(int argc, char *argv[]);
#endif

#endif

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

这种分配策略会浪费内存吗?

  • 执行过 APPEND 命令的字符串会带有额外的预分配空间, 这些预分配空间不会被释放, 除非该字符串所对应的键被删除, 或者等到关闭 Redis 之后, 再次启动时重新载入的字符串对象将不会有预分配空间。
  • 因为执行 APPEND 命令的字符串键数量通常并不多, 占用内存的体积通常也不大, 所以这一般并不算什么问题。
  • 另一方面, 如果执行 APPEND 操作的键很多, 而字符串的体积又很大的话, 那可能就需要修改 Redis 服务器, 让它定时释放一些字符串键的预分配空间, 从而更有效地使用内存。

总结

1. 获取字符串长度时,C字符串需要遍历字符串直到找到‘\0’为止,它的复杂度为O(n),而SDS直接访问len属性就可以直接获取字符串的长度,复杂度为O(1)。

2. SDS的API杜绝缓存区溢出,SDS调用SdsCat时,会首先判断 sds的空间是否充足,如果不够要先扩展SDS,再进行字符串拼接。

3. 为了减少内存重分配的性能影响,SDS的字符串增长会做内存预分配操作,通过预分配策略,可以有效的减少redis分配内存的次数。

4. SDS是二进制安全的,C字符串通过判断是否为‘\0’找字符串结尾,而SDS通过len属性来找字符串结尾,这样就不怕字符串中间有'\0'。

此外,

  • Redis 的字符串表示为 sds ,而不是 C 字符串(以 \0 结尾的 char*)。
  • 对比 C 字符串, sds 有以下特性:
    • 可以高效地执行长度计算(strlen);
    • 可以高效地执行追加操作(append);
    • 二进制安全;
  • sds 会为追加操作进行优化:加快追加操作的速度,并降低内存分配的次数,代价是多占用了一些内存,而且这些内存不会被主动释放。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值