Redis-String

悲观者从机会中看到困难。乐观者从困难中看到机会。
——温斯顿·丘吉尔

string是最基本的key-value结构,key是唯一标识,value是具体的值,value其实不仅是字符串。也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是512M

内部实现

string类型的底层的数据结构实现主要是int和SDS(简单动态字符串)。

SDS和我们认识的C字符串不太一样,之所以没有使用C语言的字符串表示,因为SDS相比于C的原生字符串:

  • SDS不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API都会以处理二进制的方式来处理 SDS 存放在 buf[]数组里的数据。所以SDS不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
  • SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 0(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 0(1)
  • Redis 的 SDS API是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。

字符串对象的内部编码有三种:intrawembstr

如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将 void*转换成 long),并将字符串对象的编码设置为int 。

如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 32 字节(redis 2.+版本),那么
字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为 **embstr **编码是专门用于保存短字符串的一种优化编码方式:

如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为 raw :

注意,embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的:

  • redis 2.+是 32 字节
  • redis 3.0-4.0 是 39 字节
  • redis 5.0 是 44 字节

可以看到 embstr 和 raw 编码都会使用 sDs 来保存值,但不同之处在于 embstr 会通过一次内存分配函数来分配一块连续的内存空间来保存 redisobject 和 ss ,而 raw 编码会通过调用两次内存分配函数来分别分配两块空间来保存 redisobject 和 sDs 。Redis这样做会有很多好处:

  • embstr 编码将创建字符串对象所需的内存分配次数从 raw编码的两次降低为一次;
  • 释放embst, 编码的字符串对象同样只需要调用一次内存释放函数;
  • 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提.
    升性能

但是 embstr 也有缺点的:

  • 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。
缓存对象

使用String来缓存对象有两种方式:

  • 直接缓存整个对象的JSON,命令例子:

    SET user:1'{"name":"xiaolin","age":18}'

  • 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值,命令例子:

    MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20

127.0.0.1:6379> keys *
1) "name"
2) "age"
127.0.0.1:6379> get name
"jeef"
127.0.0.1:6379> append name rey #如果 key 已经存在并且是一个字符串, APPEND 命令将指定的 value 追加到该 key 原来值(value)的末尾。如果不存在,相当于新建字符串。
(integer) 7
127.0.0.1:6379> get name
"jeefrey"
127.0.0.1:6379> strlen name #返回 key 所储存的字符串值的长度
(integer) 7
127.0.0.1:6379> append name ttt
(integer) 10
127.0.0.1:6379> strlen name
(integer) 10
127.0.0.1:6379> 

SDS数据结构

字符串在Redis中很常用,键值对中的键是字符串类型,值有时也是字符串类型

Redis是用C语言实现的,但是它没有直接使用C语言的char*字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS)的数据结构来表示字符串,也就是Redis的String数据类型的底层数据结构是SDS。

既然Redis设计了SDS结构来表示字符串,肯定是C语言的char*字符数组存在一些缺陷。

C语言字符串的缺陷

C语言的字符串其实就i是一个字符数组,即数组中的每个元素是字符串的一个字符。

在C语言里,对字符串操作时,char*指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。

因此,C语言标准库中的字符串操作函数就通过判断字符是不是"\0”来决定要不要停止操作,如果当前字符不是“\0”,说明字符串还没结束,可以继续操作,如果当前字符是"\0”是则说明字符串结束了,就要停止操作。
举个例子,C语言获取字符串长度的函数 strlen ,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为"\0”后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度。

很明显,C语言获取字符串长度的时间复杂度是O(N)(这是一个可以改进的地方)

C语言字符串用“\0”字符作为结尾标记有个缺陷。假设有个字符串中有个“\0”字符,这时在操作这个字符串时就会提早结束,比如“xiao\0lin“字符串,计算字符串长度的时候则会是 4,如下图:

因此,除了字符串的末尾之外,字符串里面不能含有“\0”字符,否则最先被程序读入的“\0”字符将被误认为是字符串结尾,这个限制使得C语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据(这也是一个可以改进的地方)

另外,C语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。

举个例子,strcat 函数是可以将两个字符串拼接在一起。

//将 src 字符串拼接到 dest 字符串后面
char *strcat(char *dest,const char* src);

C语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(这是一个可以改进的地方)

而且,strcat 函数和 stren 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,对字符串的操作效率不高

通过以上的分析,我们可以得知C语言的字符串不足之处以及可以改进的地方:

  • 获取字符串长度的时间复杂度为 O(N)
  • 字符串的结尾是以“\0”字符标识,字符串里面不能包含有"\0”字符,因此不能保存二进制数据
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

