前言

在 Redis 中没有直接使用 C 语言的字符串表示(以空字符结尾的字符数组),而是自己构建了 简单动态字符串(SDS) 的抽象类型,并将它用作 Redis 的默认字符串表示。

不过,C 字符串在 Redis 中会作为字符串字面量用在一些不需要对字符串值修改的地方,比如打印日志。

当 Redis 需要一个可以被修改的字符串值时,将使用 SDS 来表示字符串值。在 Redis 数据里面,包含字符串值的键值对在底层都是由 SDS 实现的。

比如,客户端执行命令:

SET msg "hello world"

Redis 将在数据库中创建一个新的键值对,其中:

1、键值对的键是一个字符串对象,底层实现是一个保存 "msg" 的 SDS;

2、键值对的值也是一个字符串对象,底层实现是一个保存字符串 "hello world" 的SDS。

1、SDS的定义

SDS 数据结构如下:

struct sdshdr {    // 记录 buf 数组中已使用字节的数量    // 等于 SDS 所保存字符串的长度    int len;    // 记录 buf 数组中未使用字节的数量    int free;    // 字节数组,用于保存字符串    char buf[ ];};

图表示例如下:

sdshdr

free 0

len 5

buf

 buf 对应的字符串:

R

e

d

i

s

\0

说明:

1、free 属性的值为0,表示这个 SDS 没有分配任何未使用空间;

2、len 属性的值为5,表示这个 SDS 保存了一个5字节长的字符串;

3、buf 是一个 char 类型的数组,数组的前5字节分别保存了 r e d i s 五个字符串,最后一个字节保存了空字符 `\0`。

SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的1字节不计算在 SDS 的 len 属性里面(这个都是由 SDS 函数自动完成的)。

下面展示另外一个 SDS 示例。

分配未使用空间的 SDS 如下:

sdshdr

free 4

len 5

buf

buf 对应的字符串:

R

e

d

i

s

\0

图中使用4个空格来表示4字节未使用空间。

2、SDS 与 C 字符串的区别

C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符 `\0`。

这种简单的字符串表示方式,并不能满足 Redis 对字符串在安全性、效率以及功能方面的要求。

2.1 常数复杂度获取字符串长度

因为 C 字符串并不记录自身的长度信息,如果需要获取一个字符串的长度,需要遍历整个字符串,并且对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。

跟 C 字符串不同,SDS 在 len 属性中记录了本身的长度,所以获取一个 SDS 的复杂度仅为 O(1)。这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。

2.2 杜绝缓冲区溢出

c 字符串不记录自身长度带来的另外一个问题是容易造成缓冲区溢出:

1、执行字符串拼接的时候,当原字符串没有足够多的内存可以容纳拼接后的字符串,将产生缓冲区溢出。

2、假设程序中有两个在内存中紧邻着的 C 字符串 s1 和 s2,当将 s1 修改成比原先 s1 更加大的字符串时,s1 的数据将溢出到 s2 所在的空间,导致别的字符串 s2 的内容被意外地修改。

例子:

在内存中紧邻的两个 c 字符串 "Go" 与 "Java"。

...

G

o

\0

J

a

v

a

\0

...

当将 "Go" 修改为 "Golang"时,忘记了为 "Go" 分配足够多的空间,那么:

...

G

o

l

a

n

g

\0

...

SDS 与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改后所需的要求,如果不满足,API 会自动将 SDS 的空间扩展至所需的大小,再执行实际的修改操作。

2.3 减少修改字符串时带来的内存重分配次数

因为 C 字符串的长度和底层数组的长度存着关联性(包含 N 个 字符的 C 字符串的底层实现总是一个 N+1 个字符长的数组),所以每次增长或者缩短一个 C 字符串,程序总要对保存这个 C 字符串的数组进行一次内存分配操作。

1、如果需要增长字符串,事先需要通过内存重分配来扩展底层数组的空间大小(如果忘记就会产生缓冲区溢出);

2、如果是缩短字符串,事先需要通过内存重分配来释放不再使用的那部分空间(如果忘记则会产生内存泄漏)。

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

但是 Redis 作为数据库,经常被用于速度要求严苛、数据频繁修改的场合,如果这种修改频繁发生的话,可能会对性能造成影响。 

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

1、空间预分配 

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

当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。 

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

1、如果对 SDS 修改后,SDS的长度将小于 1MB,那么程序将分配和 len 属性同样大小的未使用空间;

2、如果将大于等于 1MB,那么将分配 1MB的未使用空间。 

例子:如果进行修改后,SDS 的 len 将变成13字节,那么程序也会分配13字节未使用空间,SDS 的 buf 数组的实际长度将变成 13+13+1=27 字节(额外一字节用于保存空字符) 

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

通过这种策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。 

2、惰性释放 

惰性释放主要用于优化 SDS 的字符串缩短操作。 

当需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多余出来的字节,而是使用 free 属性将这些字节的数量记录起来,以供后续使用。 

另外,SDS 也提供了相应的 API,可以去真正释放 SDS 未使用空间。 

2.4 二进制安全 

C 字符串中的字符必须符合某种编码(比如 ASII),并且出了字符串的末尾外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾。(这些限制使得 C 字符串只能保存文本数据,而不能保存图片、音频、压缩文件主要的二进制数据)。

而 SDS 的 API 都是二进制安全的,所有的 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据进行修改。 

2.5 兼容部分 C 字符串函数 

SDS 遵循 C 字符串以空字符结尾的惯例,在末尾设置为空字符,并且为 buf 数组分配空间时多分配一个字节来容纳这个空字符。 这是为了让那些保存文本数据的 SDS 可以重用一部分 库定义的函数。

2.6 总结  

C 字符串和 SDS 之间的差别。

java字符串查询 java查询字符串长度_字符串

3. SDS API

函数名称

作用

复杂度

sdsempty

创建一个只包含空字符串””的sds

O(N)

sdsnew

根据给定的C字符串,创建一个相应的sds

O(N)

sdsnewlen

创建一个指定长度的sds,接受一个指定的C字符串作为初始化值

O(N)

sdsdup

复制给定的sds

O(N)

sdsfree

释放给定的sds

O(1)

sdsupdatelen

更新给定sds所对应的sdshdr的free与len值

O(1)

sdsMakeRoomFor

对给定sds对应sdshdr的buf进行扩展

O(N)

sdscatlen

将一个C字符串追加到给定的sds对应sdshdr的buf

O(N)

sdscpylen

将一个C字符串复制到sds中,需要依据sds的总长度来判断是否需要扩展

O(N)

sdscatprintf

通过格式化输出形式,来追加到给定的sds

O(N)

sdstrim

对给定sds,删除前端/后端在给定的C字符串中的字符

O(N)

sdsrange

截取给定sds,[start,end]字符串

O(N)

sdscmp

比较两个sds的大小

O(N)

sdssplitlen

对给定的字符串s按照给定的sep分隔字符串来进行切割

O(N)