redis源码分析之四基础的数据结构SDS

    科技2022-07-11  110

    一、SDS

    在前面的初步介绍中,知道Redis中的字符串是SDS——simple dynamic string,可能对于非c++人员有点不好理解,其实如果看STL的代码中std::string的实现,可能就会发现,其实有些类似,而且SDS相对简单不少。SDS除了可以实现字符串,其实还可以用来做缓冲区,毕竟char*的定义本身在C/C++中都是天然做为缓冲区的。

    typedef char *sds;

    使用char*来操作字符串,但是底层存储采用二进制,这也是为什么Redis中二进制数据存储安全的原因,由于采用二进制(-1结尾)那么结尾为0的简单扫描就不会因为C语言中的方式而遇到尴尬(C中遇到0表示这个字符串结束了)。同样,在前面的定义中头结构体中存在了一个len字节,那么得到字符串的长度也就变成了O(1)。其实这也没啥,用空间牺牲来换取时间。包括连接自动分配内存也是类似的机理,应用确实简单了,但是底层设计变得不简约,各有所取吧,和C的字符串表示,没有太大的可比性。

    二、源码分析

    1、string 使用过Redis的同学都知道,可以通过SET命令简单指定KEY和VALUE就可以把字符串存储到数据库中,那么这个基础的字符串对你从哪里创建呢?

    typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or * LFU data (least significant 8 bits frequency * and most significant 16 bits access time). * / int refcount; void * ptr; } robj; #define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44 robj *createStringObject(const char *ptr, size_t len) { if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) return createEmbeddedStringObject(ptr,len); else return createRawStringObject(ptr,len); } robj *createEmbeddedStringObject(const char *ptr, size_t len) { robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1); struct sdshdr8 *sh = (void*)(o+1); o->type = OBJ_STRING; o->encoding = OBJ_ENCODING_EMBSTR; o->ptr = sh+1; o->refcount = 1; if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL; } else { o->lru = LRU_CLOCK(); } sh->len = len; sh->alloc = len; sh->flags = SDS_TYPE_8; if (ptr == SDS_NOINIT) sh->buf[len] = '\0'; else if (ptr) { memcpy(sh->buf,ptr,len); sh->buf[len] = '\0'; } else { memset(sh->buf,0,len+1); } return o; } robj *createRawStringObject(const char *ptr, size_t len) { return createObject(OBJ_STRING, sdsnewlen(ptr,len)); } robj *createObject(int type, void *ptr) { robj *o = zmalloc(sizeof(*o)); o->type = type; o->encoding = OBJ_ENCODING_RAW; o->ptr = ptr; o->refcount = 1; /* Set the LRU to the current lruclock (minutes resolution), or * alternatively the LFU counter. * / if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL; } else { o->lru = LRU_CLOCK(); } return o; } //t_string.c //判断字符串最大长度为512M static int checkStringLength(client *c, long long size) { if (size > 512*1024*1024) { addReplyError(c,"string exceeds maximum allowed size (512MB)"); return C_ERR; } return C_OK; }

    看上面的代码是不是有点头晕,一点点分析一下,并没啥。首先,所有的数据对象都是一个robj,也就是redisObject,此结构体的定义中前三个是位域。第一个和第二个分别占四位,后面的LRU占24个位,一共占用了32个字节即一个无符号整形的大小。这样做的好处基本上主要是为节省空间。 使用命令时,会调用createStringObject,然后再根据情况调用不同的形式来创建不同的对象,根据哪个标志呢,就是robj中定义的那个编码位域:encoding,在Redis中有以下几种:

    /* Objects encoding. Some kind of objects like Strings and Hashes can be * internally represented in multiple ways. The 'encoding' field of the object * is set to one of this fields for this object. */ #define OBJ_ENCODING_RAW 0 /* Raw representation */ #define OBJ_ENCODING_INT 1 /* Encoded as integer */ #define OBJ_ENCODING_HT 2 /* Encoded as hash table */ #define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */ #define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */ #define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */ #define OBJ_ENCODING_INTSET 6 /* Encoded as intset */ #define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */ #define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */ #define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */ #define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */

    看注释就可以看到,和字符串有关的就是0,1, 8,分别代表,整形、raw和embedded sds。如果输入的数据可以转换为long,那么字符串就会转成一个long类型,其下的ptr指向其内存地址,当然,对象本身也就是int类型了。如果字符串的大小小44字节,则使用embstr,否则就使用raw。

    2、SDS头数据定义 虽然说SDS定义成为了char*,但是那是为了保持和C的兼容性,其实一个真正的SDS完整定义,要有一个头,也就是上文中提到的五个类型,一个字符数组,GCC中可以使用0长度数组,用来表示头和后面的数据在内存上是线性不可分割的。这个似乎在Windows上有细小差别。

    struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits * / char buf[]; };

    回过头来看一下头的定义,这里只拿一个来代表,在C语言中可以有很多种的方法来实现通过数据结构体的某个成员来取得相应的结构体对象的指针,反过来同样适用。这也是SDS中用宏不断的操作数据对象的原因。这种操作方式简单 快捷,缺点就是如果不怎么了解C语言或者相关的文档说明,实在是不容易理解。即使在C和C++编程里,宏编程也是相当复杂相当难用的,扯多了。 数据定义中注释很清晰,第一个是真实长度,第二个可分配的长度(容量),排除了头和null终结符。flags左侧的三位表示类型(下面定义的宏的0~4),余下的未使用。最后一个就是真正的字节缓冲区了。

    看一下几个宏的定义:

    #define SDS_TYPE_5 0 #define SDS_TYPE_8 1 #define SDS_TYPE_16 2 #define SDS_TYPE_32 3 #define SDS_TYPE_64 4 #define SDS_TYPE_MASK 7 #define SDS_TYPE_BITS 3 #define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T))); #define SDS_HDR(T,s) ((struct sdshdr##T * )((s)-(sizeof(struct sdshdr##T)))) #define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)

    后面三个宏的展开其实挺简单的,给没学过C的一些人一个简单的展开方法,自己写一个最基础的C控制台程序,把这几个宏拷贝进去,然后在编译时指定宏开展项(VS在C/C++的“预处理器”-“预处理到文件”,生成.i文件中可以看到;GCC采用 gcc -E )。 SDS_HDR_VAR用来得到Header的真实地址并保存在sh变量中,SDS_HDR只得到这个指针使用,最后一个得到类型的位域。怎么理解呢?看一个例子就明白了:

    SDS s; 此时s的指针指向何处,明白这一点,你就明白了那个宏,s指向真实的缓冲区的地址,它在高字节,也就是buf的位置,而那个SDSHDR的指针地址在低字节,所以减去它的大小正好得到Header的地址,下来想怎么操作就怎么操作了。

    3、创建方式 SDS中的函数不少,有获取长度的,有比较大小的,有去除空格的…基本上常见的都有。但是最主要还是创建有三个函数:sdsnew,sdsempty,sdsnewlen,但是前面两个都调用后面这个函数,看一下代码:

    sds sdsnewlen(const void *init, size_t initlen) { //看到这个sh是不是想到了宏SDS_HDR_VAR void * sh; sds s; //类型判断 char type = sdsReqType(initlen); /* Empty strings are usually created in order to append. Use type 8 * since type 5 is not good at this. */ if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8; int hdrlen = sdsHdrSize(type); unsigned char *fp; /* flags pointer. * / //分配空间 包含头+实际长度+空余的NULL 一个字节 sh = s_malloc(hdrlen+initlen+1); if (init==SDS_NOINIT) init = NULL; else if (!init) memset(sh, 0, hdrlen+initlen+1); if (sh == NULL) return NULL; s = (char*)sh+hdrlen; fp = ((unsigned char*)s)-1; //根据类型处理相关的头数据 switch(type) { case SDS_TYPE_5: { * fp = type | (initlen << SDS_TYPE_BITS); break; } case SDS_TYPE_8: { SDS_HDR_VAR(8,s); sh->len = initlen; sh->alloc = initlen; * fp = type; break; } case SDS_TYPE_16: { SDS_HDR_VAR(16,s); sh->len = initlen; sh->alloc = initlen; * fp = type; break; } case SDS_TYPE_32: { SDS_HDR_VAR(32,s); sh->len = initlen; sh->alloc = initlen; * fp = type; break; } case SDS_TYPE_64: { SDS_HDR_VAR(64,s); sh->len = initlen; sh->alloc = initlen; * fp = type; break; } } if (initlen && init) memcpy(s, init, initlen); s[initlen] = '\0'; return s; }

    注释在里面了,没有什么特别需要说明的。

    4、扩容 动态调整其实也非常重要,这涉及到使用效率和内存的利用率:

    sds sdsMakeRoomFor(sds s, size_t addlen) { void * sh, * newsh; size_t avail = sdsavail(s); size_t len, newlen; char type, oldtype = s[-1] & SDS_TYPE_MASK; int hdrlen; /* Return ASAP if there is enough space left. * / if (avail >= addlen) return s; len = sdslen(s); sh = (char*)s-sdsHdrSize(oldtype); newlen = (len+addlen); //此处代码说明如果新分配的空间小于1MB,则成倍分配,否则分配1MB //#define SDS_MAX_PREALLOC (1024*1024) if (newlen < SDS_MAX_PREALLOC) newlen *= 2; else newlen += SDS_MAX_PREALLOC; type = sdsReqType(newlen); /* Don't use type 5: the user is appending to the string and type 5 is * not able to remember empty space, so sdsMakeRoomFor() must be called * at every appending operation. * / //不要使用SDS_TYPE_5,直接转化成SDS_TYPE_8 if (type == SDS_TYPE_5) type = SDS_TYPE_8; hdrlen = sdsHdrSize(type); if (oldtype==type) { //类型一致,直接REALLOC newsh = s_realloc(sh, hdrlen+newlen+1); if (newsh == NULL) { s_free(sh); return NULL; } s = (char*)newsh+hdrlen; } else { /* Since the header size changes, need to move the string forward, * and can't use realloc * / //如果类型调整,需要更新头并重新分配空间并释放原空间 newsh = s_malloc(hdrlen+newlen+1); if (newsh == NULL) return NULL; memcpy((char*)newsh+hdrlen, s, len+1); s_free(sh); s = (char*)newsh+hdrlen; s[-1] = type; sdssetlen(s, len); } sdssetalloc(s, newlen); return s; }

    5、内存的重利用 这个在C和C++编程中经常使用,其实就是扩是真扩,删除并不一定是真删除,最大限度重复利用内存空间。

    void sdsclear(sds s) { sdssetlen(s, 0); s[0] = '\0'; }

    设置长度,并在结尾置0,这样空间其实仍然存在,只是在访问时,空无一物。

    三、总结

    通过内存的按策略分配而不是按实际需要分配,可以节省内存分配的次数,但却牺牲了一部分空间,同样,回收也是如此。这和内存池的使用有异曲同工的目的,但效率会更好一些。字符串的创建和扩容以及其它操作中都有类似的身影,这也是Redis数据结构设计的精妙之处,值得借鉴使用。

    Processed: 0.011, SQL: 8