这里记录每周的学习分享,周一 / 周二发表,文章保护在 Github:studeyang/leanrning-share。
回顾
在文章《Redis 的 String 类型,原来这么占内存》中,咱们学习了 SDS 的底层构造,发现 SDS 存储了很多的元数据,再加上全局哈希表的实现,使得 Redis String 类型在内存占用方面并不现实。
而后在文章《学习分享(第 1 期)之 Redis:巧用 Hash 类型节俭内存》中,咱们学习了另一种节俭内存的计划,应用 ziplist 构造的 Hash 类型,内存占用缩小了一半,效果显著。
尽管咱们在应用 String 类型后,占用了较多内存,但其实 Redis 是对 SDS 做了节俭内存设计的。除此之外,Redis 在其余方面也都思考了内存开销,明天咱们就从源码层面来看看都做了哪些节俭内存的设计。
文中代码版本为 6.2.4。
一、redisObject 的位域定义法
咱们晓得,redisObject 是底层数据结构如 SDS, ziplist 的封装,因而,redisObject 如果能做优化,最终也能带来节俭内存的用户体验。在源码 server.h
中定义了 redisObject 的构造体,如上面代码所示:
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;// 对象类型(4 位 =0.5 字节)unsigned encoding:4;// 编码(4 位 =0.5 字节)unsigned lru:LRU_BITS;// 记录对象最初一次被应用程序拜访的工夫(24 位 = 3 字节)int refcount;// 援用计数。等于 0 时示意能够被垃圾回收(32 位 = 4 字节)void *ptr;// 指向底层理论的数据存储构造,如:sds 等 (8 字节)
} robj;
type, encoding, lru, refcount 都是 redisObject 的元数据,redisObject 的构造如下图所示。
从代码中咱们能够看到,在 type、encoding 和 lru 三个变量前面都有一个冒号,并紧跟着一个数值,示意该元数据占用的比特数。这种变量后应用冒号和数值的定义方法,实际上是 C 语言中的位域定义方法,能够用来无效地节俭内存开销。
这种办法比拟实用的场景是,当一个变量占用不了一个数据类型的所有 bits 时,就能够应用位域定义方法,把一个数据类型中的 bits(32 bits),划分成多个(3 个)位域,每个位域占肯定的 bit 数。这样一来,一个数据类型的所有 bits 就能够定义多个变量了,从而也就无效节俭了内存开销。
另外,SDS 的设计中,也有内存优化的设计,咱们具体来看看有哪些。
二、SDS 的设计
在 Redis 3.2 版本之后,SDS 由一种数据结构变成了 5 种数据结构。别离是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,其中 sdshdr5 只被利用在了 Redis 中的 key 中,另外 4 种不同类型的构造头能够适配不同大小的字符串。
以 sdshdr8 为例,它的构造定义如下所示:
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 和 sdshdr8 之间应用了 __attribute__ ((__packed__))
。这是 SDS 的一个节俭内存的设计 – 紧凑型字符串。
2.1 紧凑型字符串
什么是紧凑型字符串呢?
它的作用就是通知编译器,在编译 sdshdr8 构造时,不要应用字节对齐的形式,而是采纳紧凑的形式分配内存。默认状况下,编译器会依照 8 字节对齐的形式,给变量分配内存。也就是说,即便一个变量的大小不到 8 个字节,编译器也会给它调配 8 个字节。
举个例子。假如我定义了一个构造体 st1,它有两个成员变量,类型别离是 char 和 int,如下所示:
#include <stdio.h>
int main() {
struct st1 {
char a;
int b;
} ts1;
printf("%lu\n", sizeof(ts1));
return 0;
}
咱们晓得,char 类型占用 1 个字节,int 类型占用 4 个字节,然而如果你运行这段代码,就会发现打印进去的后果是 8。这就是因为在默认状况下,编译器会依照 8 字节对齐的形式,给 st1 构造体调配 8 个字节的空间,然而这样就有 3 个字节被节约掉了。
那我用 __attribute__ ((__packed__))
属性从新定义构造体 st2,同样蕴含 char 和 int 两个类型的成员变量,代码如下所示:
#include <stdio.h>
int main() {struct __attribute__((packed)) st2{
char a;
int b;
} ts2;
printf("%lu\n", sizeof(ts2));
return 0;
}
当你运行这段代码时,能够看到打印的后果是 5,这就是紧凑型内存调配,st2 构造体只占用 5 个字节的空间。
除此之外,Redis 还做了这样的优化:当保留的字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块间断的内存区域,这种布局形式被称为 embstr 编码方式;当字符串大于 44 字节时,SDS 和 RedisObject 就离开布局了,这种布局形式被称为 raw 编码模式。
这部分的代码在 object.c
文件中:
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
// 当字符串长度小于等于 44 字节,创立嵌入式字符串
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
return createEmbeddedStringObject(ptr,len);
// 字符串长度大于 44 字节,创立一般字符串
else
return createRawStringObject(ptr,len);
}
当 len 的长度小于等于 OBJ_ENCODING_EMBSTR_SIZE_LIMIT
(默认为 44 字节)时,createStringObject
函数就会调用 createEmbeddedStringObject
函数。这是 SDS 第二个节俭内存的设计 – 嵌入式字符串。
在讲述嵌入式字符串之前,咱们还是先来看看,当 len 的长度大于 OBJ_ENCODING_EMBSTR_SIZE_LIMIT
(默认为 44 字节)时,这种一般字符串的创立过程。
2.2 RawString 一般字符串
对于 createRawStringObject
函数来说,它在创立 String 类型的值的时候,会调用 createObject
函数。createObject
函数次要是用来创立 Redis 的数据对象的。代码如下所示。
robj *createRawStringObject(const char *ptr, size_t len) {return createObject(OBJ_STRING, sdsnewlen(ptr,len));
}
createObject
函数有两个参数,一个是用来示意所要创立的数据对象类型,另一个是指向 SDS 对象的指针,这个指针是通过 sdsnewlen
函数创立的。
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 的内存区域搁置在一起的呢?
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
//【1】robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);
...
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+1);
}
return o;
}
首先,createEmbeddedStringObject
函数会调配一块间断的内存空间,这块内存空间的大小等于 redisObject
构造体的大小 + SDS
构造头 sdshdr8
的大小 + 字符串大小的总和,并且再加上 1 字节完结字符“\0”。这部分代码如上【1】处。
先调配了一块间断的内存空间,从而防止了内存碎片。
而后,createEmbeddedStringObject
函数会把参数中传入的指针 ptr 所指向的字符串,拷贝到 SDS 构造体中的字符数组,并在数组最初增加完结字符。这部分代码如上【2】处。
好了,以上就是 Redis 在设计 SDS 构造上节俭内存的两个优化点,不过除了嵌入式字符串之外,Redis 还设计了压缩列表,这也是一种紧凑型的内存数据结构,上面咱们再来学习下它的设计思路。
三、压缩列表的设计
为了不便了解压缩列表的设计与实现,咱们先来看看它的创立函数 ziplistNew,这部分代码在 ziplist.c
文件中,如下所示:
unsigned char *ziplistNew(void) {
// 初始调配的大小
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
…
// 将列表尾设置为 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 的列表头大小、列表尾大小和列表尾字节内容,如下所示。
//ziplist 的列表头大小
#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2 + sizeof(uint16_t))
//ziplist 的列表尾大小
#define ZIPLIST_END_SIZE (sizeof(uint8_t))
//ziplist 的列表尾字节内容
#define ZIP_END 255
列表头包含 2 个 32 bits 整数和 1 个 16 bits 整数,别离示意压缩列表的总字节数 zlbytes,列表最初一个元素离列表头的偏移 zltail,以及列表中的元素个数 zllen;列表尾包含 1 个 8 bits 整数,示意列表完结。执行完 ziplistNew
函数创立一个 ziplist 后,内存布局就如下图所示。
留神,此时 ziplist 中还没有理论的数据,所以图中并没有画进去。
而后,当咱们往 ziplist 中插入数据后,残缺的内存布局如下图所示。
ziplist entry 包含三局部内容,别离是前一项的长度(prevlen)、以后项长度信息的编码后果(encoding),以及以后项的理论数据(data)。
当咱们往 ziplist 中插入数据时,ziplist 会依据数据是字符串还是整数,以及它们的大小进行不同的编码,这种依据数据大小进行相应编码的设计思维,正是 Redis 为了节俭内存而采纳的。
unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
...
/* Write the entry */
p += zipStorePrevEntryLength(p,prevlen);
p += zipStoreEntryEncoding(p,encoding,slen);
if (ZIP_IS_STR(encoding)) {memcpy(p,s,slen);
} else {zipSaveInteger(p,value,encoding);
}
ZIPLIST_INCR_LENGTH(zl,1);
return zl;
}
此处源码在下文中还会提及,为了讲述不便,这里标识为【源码 A 处】。
此外,每个列表项 entry 中都会记录前一项的长度,因为每个列表项的长度不一样,Redis 会依据数据长度抉择不同大小的字节来记录 prevlen。
3.1 应用不同大小的字节来记录 prevlen
举个例子,假如咱们对立应用 4 字节记录 prevlen,如果前一个列表项只是一个 5 字节的字符串“redis”,那咱们用 1 个字节(8 bits)就能示意 0~256 字节长度的字符串了。此时,prevlen 用 4 字节记录,就有 3 字节节约掉了。
上面咱们就来看看 Redis 是如何依据数据长度抉择不同大小的字节来记录 prevlen 的。
通过下面的 __ziplistInsert
函数即【源码 A 处】能够看到,ziplist 在对 prevlen 编码时,会先调用 zipStorePrevEntryLength
函数,该函数代码如下所示:
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) {
// 如果小于 254 字节,那么返回 prevlen 为 1 字节
p[0] = len;
return 1;
} else {
// 否则,调用 zipStorePrevEntryLengthLarge 进行编码
return zipStorePrevEntryLengthLarge(p,len);
}
}
}
能够看到,zipStorePrevEntryLength
函数会判断前一个列表项是否小于 ZIP_BIG_PREVLEN(ZIP_BIG_PREVLEN 的值是 254)。如果是的话,那么 prevlen 就应用 1 字节示意;否则,zipStorePrevEntryLength
函数就调用 zipStorePrevEntryLengthLarge
函数进一步编码。
zipStorePrevEntryLengthLarge
函数会先将 prevlen 的第 1 字节设置为 254,而后应用内存拷贝函数 memcpy,将前一个列表项的长度值拷贝至 prevlen 的第 2 至第 5 字节。最初,zipStorePrevEntryLengthLarge
函数返回 prevlen 的大小,为 5 字节。
int zipStorePrevEntryLengthLarge(unsigned char *p, unsigned int len) {
uint32_t u32;
if (p != NULL) {
// 将 prevlen 的第 1 字节设置为 ZIP_BIG_PREVLEN,即 254
p[0] = ZIP_BIG_PREVLEN;
u32 = len;
// 将前一个列表项的长度值拷贝至 prevlen 的第 2 至第 5 字节,其中 sizeof(u32) 的值为 4
memcpy(p+1,&u32,sizeof(u32));
...
}
// 返回 prevlen 的大小,为 5 字节
return 1 + sizeof(uint32_t);
}
好了,在理解了 prevlen 应用 1 字节和 5 字节两种编码方式后,咱们再来学习下 encoding 的编码方法。
3.2 应用不同大小的字节来记录 encoding 编码
咱们回到下面的 __ziplistInsert
函数即【源码 A 处】,能够看到执行完 zipStorePrevEntryLength
函数逻辑后,紧接着会调用 zipStoreEntryEncoding
函数。
ziplist 在 zipStoreEntryEncoding
函数中,针对整数和字符串,就别离应用了不同字节长度的编码后果。
unsigned int zipStoreEntryEncoding(unsigned char *p, unsigned char encoding, unsigned int rawlen) {
// 默认编码后果是 1 字节
unsigned char len = 1;
// 如果是字符串数据
if (ZIP_IS_STR(encoding)) {
// 如果字符串长度小于等于 63 字节(16 进制为 0x3f)if (rawlen <= 0x3f) {
// 默认编码后果是 1 字节
if (!p) return len;
...
}
// 字符串长度小于等于 16383 字节(16 进制为 0x3fff)else if (rawlen <= 0x3fff) {
// 编码后果是 2 字节
len += 1;
if (!p) return len;
...
}
// 字符串长度大于 16383 字节
else {
// 编码后果是 5 字节
len += 4;
if (!p) return len;
...
}
} else {
/* 如果数据是整数,编码后果是 1 字节 */
if (!p) return len;
...
}
}
能够看到当数据是不同长度字符串或是整数时,编码后果的长度 len 大小不同。
总之,针对不同长度的数据,应用不同字节大小的元数据信息 prevlen 和 encoding 来记录,这种办法能够无效地节俭内存开销。
封面
参考资料
- 极客工夫《Redis 源码分析与实战》
- redis 源码 v6.2.4:https://github.com/redis/redi…
相干文章
兴许你对上面文章也感兴趣。
- Redis 高可用之哨兵机制实现细节
- Redis 高可用全景一览
- 海量数据下,如何统计用户的签到信息?