baiyanredis
所有视频:https://segmentfault.com/a/11...数据库
- 今天咱们正式进入redis5源码的学习。redis是一个由C语言编写、基于内存、单进程、可持久化的Key-Value型数据库,解决了磁盘存取速度慢的问题,大幅提高了数据访问速度,因此它经常被用做缓存。
- 那么为何redis会如此之快呢?让咱们首先从内部存储的数据结构的角度,一步一步揭开它神秘的面纱。
- 在redis的set、get等经常使用命令中,最尝试用的就是字符串类型。在redis中,存储字符串的数据类型,叫作简单动态字符串(Simple Dynamic String),即SDS,它在redis中是如何实现的呢?
引入
- 回顾咱们以前在PHP7源码分析中讲到的zend_string结构:
struct _zend_string {
zend_refcounted_h gc; /*引用计数,与垃圾回收相关,暂不展开*/
zend_ulong h; /* 冗余的hash值,计算数组key的哈希值时避免重复计算*/
size_t len; /* 存长度 */
char val[1]; /* 柔性数组,真正存放字符串值 */
};
SDS新老结构的对比
- 在redis3.2.x以前,SDS的存储结构以下:
struct sdshdr {
int len; //存长度
int free; //存字符串内容的柔性数组的剩余空间
char buf[]; //柔性数组,真正存放字符串值
};
- 以“Redis”字符串为例,咱们看一下它在旧版SDS结构中是如何存储的:

- free字段为0,表明buf字段没有剩余存储空间
- len字段为5,表明字符串长度为5
- buf字段存储真正的字符串内容“Redis”
- 存储字符串内容的柔性数组占用内存大小为6字节,其他字段所占用8个字节(4+4+6 = 14字节)
- 在新版本redis5中,为了进一步减小字符串存储过程当中的内存占用,划分了5种适应不一样字符串长度专用的存储结构:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; //低三位存储类型,高5位存储字符串长度,这种字符串存储类型不多使用
char buf[]; //存储字符串内容的柔性数组
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; //字符串长度
uint8_t alloc; //已分配的总空间
unsigned char flags; //标识是哪一种存储类型
char buf[]; //存储字符串内容的柔性数组
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; //字符串长度
uint16_t alloc; //已分配的总空间
unsigned char flags; //标识是哪一种存储类型
char buf[]; //存储字符串内容的柔性数组
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; //字符串长度
uint32_t alloc; //已分配的总空间
unsigned char flags; //标识是哪一种存储类型
char buf[]; //存储字符串内容的柔性数组
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; //字符串长度
uint64_t alloc; //已分配的总空间
unsigned char flags; //标识是哪一种存储类型
char buf[]; //存储字符串内容的柔性数组
};
- 咱们能够看到,SDS的存储结构由一种变成了五种,他们之间的不一样就在于存储字符串长度的len字段和存储已分配字节数的alloc字段的类型,分别占用了一、二、四、8字节(不考虑sdshdr5类型),这决定了这种结构可以最大存储多长的字符串(2^8/2^16/2^32/2^64)。
- 咱们注意,这些结构体中都带有__attribute__ ((__packed__))关键字,它告诉编译器不进行结构体的内存对齐。这个关键字咱们下文会详细讲解。关于结构体内存对齐是什么,请参考【PHP7源码学习】2019-03-08 PHP内存管理2笔记。
利用gdb查看SDS的存储结构
- 接着说咱们以前存储“Redis”的例子,咱们须要先对其进行gdb,观察"Redis”字符串使用了哪一种结构,gdb的步骤以下:
- 首先到官网下载源码包,编译
- 启动一个终端,进入redis源码的src目录下,后台启动一个redis-server:
./redis-server &
ps -aux |grep redis
- 记录下这个pid,而后利用gdb -p命令调试该端口(如端口号是11430):
gdb -p 11430
- 接着在setCommand函数处打一个断点,这个函数用来执行set命令,而后使用c命令执行到断点处:
(gdb) b setCommand
(gdb) c
- 有了redis服务端,咱们还要启动一个redis客户端,接下来启动另外一个终端(一样在src目录下),启动客户端:
./redis-cli
- 接着咱们在redis客户端中执行set命令,咱们设置了一个key为Redis,值为1的key-value对:
127.0.0.1:6379> set Redis 1
- 返回咱们以前终端中的服务端,咱们发现它停在了setCommand处:

