在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
前言字符串在日常开发中应用得比较普遍,对于Redis来说,键值对中的键是字符串,值也是字符串。比如在Redis中写入一条客户信息记录姓名、性别、爱好等。
在Redis这种内存数据库中,由于字符串被广泛的应用,在设计字符串时基于以下几点来设计: 可能会有人问了,既然C语言库提供了char*这样的字符数组来字符串操作。比如strcmp,strcat。感觉完全可以考虑直接使用C库提供的啊。C库字符串运用是很普遍,但是也不是没有问题的。它需要频繁的创建和检查空间,这在实际项目中其实很花时间的。所以,Redis设计了简单字符串(SDS,Simple Data )来表示字符串。同原来的C语言相比提升了字符串的操作效率,而且还支持二进制格式。下面我们就来介绍下Redis的字符串是如何实现的。 为什么不用char*先来看看char*字符数组的结构,其实很简单就是开辟一块连续的内存空间来依次存放每一个字符,最后一个字符是"\0"表示字符串结束。C库中的字符串操作函数就是通过检查"\0"来判断字符串结束。比如strlen函数就是遍历字符数组中的每一个字符并计数,直到遇到"\0"结束计数,然后返回计数结果。下面我们通过一个代码来看看"\0"结束字符对字符串长度的影响。
这段代码的执行结果如下:
表示a1的字符长度是2个字符。这是因为在he后面有了"\0",所以字符串以"\0"表示结束,这就会产生一个问题,如果字符串内部本身就有"\0",那么数据就会被"\0"截断,而这就不能保存任意二进制数据了。 传统设计操作复杂度高除了上面提到的不能保存任意二进制数据以外,操作复杂度也挺大。比如C语言中用得比较普遍的strlen函数,它要遍历字符数组中的每一个字符才能得到字符串长度。所以,时间复杂度是O(n)。另外再说一个常用函数strcat,它同strlen函数一样先遍历字符串才能得到目标字符串的末尾,而且它把源字符串追加到目标字符串末尾的时,还得确认目标字符串是否具有足够的空间。所以在调用的时候,开发人员还要人为保证目标字符串有足够的可用空间,不然就需要动态地申请空间。这样不仅时间复杂度高,操作复杂度也高了。 SDS的设计Redis在设计的时候还是尽量保证复用C标准的字符串操作函数的。Redis在保留了使用字符数组来保存实际数据基础上,专门设计了一种SDS数据结构。 typedef char *sds; /* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 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[]; }; 用个图来表示一下
代码中定义了一个别名 typedef char *sds; 所以SDS本质还是字符数组,只是在字符数组基础上增加了额外的元数据,Redis在使用字符数组时直接使用sds这个别名。 SDS的高效操作创建sdsRedis调用sdsnewlen函数创建sds。我们以sedsnewlen举例,代码如下: hisds sdsnewlen(const void *init, size_t initlen) { //指向SDS结构的指针 void *sh; //sds类型变量,就是char*的别名 sds s; //根据大小获取SDS的类型 char type = hi_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. */ //为新创建的sds结构分配内存 sh = s_malloc(hdrlen+initlen+1); if (sh == NULL) return NULL; if (!init) memset(sh, 0, hdrlen+initlen+1); //指向SDS结构体中的buf数组,sh指向SDS结构的起始位置,hdrlen表示元数据的长度 s = (char*)sh+hdrlen; fp = ((unsigned char*)s)-1; //根据类型初始化len,alloc switch(type) { case SDS_TYPE_5: { *fp = type | (initlen << HI_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) //将字符串拷贝给sds变量s memcpy(s, init, initlen); //字符串变量末尾添加"\0"表示字符串结束 s[initlen] = '\0'; return s; } 该函数主要执行过程如下: 字符数组拼接由于sds结构中记录了占用的空间和被分配的空间,所以它比传统C语言的字符串效率更高。下面我们通过Redis的字符串追加函数sdscatlen来看一看。代码如下: sds sdscatlen(sds s, const void *t, size_t len) { //获取目标字符串的长度 size_t curlen = sdslen(s); //根据追加长度和目标字符串长度判断是否需要增加新的空间 s = sdsMakeRoomFor(s,len); if (s == NULL) return NULL; //将源字符串t中len长度的数据拷贝到目标字符串尾部 memcpy(s+curlen, t, len); //设置目标字符串的最新长度 sdssetlen(s, curlen+len); //拷贝完成后,在字符串结尾加上"\0" s[curlen+len] = '\0'; return s; } 这个函数有三个参数分别是目标字符串s,源字符串t和要追加的长度len。这个代码执行过程如下: 长度获取代码中,函数sdslen记录了字符数组的使用长度,不用同C库一样遍历字符串了,这样可以大大降低了操作使用字符串的开销。该函数的代码如下所示: static inline size_t sdslen(const sds s) { unsigned char flags = s[-1]; switch(flags&SDS_TYPE_MASK) { case SDS_TYPE_5: return SDS_TYPE_5_LEN(flags); case SDS_TYPE_8: return SDS_HDR(8,s)->len; case SDS_TYPE_16: return SDS_HDR(16,s)->len; case SDS_TYPE_32: return SDS_HDR(32,s)->len; case SDS_TYPE_64: return SDS_HDR(64,s)->len; } return 0; } 这样时间复杂度直接降到了O(1)。这个函数有一个骚操作,通过s[-1]获取到flags,然后调用SDS_HDR宏函数。我们来看下这个宏函数定义 #define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T)))) 其中##用来将两个token连接为一个token,所以加上参数将在预编译阶段将被替换如下 SDS_HDR(8,s); ((struct sdshdr8 *)((s)-(sizeof(struct sdshdr8)))) 字符数组地址减去结构体的大小,就能获取到结构体的首地址,然后直接访问len属性。 预分配内存空间此外,在代码中还使用了sdsMakeRoomFor函数,它在拼接字符串之前会检查是否需要扩容,如果需要扩容则会预分配空间。这一设计的好处就是避免了开发中忘记给目标字符串扩容而导致操作失败。比如strcpy(char* dst, const char* dst),如果src长度大于了dst的长度,又没有做检查就会遭成内存溢出。代码如下所示: sds sdsMakeRoomFor(sds s, size_t addlen) { void *sh, *newsh; //获取SDS目前可用的空间 size_t avail = sdsavail(s); size_t len, newlen; char type, oldtype = s[-1] & SDS_TYPE_MASK; int hdrlen; size_t usable; //空余空间足够,无需扩展 if (avail >= addlen) return s; len = sdslen(s); sh = (char*)s-sdsHdrSize(oldtype); newlen = (len+addlen); assert(newlen > len); /* Catch size_t overflow */ //如果新的字符数组长度小于SDS_MAX_PREALLOC //分配2倍所需长度 if (newlen < SDS_MAX_PREALLOC) newlen *= 2; //否则分配新长度加上SDS_MAX_PREALLOC的长度 else newlen += SDS_MAX_PREALLOC; //重新获取SDS类型 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. */ if (type == SDS_TYPE_5) type = SDS_TYPE_8; hdrlen = sdsHdrSize(type); assert(hdrlen + newlen + 1 > len); /* Catch size_t overflow */ if (oldtype==type) { newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable); if (newsh == NULL) return NULL; s = (char*)newsh+hdrlen; } else { / /如果头部大小发生变化只需要将字符数组向前移,不使用realloc newsh = s_malloc_usable(hdrlen+newlen+1, &usable); 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); } usable = usable-hdrlen-1; if (usable > sdsTypeMaxSize(type)) usable = sdsTypeMaxSize(type); //更新SDS容量 sdssetalloc(s, usable); return s; } 其中SDS_MAX_PREALLOC的长度为1024*1024 #define SDS_MAX_PREALLOC (1024*1024) 节省内存的设计前面讲SDS结构的时候提到过它有一个元数据flag,表示字符串类型。SDS一共有5中类型,它们分别是sdshdr5,sdshdr8,sdshdr16,sdshdr32和sdshdr64。这五种的主要区别是它们字符数组的现有长度len和分配空间alloc的不同。 struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 我们可以看到现有长度len和已分配空间alloc都是uint16_t类型,uint16_t是16位无符号整型,会占用2个字节。当字符串类型是sdshdr16的时候它包含的字符数组长度最大为2^16-1字节。而对于其它三种sdshdr8,sdshdr32和sdshdr64,以此类推它们的类型就分别是uin8_t,uin32_t和uint64_t,len和alloc这两个元数据占用的空间分别是1字节、4字节和8字节。 #include <string.h> #include <iostream> using namespace std; typedef struct MyStruct { char a; int b; } MyStruct; int main() { cout << sizeof(MyStruct) << endl; return 0; } 虽然char占用1个字节,int占用4个字节,但是打印出来是8,这样多出来的3个字节白白浪费掉了。现在我们运用__attribute__ ((packed))属性定义结构体,就可以实际占用多少字节,编译器就分配多少空间。我们把刚才代码修改一下加上这个属性。代码如下 #include <string.h> #include <iostream> using namespace std; typedef struct MyStruct { char a; int b; } __attribute__ ((__packed__))MyStruct; int main() { cout << sizeof(MyStruct) << endl; return 0; } 运行这段代码,结果就变为5了,表示编译器用了紧凑型的内存分配。在开发过程中,为了节省内存开销就可以考虑把__attribute__ ((packed))这个属性运用起来。 到此这篇关于Redis的字符串是如何实现的的文章就介绍到这了,更多相关Redis字符串实现内容请搜索极客世界以前的文章或继续浏览下面的相关文章希望大家以后多多支持极客世界! |
请发表评论