Redis源码解析-SDS

总结

  1. 要想理解 Redis 数据类型的设计,必须要先了解 redisObject。 Redis 的 key 是 String 类型,但 value 可以是很多类型(String/List/Hash/Set/ZSet等),所以 Redis 要想存储多种数据类型,就要设计一个通用的对象进行封装,这个对象就是 redisObject。

    // server.h
    typedef struct redisObject {
        unsigned type:4;
        unsigned encoding:4;
        unsigned lru:LRU_BITS;
        int refcount;
        void *ptr;
    } robj;
    

    其中,最重要的 2 个字段:

    • type:面向用户的数据类型(String/List/Hash/Set/ZSet等)

    • encoding:每一种数据类型,可以对应不同的底层数据结构来实现(SDS/ziplist/intset/hashtable/skiplist等)

    例如 String,可以用 embstr(嵌入式字符串,redisObject 和 SDS 一起分配内存),也可以用 rawstr(redisObject 和 SDS 分开存储)实现。

    又或者,当用户写入的是一个「数字」时,底层会转成 long 来存储,节省内存。

    同理,Hash/Set/ZSet 在数据量少时,采用 ziplist 存储,否则就转为 hashtable 来存。

    所以,redisObject 的作用在于:

    • 为多种数据类型提供统一的表示方式

    • 同一种数据类型,底层可以对应不同实现,节省内存

    • 支持对象共享和引用计数,共享对象存储一份,可多次使用,节省内存

    redisObject 更像是连接「上层数据类型」「底层数据结构」之间的桥梁。

  2. 关于 String 类型的实现,底层对应 3 种数据结构:

    • embstr:小于 44 字节,嵌入式存储,redisObject 和 SDS 一起分配内存,只分配 1 次内存
    • rawstr:大于 44 字节,redisObject 和 SDS 分开存储,需分配 2 次内存
    • long:整数存储(小于 10000,使用共享对象池存储,但有个前提:Redis 没有设置淘汰策略,详见 object.c 的 tryObjectEncoding 函数)
  3. ziplist 的特点:

    • 连续内存存储:每个元素紧凑排列,内存利用率高
    • 变长编码:存储数据时,采用变长编码(满足数据长度的前提下,尽可能少分配内存)
    • 寻找元素需遍历:存放太多元素,性能会下降(适合少量数据存储)
    • 级联更新:更新、删除元素,会引发级联更新(因为内存连续,前面数据膨胀/删除了,后面要跟着一起动)

    List、Hash、Set、ZSet 底层都用到了 ziplist。

  4. intset 的特点:

    • Set 存储如果都是数字,采用 intset 存储
    • 变长编码:数字范围不同,intset 会选择 int16/int32/int64 编码(intset.c 的 _intsetValueEncoding 函数)
    • 有序:intset 在存储时是有序的,这意味着查找一个元素,可使用「二分查找」(intset.c 的 intsetSearch 函数)
    • 编码升级/降级:添加、更新、删除元素,数据范围发生变化,会引发编码长度升级或降级
  5. SDS 判断是否使用嵌入式字符串的条件是 44 字节,嵌入式字符串会把 redisObject 和 SDS 一起分配内存,那在存储时结构是这样的:

    • redisObject:16 个字节
    • SDS:sdshdr8(3 个字节)+ SDS 字符数组(N 字节 + \0 结束符 1 个字节)

    Redis 规定嵌入式字符串最大以 64 字节存储,所以 N = 64 - 16(redisObject) - 3(sdshr8) - 1(\0), N = 44 字节。

背景

对于Redis来说,键值对中的键是字符串,值有时也是字符串。我们在Redis中写入一条用户信息,记录了用户姓名、性别、所在城市等,这些都是字符串,如下所示:

SET user:id:100{"name":"zhangsan","gender":"M","city":"beijing"}

在Redis的字符串结构需要满足以下条件

  • 能支持丰富且高效的字符串操作,比如字符串追加、拷贝、比较、获取长度等;
  • 能保存任意的二进制数据,比如图片等;
  • 能尽可能地节省内存开销。

说到字符串会立即想到C语言中的char*字符数组,它可以通过string.h中定义的诸如字符串比较函数strcmp、字符串长度计算函数strlen、字符串追加函数strcat等来进行操作。

strcat源码如下:

char *strcat(char*dest,const char*src){
	//将目标字符串复制给tmp变量
	char *tmp=dest;
	//用一个while循环遍历目标字符串,直到遇到“\0”跳出循环,指向目标字符串的末尾			while(*dest)
		dest++;
	//将源字符串中的每个字符逐一赋值到目标字符串中,直到遇到结束字符						while((*dest++=*Src++)!='\o')
	return tmp;
}