- 接着一直n下去,直到setGenericCommand函数,s进去,就能够看到咱们的key “Redis”了,它是一个rObj结构(咱们暂时不看),里面的ptr就指向字符串结构的buf字段,咱们强转一下,可以看到字符串内容“Redis”。

- 咱们知道,不管是这五种结构中的哪种,其前一位必定是flag字段,咱们打印它的值,它的值为1。那么1是什么含义呢,它被用来标识是这五种字符串结构中的哪种:
#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
- 它的值为1,表明是sdshdr8类型,咱们能够画出当前字符串的存储结构图:

- 咱们能够看到,它总共占用3+6 = 9字节,比以前的14字节节省了5字节。经过对以前长度和alloc字段的细化(由以前的int转为int八、int1六、int3二、int64),这样一来,就会大大节省redis存储字符串所占用的内存空间。内存空间是很是宝贵的,并且redis中最经常使用的数据类型就是字符串类型。虽然看起来节省的空间不多,但因为它很是经常使用,因此这样作的好处是无穷大的。
关键字__attribute__ ((packed))的做用
- 该关键字用来告知编译器不须要进行结构体的内存对齐。
- 为了测试__attribute__ ((packed))关键字在redis字符串结构中的做用,咱们写以下一段测试代码:
#include "stdio.h"
int main(){
struct __attribute__ ((__packed__)) sdshdr64{
long long len;
long long alloc;
unsigned char flags;
char buf[];
};
struct sdshdr64 s;
s.len = 1;
s.alloc = 2;
printf("sizeof sds64 is %d", sizeof(s));
return 1;
}
- 咱们定义一个结构体,其字段和redis中的字符串结构基本一致。若是加上__attribute__ ((__packed__)) ,应该不是内存对齐的。若是去掉它,就应该是内存对齐的,会比前一种状况更加浪费内存,因此会对齐会节省内存。咱们如今猜测的内存结构图应该以下所示:

- 咱们首先验证加上__attribute__ ((__packed__)) 的状况,咱们预期应该是不对齐的,在gdb中内存地址以下:

- 咱们看到,buf确实是从0x171地址处开始的,并无对齐。那么咱们看另外一种状况,去掉__attribute__ ((__packed__)),再进行gdb调试:

