动态字符串 SDS
2023-3-23
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

 
String是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M
notion image
 
Redis使用标准C语言编写,但在存储字符时,没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将SDS用作 Redis 的默认字符串表示。
Redis里面, C 字符串只会作为字符串字面量, 用在一些无须对字符串值进行修改的地方, 比如打印日志:
Redis需要的不仅仅是一个字符串字面量, 而是一个可以被修改的字符串值时, Redis就会使用 SDS 来表示字符串值: 比如在Redis的数据库里面, 包含字符串值的键值对在底层都是由 SDS 实现的。
 
如果客户端执行命令:
那么Redis 将在数据库中创建了一个新的键值对, 其中:
  • 键值对的键是一个字符串对象, 对象的底层实现是一个保存着字符串 "msg" 的 SDS
  • 键值对的值也是一个字符串对象, 对象的底层实现是一个保存着字符串 "hello world" 的 SDS
 
又比如说, 如果客户端执行命令:
那么 Redis 将在数据库中创建一个新的键值对, 其中:
  • 键值对的键是一个字符串对象, 对象的底层实现是一个保存了字符串 "fruits" 的 SDS
  • 键值对的值是一个列表对象, 列表对象包含了三个字符串对象, 这三个字符串对象分别由三个 SDS 实现: 第一个 SDS 保存着字符串 "apple" , 第二个 SDS 保存着字符串 "banana" , 第三个 SDS 保存着字符串 "cherry" 
 
除了用来保存数据库中的字符串值之外, SDS 还被用作缓冲区(buffer): AOF 模块中的 AOF 缓冲区, 以及客户端状态中的输入缓冲区, 都是由 SDS 实现的。
 

SDS动态字符串

每个 sds.h/sdshdr 结构表示一个 SDS 值:
 
一个SDS 示例:
notion image
  • free 属性的值为0 , 表示这个 SDS 没有分配任何未使用空间。
  • len 属性的值为 5 , 表示这个 SDS 保存了一个五字节长的字符串。
  • buf 属性是一个 char 类型的数组, 数组的前五个字节分别保存了 'R'、 'e''d''i''s'五个字符, 而最后一个字节则保存了空字符 '\0' 。
 
SDS 遵循 C 字符串以空字符结尾的惯例, 保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面, 并且为空字符分配额外的 1 字节空间, 以及添加空字符到字符串末尾等操作都是由 SDS 函数自动完成的, 所以这个空字符对于 SDS 的使用者来说是完全透明的。
遵循空字符结尾这一惯例的好处是, SDS 可以直接重用一部分 C 字符串函数库里面的函数。假如有一个指向SDS的指针 s , 那么可以直接使用 stdio.h/printf 函数, 通过执行以下语句:
来打印出 SDS 保存的字符串值 "Redis” , 而无须为 SDS 编写专门的打印函数。
 

SDS 与 C 字符串的区别

C 字符串
SDS
获取字符串长度的复杂度为
获取字符串长度的复杂度为
API 是不安全的,可能会造成缓冲区溢出
API 是安全的,不会造成缓冲区溢出
修改字符串长度 N 次必然需要执行 N 次内存重分配
修改字符串长度 N 次最多需要执行 N 次内存重分配
只能保存文本数据
可以保存文本或者二进制数据
可以使用所有 <string.h> 库中的函数
可以使用一部分 <string.h> 库中的函数
 
C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串, 并且字符数组的最后一个元素总是空字符 '\0' 。C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis 对字符串在安全性、效率、以及功能方面的要求。
 
常数复杂度获取字符串长度
因为 C 字符串并不记录自身的长度信息, 所以为了获取一个C字符串的长度, 程序必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为
notion image
notion image
notion image
notion image
notion image
notion image
和C字符串不同, 因为SDS在 len 属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为,这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。 
设置和更新SDS长度的工作是由SDS的 API 在执行时自动完成的, 使用 SDS 无须进行任何手动修改长度的工作。
 
杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外, C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。
例如,<string.h>/strcat函数可将src 字符串中的内容拼接到dest字符串的末尾:
因为C字符串不记录自身的长度, 所以strcat假定用户在执行这个函数时, 已经为dest分配了足够多的内存, 可以容纳src字符串中的所有内容, 而一旦这个假定不成立时, 就会产生缓冲区溢出。
 
例如, 有两个在内存中紧邻着的C字符串s1s2s1保存了字符串"Redis" , 而s2则保存了字符串 "MongoDB"
notion image
执行:
但忘了在执行strcat之前为 s1 分配足够的空间, 那么在strcat函数执行之后, s1 的数据将溢出到 s2 所在的空间中, 导致 s2 保存的内容被意外地修改:
notion image
 
 
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
比如, SDS的API里面也有一个用于执行拼接操作的sdscat函数, 将一个C字符串拼接到给定SDS所保存的字符串的后面。 在执行拼接操作之前, sdscat会先检查给定SDS的空间是否足够, 如果不够, sdscat会先扩展SDS的空间, 然后才执行拼接操作。
                              sdscat执行之前的sds
