深入理解sds及初始化、扩容、缩容

0 阅读10分钟

深入理解sds及初始化、扩容、缩容

sds是什么?

 - sds是redis里面自定义的结构体(),有 5 个 sdshdr 结构体(5 个 sds 分别用来存储不同长度的字符串)
 - C语言中int有多种长度的:uint5_t、uint8_t、uint16_t、uint32_t、uint64_t 
                         如uint8_t 就是占 8 位的无符号 int 值,能表示的最大值就是 2^8-1
 -__attribute__ ((__packed__)):被它修饰的实例对象存储不是内存对齐的(没有对齐填充),内部对象是紧密相邻的
 -内存对齐好处-->方便访问实例(个人理解:这个和机器有关,如果机器每次读n个指定字节的数据,实例对象地址都是
 n的倍数。访问时不会出现读取一半而需要二次读取)。
 
 -对齐能方便实例的好处,为啥redis定义的时候指定不内存对齐呢?-->因为对齐填充了,就失去了指针滑动访问对象的能力。
 (指针+或者- 对象对应字节长度的方式 访问 对象-->有可能访问到填充部分)

Redis 里面 Key 都是字符串,Key 小于 32 个字节的时候,会用 sdshdr5;value 的话,即使小于 32 字节
,也会用 sdshdr8(主要是sdshdr5太小了,可能会频繁扩容)。
  • 有 5 个 sdshdr 结构体如下 image.png
  • SDS结构逻辑图如下
sds:是一个指针,指向buff数组的首地址
flags:一个字节,低三位存储此实例 对应sdshdr具体类型
len:字符串存储的实际字节长度(考虑到二进制安全,buff里面存的是字节信息,里没有做字符转换)
alloc:buff数组实际的长度
uint5_t 从上图定义可以知道,是没有len与alloc的,并没有再单独搞个 len 字段,而是用了 flags 字段的高 5 位来存了 
len 字段,也就是字符串的使用长度。它里面也没有再搞个 alloc 字段出来,总之,就是为了省内存。

image.png

注意:放在结构体最后的这个数组,比较特殊,被叫作柔性数组。它是不占内存空间的,只有真正使用的时候,才会开辟内存空间。我们在 sdshdr 的结构体里面看到的 buf 字段,也是柔性数组,这个稍微注意一下就好。

  1. 初始化字符串
  • [1] sdsReqType(initlen):根据字符串长度,选择应该用sdshdr具体类型
  • [2] sdsHdrSize(type):计算除柔性数组外,所有字段占的字节长度
  • [3] trymalloc? s_trymalloc_usable(hdrlen+initlen+1, &usable) : s_malloc_usable(hdrlen+initlen+1, &usable);开辟指定大小内存
  • [4]s = (char*)sh+hdrlen;fp = ((unsigned char*)s)-1;//根据首地址计算buff首地址与flags字段首地址
  • [5]非buff之外属性设置
  • [6] memcpy(s, init, initlen);//buff数值设置初始值

总结:根据initlen选择合适类型-->mollc开辟内存-->计算出sh、s、fp-->非buff之外属性设置-->buff数值设置初始化

sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
    void *sh;
    sds s;
    // 根据initlen这个长度决定使用哪个sdshdr结构体
    // 我们跳进去看看,里面就是找一个能装下initlen的最小sdshdr类型
    char type = sdsReqType(initlen);
    // 如果是sdshdr5,这里直接专转成sdshdr8类型,因为sdshdr5的alloc太小了,
    // 很容易就要扩容,还不如直接使用sdshdr8
    // 那为什么还需要定义sdshdr5呢?这个后面再说
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    // 这里选定sdshdr类型之后,就要开始创建sdshdr的实例了。
    // 先用sizeof算一下sdshdr在内存里面占多少个字节,
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp;
    size_t usable;
    assert(initlen + hdrlen + 1 > initlen);
    // 根据前面计算的sdshdr分配内存,此时sh指针指向了sdshdr实例的第一个字节
    // usable是分配出来的实际空间大小
    sh = trymalloc?
        s_trymalloc_usable(hdrlen+initlen+1, &usable) :
        s_malloc_usable(hdrlen+initlen+1, &usable);
    if (sh == NULL) return NULL;
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    // s指向的是buf的第一个字节
    s = (char*)sh+hdrlen;
    // fp是s向前移动一个字节,也就是指向了sds里面的flags字段
    fp = ((unsigned char*)s)-1;
    usable = usable-hdrlen-1;
    // 如果一次分配的空间太多了,超过了当前用的sdshdr的上限值,就修改一下
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    switch(type) {
        case SDS_TYPE_5: {
            ... 
            break;
        }
        // 如果是用了sdshdr8结构体,就走这个分支
        case SDS_TYPE_8: {
            // s向前移动,指向sdshdr8的首字节
            SDS_HDR_VAR(8,s);
            sh->len = initlen; // 把len设置成initlen
            sh->alloc = usable; // alloc设置成usable
            *fp = type; // 设置flags字段值
            break;
        }
        case SDS_TYPE_16: {
            ... //省略
            break;
        }
        case SDS_TYPE_32: {
            ... //省略
            break;
        }
        case SDS_TYPE_64: {
            ... //省略
            break;
        }
    }
    if (initlen && init) 
        // memcpy()函数是init字符串的内容,拷贝到s往后
        memcpy(s, init, initlen);
    s[initlen] = '\0'; // 字符串的最后添加'\0'
    return s;
}

字符串扩容:sdsMakeRoomFor() -[1]avail = sdsavail(s);计算预留空间-->空间够就不扩容 -[2]if (newlen < SDS_MAX_PREALLOC) newlen *= 2; else newlen += SDS_MAX_PREALLOC;计算出newlen是否小于1M,小于成倍扩容;不小于每次扩容1M -[3] type = sdsReqType(newlen); // 根据扩容之后的长度(包含预留字段),确认sds类型 -[4] oldtype==type,扩容后对象sds类型不变,调用s_realloc_usable(sh, hdrlen+newlen+1, &usable);在原对象buff后面扩容,实例首地址不变,但是len,alloc,buff首地址需要重新设置。 -[5]oldtype!=type-->s_malloc_usable(hdrlen+newlen+1, &usable);新开辟出实例,并将就字符串copy到新的实例里。计算设置,首地址sh、len、alloc、fp、s。释放旧实例

总结:先判断预留空间是否足够-->计算扩容后需要的newlen-->扩容机制,添加预留字段(不足1M*2;超过+1M)-->根据newlen(包含预留字段)选择type-->type不变在原来实例上扩容;改变新建实例,释放旧实例。

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // 计算剩余空间,点进去看看,就是alloc-len
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    // 分支一:检查sds剩余可用空间要是足够的话,就不用扩容了
    if (avail >= addlen) return s;

    // 要是剩余空间不够,就要开始走下面的扩容逻辑了。
    // 扩容之后,sds里面还是要预留一些空闲buf的,我们先根据要追加的字节数,
    // 确认如何扩容:要是扩容之后小于1M,就两倍的扩容,要是超过1M,就每次扩容1M
    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    reqlen = newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    // 下面的逻辑其实和初始化字符串的逻辑类似了
    type = sdsReqType(newlen);     // 根据扩容之后的长度,确认sds类型
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > reqlen);
    if (oldtype==type) {
        // sds类型没变的话,sds里面原有的字符串是不用进行拷贝的,这里直接走realloc,扩大buf的长度就行了
        // 这个newsh返回的还是sds的首地址,其实和sh是一样的
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        // s指向buf的首地址
        s = (char*)newsh+hdrlen;
    } else {
        // 要是sds的类型发生了变化,那len、alloc的长度就变了,这个时候buf里面的字符就前后移动,
        // 这里就要走malloc分配一个新的sds实例,然后把原来buf里面的数据拷贝过去
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        // 拷贝数据buf里面原有的数据
        memcpy((char*)newsh+hdrlen, s, len+1);
        // 释放原sds实例
        s_free(sh);
        // s指向了新sds实例的buf
        s = (char*)newsh+hdrlen;
        s[-1] = type; // 设置新sds实例的flags字段
        sdssetlen(s, len); // 设置新sds实例的len字段
    }
    // 设置新sds实例的alloc或者是扩大原sds的alloc字段
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    sdssetalloc(s, usable);
    return s;
}