- 你们看这张图,是否是和上一张图一摸同样(我真的去掉了而且从新编译了!!!)。这说明在当前状况下,redis字符串结构中的柔性数组的起始位置并不受是否加__attribute__ ((__packed__))关键字而影响,是紧跟在结构体后面的,因此节省内存这个说法并不成立。(不必定是全部状况下柔性数组都紧跟在结构体后面,若是把buf的类型改成int就不是紧跟在后面,你们感兴趣能够本身调试一下)。
- 那么,为何这里要加上__attribute__ ((__packed__)呢?咱们换个思路,既然不能节省空间,那么能不能节省时间呢?会不会操做非对齐的结构体性能更好、效率更高,或者是写代码更方便、可阅读性强呢?
- 笔者在这里的猜测是比较方便工程中的代码编写,可阅读性更强,个人参考以下:
- 在sizeof运算符中,它返回的是结构体占用空间的大小,和是否对齐有很大关系。好比上例中的结构体,若是不加上__attribute__ ((__packed__)),说明须要内存对齐,sizeof(struct s)的返回结果应该为24(8+8+8);若是加上__attribute__ ((__packed__)),说明不须要对齐,返回的结果应该为17(8+8+1),咱们打印一下:

- 结果和咱们预期的一致。咱们知道,在以前咱们gdb的时候,rObj的指针直接指向柔性数组buf的地址,即字符串内容的起始地址。那么如何知道它的len和alloc的值呢?只须要用buf的地址ptr - sizeof(struct s)便可。在这里,若是加上__attribute__ ((__packed__)),它返回的结果是17,那么直接作减法,就能够到结构体开头的位置,便可直接读取len的值。若是不加__attribute__ ((__packed__)),它返回的结果是24,作减法就会的到错误的位置,这就是缘由所在,在源码中咱们也能够看到,它确实是这么找到当前字符串结构体的头部的:
#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))))
- 那么咱们可能会问了,你刚才不是还用buf[-1]也能访问到吗?或者buf[-17],应该也能访问到len吧。这里笔者简单猜测多是上一种写法,在工程的代码实现中,更加易读也更加方便。更加深层的缘由仍待讨论。
为何须要alloc字段
- 在以前的讲解中,咱们一直没有提到alloc字段的做用。咱们知道,它是目前给存储字符串的柔性数组总共分配了多少字节的空间。那么记录这个字段的做用何在呢?那就是空间预分配和惰性空间释放的设计思想了。
- 空间预分配:在须要对 SDS 进行空间扩展的时候, 程序不只会为 SDS 分配修改所必需要的空间, 还会为 SDS 分配额外的未使用空间。举一个例子,咱们将字符串“Redis”扩展到“Redis111”,应用程序并不只仅分配3个字节,仅仅让它刚好知足分配的长度,而是会额外分配一些空间。具体如何分配,见下述代码注释。咱们讲其中一种分配方式,假设它会分配8字节的内存空间。如今总共的内存空间为5+8 = 13,而咱们只用了前8个内存空间,还剩下5个内存空间未使用。那么咱们为何要这样作呢?这是由于若是咱们再继续对它进行扩展,如改为“Redis11111”,在扩展 SDS 空间以前,SDS API 会先检查未使用空间是否足够,若是足够的话,API 就会直接使用未使用空间那么咱们就不用再进行系统调用申请一次空间了,直接把追加的“11”放到以前分配过的空间处便可。这样一来,会大大减小使用内存分配系统调用的次数,提升了性能与效率。空间预分配的代码以下:
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;
/* 若是可用空间大于追加部分的长度,说明当前字符串还有额外的空间,足够容纳扩容后的字符串,不用分配额外空间,直接返回 */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC) //SDS_MAX_PREALLOC = 1MB,若是扩容后的长度小于1MB,直接额外分配扩容后字符串长度*2的空间
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC; //扩容后长度大于等于1MB,额外分配扩容后字符串+1MB的空间
...
真正的去分配空间
...
sdssetalloc(s, newlen);
return s;
}
- 上述sdsavail函数在获取字符串剩余可用空间的时候,就会使用到alloc字段。它记录了分配的总空间大小,方便咱们在进行字符串追加操做的时候,判断是否须要额外分配空间。当前剩余的可用空间大小为alloc - len,即已分配总空间大小alloc - 当前使用的空间大小len
static inline size_t sdsavail(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5: {
return 0;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
return sh->alloc - sh->len;
}
}
return 0;
}
- 惰性空间释放:惰性空间释放用于优化 SDS 的字符串截取或缩短操做。当 SDS 的 API 须要缩短 SDS 保存的字符串时,程序并不当即回收缩短后多出来的字节。这样一来,若是未来要对 SDS 进行增加操做的话,这些未使用空间就可能会派上用场。好比咱们将“Redis111”缩短为“Redis”,而后又改为“Redis111”,这样,若是咱们马上回收缩短后多出来的字节,而后再从新分配内存空间,是很是浪费时间的。若是等待一段时间以后再回收,能够很好地避免了缩短字符串时所需的内存重分配操做, 并为未来可能有的增加操做提供了扩展空间。源码中一个清空字符串的SDS API以下:
/* Modify an sds string in-place to make it empty (zero length).
* However all the existing buffer is not discarded but set as free space
* so that next append operations will not require allocations up to the
* number of bytes previously available. */
void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}
- 咱们重点看上面的注释:可是全部额外分配的空间并不会随着清空字符串而释放,因此下一个对字符串的追加操做并不会再次进行内存分配的系统调用。而源码中也并无直接调用任何函数,对清空操做以后的剩余空间马上进行释放,也验证了咱们以前的猜测。