Loading

Redis 数据结构-简单动态字符串

Redis 是基于 C 语言的内存数据库,但是 Redis 中并没有使用 C 语言的字符串(实质是 以空格结尾的字符数组)作为默认的字符串,而是自己构建了一种名为 简单动态字符串(Simple Dynamic String) 的抽象数据结构,将其用作默认的字符串表示。

通常而言,SDS 在 Redis 中被用于 1. 默认的字符串结构;2. 用作缓冲区(buffer)。

我们首先看看 Redis 是如何定义 SDS 的,再看看为什么要通过实现 SDS 作为默认的字符串实现。

SDS 的定义

在 Redis 在 sds.h/sdshrd 定义了 SDS 的结构体:

struct {
    // 实际的字符数组,用于保存字符串
    char buf[];
    
    // buf数组中**已使用**字节的长度
    int len;
    
    // buf数组中的剩余空间长度
    int free; 
}

下图是一个 SDS 的示例:

  • buf 中存储了 Redis 这个字符串
    • 可以看到,SDS 和 C 语言一样,通过 字符数组 存储字符串,且 末尾有一个空字符 \0 作为结尾
    • SDS 底层采用和 C 语言一样的存储,这样 Redis 可以直接复用 C 标准库中的部分字符串处理函数
  • len 保存了 buf 中存储字符串的 实际长度
    • 注意,这里 len 不包含 buf 末尾的空字符 \0
  • freebuf剩余空闲空间

为什么需要 SDS

常数复杂度获取字符串长度(len 字段)

在 C 语言中,获取字符串(也就是字符数组)的长度,是一个 O(N) 复杂度的操作。

  • C 需要遍历字符数组,直到遇到空字符 \0

SDS 的 len 字段令 Redis 获取字符串长度的复杂度降为 O(1)(直接读取 len 字段即可),这也确保了「获取字符串长度」这一行为,不会成为 Redis 的性能瓶颈。

杜绝缓冲区溢出(len 字段)

什么是「缓冲区溢出」?

首先,我们需要知道的是,C 语言通过空字符 \0 判断字符串是否结束。

如下例,在内存中有下面两个连续的 C 字符串 S1S2,各自保存了 RedisStr 两个字符串:

C 在执行字符串拼接函数 strcat 时,会假定调用者已经分配好了足够的内存、直接在字符串末尾添加对应字符

即,如果我们在上图情况下,执行 strcat(s1, "01"),便会得到下图的结果:

可以看到,S1 虽然正常修改,但其数据溢出到 S2 所在的内存空间中,导致 S2 被意外修改了:S2 变成了字符串 "1"

这便是我们所说的 缓冲区溢出


SDS 如何杜绝?

SDS 通过它的 空间分配策略 杜绝「缓冲区溢出」这一现象。

当通过 SDS 的 API 对一个 SDS 进行修改时,API 会按照下列步骤执行:

  1. 检查 SDS 的空间是否满足要求
  2. 如果不满足要求,API 会先对 SDS 自动进行扩容
  3. 执行实际的修改操作

SDS 的空间分配策略(free & len 字段)

前面提到,如果通过 API 对 SDS 进行修改,API 会通过自动扩容来确保 SDS 空间满足要求;这是 SDS 空间分配策略的一种,这里我们仔细说一下 SDS 具体的空间分配策略。

C 字符串的空间分配

SDS 的空间分配策略和 C 不同,对于 C 字符串,其空间分配策略如下:

  • 如果需要进行的是「增长字符串」,例如拼接操作 append
    • 先通过内存重分配扩展底层数组的空间大小(如果没有这么做,就会发生上面提到的「缓冲区溢出」)
  • 如果需要进行的是「缩短字符串」,例如截断操作 trim
    • 需要先通过内存重分配释放字符串不再使用的空间(如果没有这一步,那么就会发生「内存泄漏」)

可以看到,每次对 C 字符串进行操作时,都需要进行 内存重分配

  • 内存重分配涉及复杂的算法,甚至可能需要执行系统调用,是一个 较为耗时 的操作
  • Redis 作为数据库,对很可能被用于对速度有要求,同时也会对数据进行频繁修改的场合
    • 如果使用 C 字符串原始的空间分配策略,那么每次修改字符串的内存重分配会对性能造成较为严重的损耗

基于这个原因,SDS 实现了自己的「空间分配策略」。

