阅读 470

Redis源码解析-SDS简单动态字符串

前言


SDS是Redis基础数据结构之一,不同于C字符串(以/0结尾,无法保证二进制安全),SDS为Redis定义的一种抽象类型,该类型有诸多优点如

  • 获取字符串长度时间复杂度O(1)
  • 高效的扩容机制,还能杜绝缓冲区溢出
  • 惰性释放空间,减少内存重分配次数以提高性能
  • 二进制安全
  • 兼容部分C字符串函数

正文


1. 获取源码

自行官网获取,SDS字符串源码主要在sds.c中如下图

2. SDS数据结构

以下代码是数据结构的定义,可以看到字符串有五种定义。只有第一种结构不一样,下面四种结构类似

//sds数据结构
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低三位表示类型,高5位表示字符串长度*/
    char buf[];	//用于存放字符串的数组,由malloc函数动态分配
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; //记录buf已使用长度
    uint8_t alloc; //记录buf总长度
    unsigned char flags; /* 低三位表示类型, 高五位没被使用 */
    char buf[];	//用于存放字符串的数组,由malloc函数动态分配
};
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[];
};
复制代码

以上参数,其中len,alloc,flags都是字符串的描述信息,可以看作是头信息。真正存储字符串的是buf[]数组,这个数组是由malloc函数动态分配的

  • 由于结构定义了len,因此可以在常数级时间复杂度获取字符串长度,而且通过len而不是/0来判断字符串是否结束,保证了二进制安全

之所以要定义多种数据结构,是考虑到内存浪费问题,当我们存储字符串时候,无论是key还是value,都会以SDS字符串的形式分配。如果我们分配一个很小的字符串,却占用了很大的头部,就不合适了

那么问题来了,如何区分一个字符串该用哪种数据类型?

答案是flags,它是用来区分不同的数据类型的,flags位char类型占1字节,但是一共有五种数据类型,因此三位能表示8种足够了,所以就用低三位来表示类型。在第一种数据结构中,flags的高五位也用来表示字符串长度

下面之分析第一,二种数据结构的不同之处。三,四,五结构同二

sdshdr5

对于第一种结构如下图,sdshdr5类型的数据结构,直接用flags的高五位来表示数据

sdshdr8

对于第二种数据结构。首先说明一下uint8_t是SDS封装的抽象数据类型,其中的8代表8位,因此uint8_t len;这么声明代表变量len占8位也就是1字节。sdshdr16,sdshdr32,sdshdr64和8一样,区别就是len和alloc前面的抽象数据类型不同,也就是占用空间不一样

对于数据结构的定义,我们注意到都有一个关键字__attribute__ ((packed)) ,改关键字供GCC使用,作用是使结构按1字节对齐。不加该关键字通常是按照变量的最小公倍数字节对齐。

下面用代码演示packed关键字效果

#include <stdio.h>
#include <string.h>

int main(){
      struct __attribute__ ((__packed__)) sdshdr32{
          long len;
          long alloc;
          unsigned char flags;
          char buf[];
      };
 
 	  	struct sdshdr32 s;   
    	printf("sizeof sds32 is %lu", sizeof(s));
      return 1;
}
复制代码

以上代码模拟SDS32抽象类型,long类型占8字节(我这里是Mac OS,类unix系统long占8字节,如果是windows,long占四字节,这里会四字节对齐),char占1字节。下面看不加packet和加packet关键字的执行结果。可以看到没有加packed,总共占了24位。有packed修饰占了17字节


3. 创建字符串

以下是创建字符串的代码,说明一下参数

  • *init:指向字符串的指针,该参数如果传"SDS_NOINIT",则不进行初始化sh指针指向的空间,如果传NULL,则将sh指针指向的空间初始化为0
  • initlen:字符串长度
sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;		
    sds s;
    char type = sdsReqType(initlen);	//根据字符串长度选择类型
    
   	if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);	//计算不同头部需要的长度
    unsigned char *fp; /* flags pointer. */

  	//sh指针指向的整个结构,使用malloc分配内存,+1是因为'/0'结束符
    sh = s_malloc(hdrlen+initlen+1);
  
  	//这段逻辑就是我上面对init的参数说明,其中SDS_NOINIT是宏定义,内容是"SDS_NOINIT"
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
      	//该函数会将sh指向的空间初始化为0
        memset(sh, 0, hdrlen+initlen+1);
    if (sh == NULL) return NULL;
  
    //s指针是指向buf的指针,这里通过sh指针进行偏移头部的长度计算指针指向的位置
    s = (char*)sh+hdrlen;
  
    //通过s指针偏移1获得flags指针,因为内存单位是字节
    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: {
            ...
        }
        case SDS_TYPE_32: {
            ...
        }
        case SDS_TYPE_64: {
          	...
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';//这里注意最终返回的是s指针,指向buf的,因此可以兼容部分c函数
    return s;
}
复制代码

以上就是创建一个字符串的过程,需要注意的点主要有:

  • sh指向的是SDS结构,而真正返回的s指针是指向buf的
  • 创建字符串会根据字符串长度选用不同的结构
  • 计算结构体成员的指针地址往往是通过偏移量

上面还有个有意思的函数,计算头部的函数,源代码如下。了解网络掩码(netmask)原理的同学肯定能很快明白,我们可以通过IP与网络掩码进行逻辑与运算,从而计算出该IP所在的子网。SDS计算头部类型用了通用的方式,其中宏定义SDS掩码为7,也就是二进制111,对应低三位类型。而SDS_TYPE_5-64在宏定义分别是0,1,2,3,4,同样是进行逻辑与运算得出其对应的类型

#define SDS_TYPE_MASK 7
static inline int sdsHdrSize(char type) {
    switch(type&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return sizeof(struct sdshdr5);
        case SDS_TYPE_8:
            return sizeof(struct sdshdr8);
        case SDS_TYPE_16:
            return sizeof(struct sdshdr16);
        case SDS_TYPE_32:
            return sizeof(struct sdshdr32);
        case SDS_TYPE_64:
            return sizeof(struct sdshdr64);
    }
    return 0;
}
复制代码

4. 内存释放

内存释放的两个方法

//通过偏移s指针定位到首部,直接调用free释放内存
void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}

//将len设置为0,旧值还在内存,但是数据可以覆写,不需要重新分配内存
void sdsclear(sds s) {
    sdssetlen(s, 0);
    s[0] = '\0';
}
复制代码

5. SDS扩容策略

当字符串长度有改动的时候,如何防止缓冲区溢出?,由如何保证高性能?,这就是SDS扩容策略解决的问题

使用到扩容策略的函数由很多,这里拿一个拼接字符串函数说明

首先该函数s为原有字符串结构。t指针指向第二个字符串,len为其长度。它的作用是

  • 计算当前字符串长度
  • 通过新字符串长度,调用sdsMakeRoomFor,来得到扩容后的s指针
  • 进行字符串拼接,这里是二进制安全的,因为这里的curlen长度是不包含"/0"的
  • 最后返回s指针
sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);

    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);
    sdssetlen(s, curlen+len);
    s[curlen+len] = '\0';
    return s;
}
复制代码

其中扩容策略是sdsMakeRoomFor函数实现的,源码如下

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为宏定义为1M大小
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen); //根据长度计算需要的类型

    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {		//如果类型不变直接s_realloc扩容即可
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        //如果类型变了,需要malloc重新分配内存
        newsh = s_malloc(hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);	//将buf内容移动到新的位置
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;//为flags赋值
        sdssetlen(s, len);	//为len赋值
    }
    sdssetalloc(s, newlen);//为alloc赋值
    return s;
}
复制代码

以上代码表明了扩容机制的几种情况

  • 当剩余空间足够,无需扩容
  • 当新的长度大于剩余空间,如果新的长度小于1M则容量翻倍,大于1M则加1M
  • 根据新的长度计算类型,类型不变则通过recalloc扩容即可,类型变的话需要malloc重新分配内存

这种扩容机制明显的优点是,减少修改字符串时内存的重分配次数,这对提高性能由很大帮助

总结


分析了SDS源码,就能明白Redis为何如此高效。总的来说,SDS动态字符串对细节把控的很好。例如数据结构的定义,以及编译器层面的优化,选择数据结构策略,都极大的节省了内存空间。而内存的分配,释放,以及扩容,考虑了各种情况