综上,char*字符数组有以下不足

  • 操作效率低:获取长度需遍历,O(N)复杂度
  • 二进制不安全:无法存储包含 \0 的数据(\0代表字符串结束)

SDS

结构

简单动态字符串(Simple Dynamic String,SDS),它的结构如下:

SDS创建函数sdsnewlen

typedef char *sds; //sds是char*的别名

sds sdsnewlen(const void*init,size_t initlen){
	void*sh; //指向SDS结构体的指针
	sds s; //sds类型变量,即char*字符数组
	...
	sh=s_malloc(hdrLen+initlen+1); //新建SDs结构,并分配内存空间
	...
	s=(char*)sh+hdrLen;//sds类型变量指向SDS结构体中的buf数组,sh指向SD
	...
    if(initlen&&init)
		memcpy(s,init,initlen); //将要传入的字符串拷贝给sds变量s 					s[initlen]='\0'; //变量s未尾增加\0,表示字符串结束
	returns;
}

SDS字符串追加函数sdscatlen()

sds sdscatlen(sds s,const void*t,size_t len){
	//获取目标字符串s的当前长度
	size_t curlen=sdslen(s);
	//根据要追加的长度Len和目标字符串s的现有长度,判断是否要增加新的空间					s=sdsMakeRoomFor(s,len);
	if(s==NULL) return NULL;
	//将源字符串t中1en长度的数据拷贝到目标字符串结尾
	memcpy(s+curlen,t,len);
	//设置目标字符串的最新长度:拷贝前长度curLen加上拷贝长度
	sdssetlen(s,curlen+Len);
	//拷贝后,在目标字符串结尾加上\0
	s[curlen+1en]='\0';
	returns;
}

SDS通过记录字符数组的使用长度和分配空间大小,避免了对字符串的遍历操作,降低了操作开销,进一步就可以帮助诸多字符串操作更加高效地完成,比如创建、追加、复制、比较等。

封装

SDS把目标字符串的空间检查和扩容封装在了sdsMakeRoomFor 函数中,并且在涉及字符串空间变化的操作中,如追加、复制等,会直接调用该函数。这一设计实现,就避免了开发人员因忘记给目标字符串扩容,而导致操作失败的情况。比如,在使用函数 strcpy(char *dest,const char *src)时,如果src的长度大于dest的长度,代码中也没有做检查的话,就会造成内存溢出。

紧凑型字符串结构

SDS结构中有一个元数据flags,表示的是SDS类型。事实上,SDS一共设计了5种类型,分别是sdshdr5(已经不再使用)、sdshdr8、sdshdr16、sdshdr32和sdshdr64。这5种类型的主要区别就在于,它们数据结构中的字符数组现有长度len和分配空间长度alloc,这两个元数据的数据类型不同。

sdshdr8:

struct__attribute__((__packed__))sdshdr8{
	uint8_t len;/*字符数组现有长度*/
	uint8_t alloc;/*字符数组的已分配空间,不包括结构体和\0结束字符*/
	unsigned char flags;/*SDS类型*/
	char buf[];/*字符数组*/
};

现有长度len和已分配空间alloc的数据类型都是uint8t。uint8t是8位无符号整型,会占用1字节的内存空间。当字符串类型是sdshdr8时,它能表示的字符数组长度(包括数组最后一位\0)不会超过256字节(2的8次方等于256)。而对于sdshdr16、sdshdr32、sdshdr64三种类型来说,它们的len和alloc 数据类型分别是uint16t、uint32t、uint64t即它们能表示的字符数组长度,分别不超过2的16次方、32次方和64次方。这两个元数据占用的内存空间在sdshdr16、sdshdr32、sdshdr64类型中,则分别是2字节、4字节和8字节。

SDS之设计不同的结构头(即不同类型),是为了能灵活保存不同大小的字符串,从而有效节省内存空间。因为在保存不同大小的字符串时,结构头占用的内存空间也不一样,这样一来,在保存小字符串时,结构头占用空间也比较少。

代码中的__attribute__((__packed__))的作用就是告诉编译器,在编译 sdshdr8结构时,不要使用内存对齐的方式,而是采用紧凑的方式分配内存。因为在默认情况下,编译器会按照内存对齐的方式,给变量分配内存。

posted @ 2021-08-30 23:42  请务必优秀  阅读(328)  评论(0编辑  收藏  举报