sdscat执行之前的sds
 
                                                                           sdscat执行之后的sds
sdscat执行之后的sds
sdscat 不仅对这个 SDS 进行了拼接操作, 它还为 SDS 分配了 13 字节的未使用空间
 
减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度, 所以对于一个包含了N个字符的 C 字符串来说, 底层实现总是一个 N+1 个字符长的数组。每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
  • 如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前,需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
  • 如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
 
因为内存重分配涉及复杂的算法, 并且可能需要执行系统调用, 所以它通常是一个比较耗时的操作:
  • 在一般程序中, 如果修改字符串长度的情况不太常出现, 那么每次修改都执行一次内存重分配是可以接受的。
  • 但是Redis作为数据库, 经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话, 那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。
为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在 SDS 中, buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由 SDS 的 free 属性记录。
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
 
空间预分配
空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
额外分配的未使用空间数量由以下公式决定:
  • 如果对 SDS 进行修改之后, SDS 的长度( len 属性的值)将小于 1 MB , 那么程序分配和 len 属性同样大小的未使用空间, 这时 SDS len 属性的值将和 free 属性的值相同。 举个例子, 如果进行修改之后, SDS 的 len 将变成 13 字节, 那么程序也会分配13 字节的未使用空间, SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
  • 如果对 SDS 进行修改之后, SDS 的长度将大于等于 1 MB , 那么程序会分配 1 MB 的未使用空间。 举个例子, 如果修改之后, SDS 的len将变成30 MB , 那么程序会分配1 MB 的未使用空间, SDS 的buf数组的实际长度将为 30 MB + 1 MB + 1 byte 。
通过空间预分配策略, Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。
 
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。
举个例子, sdstrim 函数接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。
                                                                   执行sdstrim之前的sds
执行sdstrim之前的sds
                                                   执行sdstrim之后的sds
执行sdstrim之后的sds
执行sdstrim之后的SDS并没有释放多出来的8字节空间, 而是将这8字节空间作为未使用空间保留在了 SDS 里面, 如果将来要对SDS进行增长操作的话, 这些未使用空间就可能会派上用场。
通过惰性空间释放策略, SDS 避免了缩短字符串时所需的内存重分配操作, 并为将来可能有的增长操作提供了优化。
 
二进制安全
C 字符串中的字符必须符合某种编码(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
 
为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe): 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。
这也是将 SDS 的 buf 属性称为字节数组的原因 —— Redis 不是用这个数组来保存字符, 而是用它来保存一系列二进制数据。
 
兼容部分 C 字符串函数
虽然 SDS 的 API 都是二进制安全的, 但它们一样遵循 C 字符串以空字符结尾的惯例: 这些 API 总会将 SDS 保存的数据的末尾设置为空字符, 并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符, 这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h>库定义的函数。
如果有一个保存文本数据的 SDS值 sds , 那么可以重用 <string.h>/strcasecmp 函数, 使用它来对比 SDS 保存的字符串和另一个 C 字符串:
这样 Redis 就不用自己专门去写一个函数来对比 SDS 值和 C 字符串值了。
 
 

SDS API

函数
作用
时间复杂度
sdsnew
创建一个包含给定 C 字符串的 SDS
N 为给定 C 字符串的长度
sdsempty
创建一个不包含任何内容的空 SDS
sdsfree
释放给定的 SDS
sdslen
返回 SDS 的已使用空间字节数
这个值可以通过读取 SDS 的 len 属性来直接获得, 复杂度为
sdsavail
返回 SDS 的未使用空间字节数
这个值可以通过读取 SDS 的 free 属性来直接获得, 复杂度为
sdsdup
创建一个给定 SDS 的副本(copy)
N 为给定 SDS 的长度
sdsclear
清空 SDS 保存的字符串内容
因为惰性空间释放策略,复杂度为
sdscat
将给定 C 字符串拼接到 SDS 字符串的末尾
N 为被拼接 C 字符串的长度
sdscatsds
将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾
N 为被拼接 SDS 字符串的长度
sdscpy
将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串
N 为被复制 C 字符串的长度
sdsgrowzero
用空字符将 SDS 扩展至给定长度
N 为扩展新增的字节数
sdsrange
保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除
N 为被保留数据的字节数
sdstrim
接受一个SDS和一个C字符串作为参数, 从SDS左右两端分别移除所有在C字符串中出现过的字符
M 为 SDS 的长度, N为给定 C 字符串的长度
sdscmp
对比两个 SDS 字符串是否相同
N 为两个 SDS 中较短的那个 SDS 的长度
 
 
  • Redis
  • 底层数据结构链表 linkedlist
    目录