SDS结构设计

下图就是Redis 5.0 的SDS的数据结构:

结构中的每个成员变量分别介绍下:

len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要O(1).

alloc,分配的字符数组的空间长度。这样在修改字符串的时候,可以通过'alloc-len'计算出剩余空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出的问题。

  • flags,用来表示不同类型的SDS。一共设计了5种类型,分别是sdshdr5、sdshdr8、sdshdr16、sdshdr32和sdshdr64,后面在说明区别之处。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
  • 总的来说,Redis的SDS结构在原本字符数组之上,增加了三个元数据:len,alloc,flags,用来解决c语言字符串的缺陷

O(1)复杂度获取字符串长度

C语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。而 Redis 的 SDS 结构因为加入了len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 0(1)。

二进制安全

因为 SDS 不需要用 “0”字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “0” 的数据。但是 SDS 为了兼容部分C 语言标准库的函数,SDS 字符串结尾还是会加上“0”字焘。

因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。
通过使用二进制安全的 SDS,而不是(字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

不会发生缓冲区溢出

C语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。
所以,Redis 的 SDS 结构里引入了 aloc和 len 成员变量,这样 SDS API通过 alloc- len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够
用。
而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。

SDS扩容的规则代码如下:

hisds hi_sdsMakeRoomFor(hisds s, size_t addlen)
{
    ... ...
    // s目前的剩余空间已足够,无需扩展,直接返回
    if(avail >= addlen)
    	return s;
	//获取目前s的长度
    len = hi_sdslen(s);
    sh =(char *)s - hi_sdsHdrsize(oldtype);
    //扩展之后 s 至少需要的长度
    newlen =(len + addlen);
    //根据新长度,为s分配新空间所需要的大小
    if(newlen < HI SDS MAX PREALLOC)
	//新长度<HI SDS MAX PREALLOC 则分配所需空间*2的空间
        newlen *= 2;
	else
	//否则,分配长度为目前长度+1MB
	neWlen += HI SDS MAX PREALLOC;
    ... ...
}

  • 如果所需的 sds 长度小于1MB,那么最后的扩容是按照翻倍扩容来执行的,即2倍的newlen
  • 如果所需的 sds 长度超过1MB,那么最后的扩容长度应该是 newlen + 1MB。

在扩容 SDS 空间之前,SDS API 会优先检査未使用空间是否足够,如果不够的话,AP! 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」

这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。

所以,使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。

节省内存空间

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。Redis 一共设计了 5种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32和 sdshdr64这5种类型的主要区别就在于,它们数据结构中的len 和 aloc 成员变量的数据类型不同。比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:

struct _attribute_ ((packed )) sdshdr16 {
	uint16_t len;
	uint16_t alloc;
	unsigned char flags;
	char buf[];
};
struct _attribute_(( packed)) sdshdr32 {
	uint32_t len;
	uint32_t alloc;
	unsigned char flags;
	char buf[];
};

可以看到:

  • sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2
    的 16 次方。
  • sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实attribute((packed))际占用字节数进行对齐

比如,sdshdr16 类型的 SDS,默认情况下,编译器会按照2 字节对齐的方式给变量分配内存,这意味着,即使一个变量的大小不到2个字节,编译器也会给它分配2个字节。

举个例子,假设下面这个结构体,它有两个成员变量,类型分别是 char和 int,如下所示:

#include <stdio.h>
struct test1 {
	char a;
	int b;.
} test1;

int main(){
	printf("%lu\n",sizeof(test1));
	return 0;
}

大家猜猜这个结构体大小是多少?我先直接说答案,这个结构体大小计算出来是 8。

这是因为默认情况下,编译器是使用「字节对齐」的方式分配内存,虽然 char 类型只占一个字节,但是由于成员变量里有 int 类型,它占用了4个字节,所以在成员变量为 char 类型分配内存时,会分配4个字节,其中这多余的3个字节是为了字节对齐而分配的,相当于有3个字节被浪费掉了。
如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 attribute((packed)) 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。
比如,我用_attribute_((packed))属性定义下面的结构体 ,同样包含 char 和 int 两个类型的成员变
量,代码如下所示:

#include <stdio.h>
struct _attribute_((packed)) test2 {
	char a;
	int b;.
} test2;

int main(){
	printf("%lu\n",sizeof(test2));
	return 0;
}

这时打印的结果是5(1个字节char + 4个字节int)

可以看得出,这是按照实际占用字节数进行分配内存的,这样可以节省内存空间

原文

posted @ 2025-04-06 15:11  Tsukinor  阅读(16)  评论(0)    收藏  举报