SDS 的空间分配策略

对于每个 SDS 结构,buf 数组长度和实际存储的字符串长度并不是相同的,准确地说,应该是 len(buf)==len+free+1buf 数组的空字符不在 lenfree 计算中)。SDS 基于 free 字段实现了「空间预分配」和「惰性空间释放」两种空间分配策略。

空间预分配

空间预分配用于优化字符串的增长操作

当 SDS API 对 SDS 字符串进行增长时,不仅会为 SDS 分配修改所需空间,还会基于 SDS 字符串的增长后的实际长度(也就是 len)为 SDS 分配额外的空间

  • 如果增长后,SDS 保存的字符串小于 1MB(通过 len 字段判断)
    • 那么会为 SDS 预先分配 len 属性相同大小 的未使用空间 free
  • 如果增长后,SDS 保存的字符串大于等于 1MB
    • 会为 SDS 分配 1MB 的未使用空间 free

如下图,有 SDS "Redis",我们对其顺序执行两次修改:

  1. 第一次追加字符串 "01"
    1. 当前 free 为 1,小于字符串 "01" 的长度,因此需要扩容,也就是进行内存重分配
    2. 因为追加后的 len 为 7,小于 1024,因此我们直接扩充未使用空间 freelen 的大小,也就是 7
  2. 第二次追加字符串 "02"
    1. 当前 free 为 7,足够追加字符串 "02",因此无需扩容,直接追加即可

可以看到,通过这种「空间预分配」的策略,SDS 将连续增长 N 次字符串所需的内存分配次数从「必定 N 次」优化为「最多 N 次」。

惰性空间释放

惰性空间释放用于优化字符串的缩短操作

当 SDS API 需要缩短 SDS 保存的字符串时,程序不会像 C 一样立即释放被缩短的空间,而是仅增长 free 字段、并将空间保留至将来使用。

如下图,我们缩短之前的 SDS "Redis0102""Redis"

  • 可以看到,buf 数组中的无用空间并没有被释放,而是将其保留,并令 free 增长而已。

这样做的好处是,如果后续有对这个字符串进行增长操作时,可以尽可能的减少扩容动作

与此同时,SDS 也提供了「主动释放空闲内存」的 api,让我们在有需要时让这些空闲空间真正被释放,防止内存浪费。

二进制安全(len & buf 字段)

在 C 语言中,由于是用字符数组实现的字符串,安全性方面天然就带有一些限制:

  • 字符串中的字符必须符合某种编码(例如 ASCII)
  • 除了字符串末尾,字符串中不能包含空字符 \0(也就是前面提到的「缓冲区溢出」)

这也使得 C 语言的字符串只能保存文本数据,不能保存图片、音频等二进制数据,也就是「二进制不安全」。

而为什么说 SDS 是二进制安全的呢?

  • SDS 的 API 会以处理二进制的方式来处理 buf 数组里的数据,不对数据作任何限制和处理,也就是保证了数据写入和读取时都是一样的
  • SDS 通过 len 字段判断字符串是否结束,也就是允许中间出现空字符 \0

因此,SDS 是一个「二进制安全」的字符串,它可以存储任何形式的二进制数据。

兼容部分 C 字符串函数(buf 字段)

如前文「SDS 的定义」一节中所说,SDS 和 C 语言一样,通过 字符数组 存储字符串,且 末尾有一个空字符 \0 作为结尾。这样保存文本数据的 SDS 就可以复用部分 <string.h> 中的函数了。

总结

最后再对 SDS 做一个总结。

SDS 是 Redis 在 C 字符串基础上实现的数据结构,用于 Redis 中默认的字符串表示,其结构体定义如下:

struct {
    // 实际的字符数组,用于保存字符串
    char buf[];
    
    // buf数组中**已使用**字节的长度
    int len;
    
    // buf数组中的剩余空间长度
    int free; 
}

相比于原生的 C 字符串,SDS 有如下的优点:

  • O(1) 的时间复杂度获取字符串长度
  • 不会出现「缓冲区溢出」
  • 减少了字符串扩缩容时的内存重分配次数
  • 二进制安全(可以存储视频等二进制数据)
  • 同时兼容部分 C 字符串库函数
posted @ 2022-02-10 09:35  KawaiHe  阅读(140)  评论(0编辑  收藏  举报