美光科技任命朱文菊为美光半导体(西安)有限责任公司副总裁
06-06
每周学习分享记录于此,周一/周二发布,文章维护在Github:studeyang/leanrning-share 。回顾第《Redis 的 String 类型,原来这么占内存》文章,我们了解了SDS的底层结构,发现SDS存储了大量的元数据。
再加上全局哈希表的实现,Redis String类型在内存使用方面并不理想。然后在《学习分享(第1期)之Redis:巧用Hash类型节省内存》文章中,我们学习了另一种节省内存的解决方案。
使用ziplist结构的Hash类型,内存占用减少一半,效果显着。虽然我们使用的是String类型,占用内存较多,但Redis其实对SDS有节省内存的设计。
除此之外,Redis还考虑了其??他方面的内存开销。今天我们就从源码层面来看看节省内存的设计。
本文中的代码版本是6.2.4。 1、redisObject的位域定义方法我们知道redisObject是对SDS、ziplist等底层数据结构的封装。
因此,如果redisObject能够得到优化,最终能够带来节省内存的用户体验。 redisObject的结构体在源码server.h中定义,如下代码所示: 代码语言:c copy #define LRU_BITS 24typedef struct redisObject { unsigned type: 4; // 对象类型(4 位 = 0.5 字节) unsigned encoding :4;//编码(4 位 = 0.5 字节) unsigned lru:LRU_BITS;//记录应用程序最后一次访问该对象的时间(24 位 = 3 bytes) int refcount;//引用计数。
等于0时,表示可以被垃圾回收(32位=4字节) void *ptr;//指向底层实际数据存储结构,如:sds等(8字节)} robj; type、encoding、lru、refcount都是redisObject的元数据。 redisObject的结构如下图所示。
从代码中我们可以看到,type、encoding、lru这三个变量后面有一个冒号,后面跟着一个值,表示元数据占用的位数。这种使用冒号和值来定义变量的方法实际上是C语言中的位域定义方法,可以用来有效节省内存开销。
这种方法比较适合的场景是,当一个变量无法占据某个数据类型的所有位时,可以使用位域定义方法,将某个数据类型中的位(32位)划分为多个(3个)位域,每个位域占用一定数量的位。这样,一个数据类型的所有位都可以用来定义多个变量,从而有效节省内存开销。
另外,SDS的设计中还有内存优化设计。让我们详细看看它们。
2. SDS 的设计 Redis 3.2 版本之后,SDS 从一种数据结构变成了五种数据结构。它们是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
其中sdshdr5仅用于Redis中的key中。其他四种不同类型的结构头可以适应不同大小的字符串。
以 sdshdr8 为例,其结构体定义如下: 代码语言: c copy struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* 使用 */ uint8_t alloc; /* 排除标头和空终止符 */ unsigned char flags; /* 3 个 lsb 类型,5 个未使用的位 */ char buf[];};不知道大家有没有注意到struct和sdshdr8之间使用了__attribute__((__packed__))。这是 SDS 的节省内存设计 - 紧凑字符串。
2.1 紧字符串 什么是紧字符串?它的作用是告诉编译器在编译sdshdr8结构时不要使用字节对齐,而是以紧凑的方式分配内存。默认情况下,编译器以 8 字节对齐方式为变量分配内存。
也就是说,即使变量的大小小于 8 个字节,编译器也会为其分配 8 个字节。举个例子。
假设我定义了一个结构体st1,它有两个成员变量,类型分别是char和int,如下: 代码语言:c Copy #include
然后我使用 __attribute__ ((__packed__)) 属性重新定义结构体 st2,其中也包含两种类型的成员变量:char 和 int。代码如下: 代码语言:c copy #include
st2结构只占用5个字节的空间。另外,Redis还做了这样的优化:当保存的字符串小于等于44字节时,RedisObject中的元数据、指针和SDS都是连续的内存区域。
这种布局方法称为embstr编码。方法;当字符串大于44字节时,SDS和RedisObject分开布局。
这种布局方法称为原始编码模式。这部分代码在object.c文件中: 代码语言:c copy #define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44robj *createStringObject(const char *ptr, size_t len) { // 当字符串长度小于等于44字节时,创建嵌入字符串如果(len <= Object 部分),则 createStringObject 函数调用 createEmbeddedStringObject 函数。
这是 SDS 的第二个节省内存的设计——嵌入式字符串。在讲嵌入字符串之前,我们先来看看当len长度大于OBJ_ENCODING_EMBSTR_SIZE_LIMIT(默认为44字节)时这个普通字符串的创建过程。
2.2 RawString 普通字符串 对于createRawStringObject函数来说,在创建String类型的值时会调用createObject函数。 createObject函数主要用于创建Redis数据对象。
代码如下所示。代码语言:c copy robj *createRawStringObject(const char *ptr, size_t len) { return createObject(OBJ_STRING, sdsnewlen(ptr,len));} createObject函数有两个参数,一个用于指示要创建的数据对象的类型被创建,另一个是指向SDS对象的指针,这个指针是通过sdsnewlen函数创建的。
代码语言:c copy robj *createObject(int type, void *ptr) { // 【1】为redisObject结构体分配内存空间 robj *o = zmalloc(sizeof(*o)); //设置redisObject的类型 o->type = type; //设置redisObject的编码类型 o->encoding = OBJ_ENCODING_RAW; // 【2】将传入的指针赋值给redisObject中的指针 o->ptr = ptr; … return o;} 调用sdsnewlen函数创建SDS对象的指针后,还分配了一块SDS内存空间。接下来,createObject函数将为redisObject结构体分配内存空间,如上面的代码[1]所示。
然后将传入的指针赋值给redisObject中的指针,如上面代码[2]所示。接下来让我们看看嵌入的字符串。
2.3 EmbeddedString 嵌入字符串由上面我们知道,当保存的字符串小于等于44字节时,RedisObject中的元数据、指针和SDS都是连续的内存区域。那么createEmbeddedStringObject函数是如何将redisObject和SDS的内存区域放在一起的呢?代码语言:c copy robj *createEmbeddedStringObject(const char *ptr, size_t len) { // [1] robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len); ... if (ptr == SDS_NOINIT) sh->buf[len] = '\0'; else if (ptr) { // [2] memcpy(sh->buf,ptr,len); sh->buf[len] = '\0'; } else { memset(sh->buf,0,len); } return o;} 首先,createEmbeddedStringObject函数会分配一块连续的内存空间。
该内存空间的大小等于redisObject结构体+SDS结构体头的大小。 sdshdr8 的大小 + 字符串大小的总和,加上 1 字节终止字符“\0”。
这部分代码如上面的[1]所示。首先分配连续的内存空间以避免内存碎片。
然后,createEmbeddedStringObject函数会将参数传入的指针ptr指向的字符串复制到SDS结构体中的字符数组中,并在数组末尾添加结束字符。这部分代码如上面的[2]所示。
好了,以上就是Redis在设计SDS结构来节省内存方面的两个优化点。不过,除了嵌入字符串之外,Redis还设计了压缩列表,这也是一种紧凑的内存数据结构。
下面我们就来了解一下。下载其设计理念。
3.压缩列表的设计为了方便理解压缩列表的设计和实现,我们首先看一下它的创建函数ziplistNew。这部分代码在ziplist.c文件中,如下所示: 代码语言:c copy unsigned char *ziplistNew (void) { // 初始分配大小 unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;无符号字符 *zl = zmalloc(字节); … // 将列表末尾设置为 ZIP_END zl[bytes-1] = ZIP_END; return zl;} 可以看到,ziplistNew函数的逻辑很简单,就是创建一个大小为ZIPLIST_HEADER_SIZE和ZIPLIST_END_SIZE之和的连续内存空间,然后将连续空间的最后一个字节赋给ZIP_END ,表示列表的末尾。
另外,ziplist.c文件中还定义了ZIPLIST_HEADER_SIZE、ZIPLIST_END_SIZE和ZIP_END的值,它们分别代表ziplist的列表头大小、列表尾大小和列表尾字节内容,如下所示。代码语言:c copy //ziplist列表头大小 #define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2 + sizeof(uint16_t))//ziplist列表尾部大小 #define ZIPLIST_END_SIZE (sizeof(uint8_t))//ziplist列表尾部大小 字节内容#define ZIP_END 列表头包括两个32位整数和一个16位整数,分别表示压缩列表的总字节数zlbytes、列表最后一个元素距列表头zltail的偏移量、列表中的元素数量 Number zllen;列表的末尾包含一个8位整数,表示列表的末尾。
执行ziplistNew函数创建ziplist后,内存布局如下图所示。注意,此时ziplist中还没有实际数据,所以图中没有画出来。
然后,当我们向ziplist中插入数据时,完整的内存布局如下图所示。ziplist条目包括三部分,即前一项的长度(prevlen)、当前项的长度信息的编码结果(encoding)、当前项的实际数据(data)。
当我们向ziplist中插入数据时,ziplist会根据数据是字符串还是整数以及它们的大小对其进行不同的编码。这种根据数据大小进行相应编码的设计思想,正是Redis为了节省内存而采用的。
代码语言: c copy unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { ... /* 写入条目 */ p += zipStorePrevEntryLength(p,prevlen); p += zipStoreEntryEncoding(p,编码,slen); if (ZIP_IS_STR(编码)) { memcpy(p,s,slen); } else { zipSaveInteger(p,值,编码); } ZIPLIST_INCR_LENGTH(zl,1); return zl;} 这里的源码下面会提到。为了描述方便,这里标记为【源代码A】。
此外,前一项的长度将被记录在每个列表项条目中。由于每个列表项的长度不同,Redis会根据数据长度选择不同大小的字节来记录prevlen。
3.1 使用不同大小的字节来记录prevlen。例如,假设我们使用4个字节来记录prevlen。
如果前面的列表项只是一个5字节的字符串“redis”,那么我们用1个字节(8位)就可以表示一个0~byte长度的字符串。此时prevlen用了4个字节来记录,浪费了3个字节。
我们来看看Redis是如何根据数据长度选择不同大小的字节来记录prevlen的。从上面【源码A】的__ziplistInsert函数可以看到,ziplist在对prevlen进行编码时,会首先调用zipStorePrevEntryLength函数。
函数代码如下: 代码语言: c copy unsigned int zipStorePrevEntryLength(unsigned char *p, unsigned int len) { if (p == NULL) { return (len < ZIP_BIG_PREVLEN) ? 1 : sizeof(uint32_t) + 1; } else { //判断prevlen的长度是否小于ZIP_BIG_PREVLEN if (len < ZIP_BIG_PREVLEN) { //如果小于bytes,则返回prevlen为1字节 p[0] = len;返回1; } else { //否则,调用zipStorePrevEntryLengthLarge进行编码 return zipStorePrevEntryLengthLarge(p,len);可以看到, zipStorePrevEntryLength 函数会判断前一个列表项是否小于 ZIP_BIG_PREVLEN (ZIP_BIG_PREVLEN 的值为)。如果是,则prevlen用1个字节表示;否则,zipStorePrevEntryLength 函数调用 zipStorePrevEntryLengthLarge 函数进行进一步编码。
zipStorePrevEntryLengthLarge函数首先会将prevlen的第1个字节设置为 ,然后使用内存复制函数memcpy将前一个列表项的长度值复制到prevlen的第2到第5个字节。最后,zipStorePrevEntryLengthLarge 函数返回 prevlen 的大小,即 5 个字节。
代码语言:c copy int zipStorePrevEntryLengthLarge(unsigned char *p, unsigned int len) { uint32_t u32; if (p != NULL) { //设置prevlen的第一个字节为ZIP_BIG_PREVLEN,即p[0] = ZIP_BIG_PREVLEN; u32 = 长度; //将前一个列表项的长度值复制到prevlen的第2到第5个字节,其中sizeof(u32)的值为4 memcpy(p,&u32,sizeof(u32)); ... } //返回prevlen的大小,即5字节 return 1 + sizeof(uint32_t);} 好了,了解了prevlen采用1字节和5字节两种编码方式后,我们来了解一下编码方式。 。
3.2 使用不同大小的字节来记录编码。我们回到上面的__ziplistInsert函数,也就是【源代码A】。
我们可以看到,执行完zipStorePrevEntryLength函数逻辑后,会立即调用zipStoreEntryEncoding函数。在zipStoreEntryEncoding函数中,ziplist对整数和字符串使用不同字节长度的编码结果。
代码语言:c copy unsigned int zipStoreEncoding(unsigned char *p, unsigned char编码, unsigned int rawlen) { //默认编码结果为1字节 unsigned char len = 1; //如果是字符串数据 if (ZIP_IS_STR(encoding )) { //如果字符串长度小于等于63字节(十六进制为0x3f) if (rawlen <= 0x3f) { //默认编码结果为1字节 if (!p) 返回 len; ... } //字符串长度小于等于3个字节(十六进制为0x3fff) else if (rawlen <= 0x3fff) { //编码结果为2个字节 len += 1; if (!p) 返回 len; ... } //字符串长度大于3个字节 else { //编码结果为5个字节 len += 4; if (!p) 返回 len; ... } } else { /* 如果数据为整数,则编码结果为 1 个字节 */ if (!p) return len; ... }} 可以看出,当数据是不同长度的字符串或者整数时,编码结果的长度len是不同的。简而言之,对于不同长度的数据,采用元数据信息prevlen和不同字节大小的编码来记录。
这种方法可以有效节省内存开销。
版权声明:本文内容由互联网用户自发贡献,本站不拥有所有权,不承担相关法律责任。如果发现本站有涉嫌抄袭的内容,欢迎发送邮件 举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。
标签:
相关文章
06-18
06-21
06-18
06-18
06-08
06-17
06-17
最新文章
【玩转GPU】ControlNet初学者生存指南
【实战】获取小程序中用户的城市信息(附源码)
包雪雪简单介绍Vue.js:开学
Go进阶:使用Gin框架简单实现服务端渲染
线程池介绍及实际案例分享
JMeter 注释 18 - JMeter 常用配置组件介绍
基于Sentry的大数据权限解决方案
【云+社区年度征文集】GPE监控介绍及使用