缩容:是直接修改 len 值实现的。在缩容过程中需要释放无用内存。sdsRemoveFreeSpace() 函数是释放空间的

-[1]len = sdslen(s);获取当前len,用来计算可用空间。如果无可用空间-->buff存的有用信息,且是满的,无需缩容

-[2]type = sdsReqType(len);//根据len计算type

-[3]if (oldtype==type || type > SDS_TYPE_8)if (oldtype==type || type > SDS_TYPE_8)-->在原实例对象上使用realloc,缩容buf就行.对于 sdshdr8 以上的缩容,不做 sds 类型上面的变更

-[4]如果上述条件不满足,就新建一个实例,会把旧实例释放掉。

sds sdsRemoveFreeSpace(sds s) {
    void *sh, *newsh;
    // 看这里,s本来是个指针吧,C语言里面指针和数组其实是一样的,这里可以用s[-1]来表示指针向前移动
    // 然后,算一下当前sdshdr的类型
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
    // 再算一下当前的len和空闲字节数
    size_t len = sdslen(s);
    size_t avail = sdsavail(s);
    sh = (char*)s-oldhdrlen; // 前移指针,拿到当前sdshdr首地址

    if (avail == 0) return s; // 要是没有剩余空间,就不用缩容buf了

    // 算算要是存储当前len长度的字符串,需要用什么类型的sdshdr
    type = sdsReqType(len);
    hdrlen = sdsHdrSize(type);

    // 要是sdshdr类型没发生变化,或者是大sdshdr之间变来变去,就没必要更新sdshdr,
    // 毕竟有效负载全在buf那里,更新sdshdr节省的那几个字节带来收益微乎其微,
    // 但是付出的代价是要拷贝一边巨大的buf,所以这里使用realloc,缩容buf就行了
    if (oldtype==type || type > SDS_TYPE_8) {
        newsh = s_realloc(sh, oldhdrlen+len+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+oldhdrlen;
    } else {
        // 如果是sdshdr缩到的比较厉害,比如缩到了sdshdr8,这个时候就需要通过malloc
        // 新申请一块内存区域,然后拷贝buf,这个时候buf比较小,拷贝的代价不大,
        // 而且sdshdr缩小的那几个字节,可以提高整个sdshdr的有效负载
        newsh = s_malloc(hdrlen+len+1);
        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);
    }
    sdssetalloc(s, len); // 最后更新一下alloc字段
    return s;
}

  • 这主要是因为 sdshdr8 以上的字符串,有效负载几乎全在 buf 上。比如说,从 sdshdr64 更新成 sdshdr16,len、alloc 各自节省了 6 个字节,总共节省了 12 字节,但是呢,重新申请空间要拷贝的数据量是多少?至少是 2^8 吧,256 个字节,这是 sdshdr16 的存储下限,字节数再少,就要用 sdshdr8 存储了。而且一般 sdshdr16 的字符串长度不会恰好卡在下限,都要比 256 字节多。这样的话,节省空间带来的收益微乎其微,但是付出的代价是要拷贝一遍大 buff

  • 如果是字符串长度缩得比较厉害,比如缩到了 sdshdr8,这个时候就要通过 malloc 新申请一块内存区域,然后拷贝 buf 里面的数据,这个时候 buf 比较小,拷贝的代价不大,而且 sdshdr 类型的变化,会缩小几个字节,可以提高整个 sdshdr 的有效负载