1、认识redis的string

认识redis的string

Redis 并没有直接用 C 语言的字符串,而是自己搞了一个 sds 的结构体来表示字符串,这个 sds 的全称是 Simple Dynamic String,翻译过来就是“简单的动态字符串”。

sds的5种结构

redis的string使用了5种结构体表示, 它们分别如下

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
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 __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[];
};

在这里插入图片描述

5 个 sds 分别用来存储不同长度的字符串

len: 记录char数组实际使用了多少个字节(不包括 \0 终止符)

alloc: 记录char数组已分配的字符串存储空间大小(但不包括 SDS 头部和结尾的 \0)

buf: 存储字符串

flags: 主要用于 存储字符串类型信息,帮助 Redis 区分不同的 SDS 结构体,从而节省内存和提高操作效率。

  • sdshdr8 里面的 len、alloc 字段都是 uint8_t 这个类型,在很多语言中,例如 Java, int 就是 32 位,而 C 语言里面有多种长度的 int 值,uint8_t 就是占 8 位的无符号 int 值,能表示的最大值就是 2^8-1(255 字节),那它的 buf 数组,最大长度就是 2^8 -1。也就是说,sdshdr8 能表示长度在 2^8-1 这个范围内的字符串,再长的话,buf 数组就存不下了。
  • sdshdr16 里面的 len 和 alloc 字段都是 uint16_t 类型,也就是占 16 位的无符号 int 值,能表示的最大值就是 2^16-1(65,535 字节)。也就是说,sdshdr16 能表示长度在 2^16-1 这个范围内的字符串,再长的话,buf 数组也就存不下了
  • sdshdr32, 最大存储为2^32-1, 为4,294,967,295 字节=4,294.967 MB=4GB(32位cpu的寻址范围)
  • sdshdr64, 最大存储为2^64-1, 为18,446,744,073,709,551,615 字节=16 PB(64位cpu的寻址范围)

取消内存对齐

__attribute__ ((__packed__)): 告诉编译器取消结构体的内存对齐(padding),以最小的内存占用存储数据; 在默认情况下,编译器会为了优化访问速度,自动在结构体成员之间插入 填充字节(padding),确保结构体成员对齐到合适的地址(如 2 字节、4 字节或 8 字节对齐)

关于内存对齐, 这里简单介绍一下。

这里我们先写一个 sdemo 结构体,这里 typedef struct 是另一种定义结构体的方式,其实和前面直接用 struct 定义结构体一样的效果,具体如下:

typedef struct {
    char c1; // 1字节
    short s; // 2字节
    char c2; // 1字节
    int i;   // 4字节
} sdemo;

可以看到,这个结构体里面有四个字段,分别是:c1,这是个 char 类型的字段,占一个字节,这个地方和 Java 不太一样,Java 里面一个 char 类型的字段,占用 2 个字节;然后是一个 short 类型的 s 字段,占两个字节;接下来又是一个 char 类型的 c2 字段;最后是一个 int 类型的 i,占四个字节。

按照我们正常的想法,c1、s、c2 还有 i 这些字段都是紧凑地排列在内存里面的,就跟下面这张图一样:
在这里插入图片描述

下面我们就写一个示例,输出一下在32位操作系统中 sdemo 里面各个字段的地址

void testsdemo() {
    sdemo a; // 创建一个sdemo实例
    printf("%p\n", &a);
    printf("%p\n", &a.c1);
    printf("%p\n", &a.s);
    printf("%p\n", &a.c2);
    printf("%p\n", &a.i);
}

void main() {
    testsdemo();
}
// 输出
0x7ffee382b890
0x7ffee382b890
0x7ffee382b892
0x7ffee382b894
0x7ffee382b898

我们会发现,sdemo 里面各个字节的排布是下图这样的:
在这里插入图片描述

编译器会在 c1 之后填充一个字符,在 c2 后面填充三个字符,这样的话,就四字节对齐了。

这里说明一下, 32位操作系统的内存一般也是32位的, 也就是4个字节, 每次内存io获取4个字节。内存颗粒(chip)的bank组有4个bank, 每次默认从每个bank中拿1个字节, 也就是4个字节。而short s占两个字节, 存放short s的起始地址必须是2的整数倍, 所以这里s不会挨着c1, 需要存放在0x7ffee382b892的位置, 被2整除。

那为什么要进行内存对齐呢?嗯,这主要跟具体的平台有关系,比如说我的机器每次读内存的时候,都是从四字节的位置开始读,每次读取四个字节,这样的话,我读取一个 int 的时候,就希望它的起始位置在四字节倍数的位置,这样读取一次就可以完成一个 int 的读取。那如果我的 int 首地址放到了一个奇数的地址上,就像这样:
在这里插入图片描述

这种情况就会导致我读两次内存后,才能读出来一个完整的 int 值。

一般情况下,我们是不需要关心内存对齐的事情的,因为编译器在编译代码的时候,会直接根据机器的这个平台完成代码对齐的这些操作。但是,有的场景里面,我们不能进行内存对齐,例如, sds 中需要使用指针前后移动的方式,获取结构体中指定的字段值。 这个时候,我们就可以在结构体前面加上__attribute__ ((__packed__)) 指令。

来写个例子试一下,我在 sdemo 结构体前面加上这个指令,让它不进行内存对齐:

typedef struct __attribute__ ((__packed__)) {
    char c1; // 1字节
    short s; // 2字节
    char c2; // 1字节
    int i;   // 4字节
} sdemo;

我们依旧执行前面的 testsdemo() 方法,得到输出是这样的:

0x7ffeea539898
0x7ffeea539898
0x7ffeea539899
0x7ffeea53989b
0x7ffeea53989c

那在内存里面的结构就是这样的,内存是非常紧凑的,没有任何填充
在这里插入图片描述

所以说,__attribute__ ((__packed__)) 的主要目的就是不进行内存填充,这样,sds 就可以安全地用指针前后移动的方式,获取到指定字段值,而不用担心指针前后移动的过程中,碰到填充的空白字节。在介绍 flags 字段的时候,我们就会看到 sds 是如何通过指针移动来确定自身类型的,你也可以在阅读下面这部分内容时,仔细体会一下 __attribute__ ((__packed__)) 的作用。

sds 中的 flags

既然内存对齐能够帮助我们更快地读取数据,那为什么 Redis 不进行内存对齐呢?这个就跟我们 sds 里面的结构有关了。

我们来关注一下 flags 这个字段。

它是个 8 位的 char 类型,其实里面只用了低 3 位来保存字符串的类型,0、1、2、3、4 分别对应了 sdshdr5 到 64 这五个 sdshdr 结构体。小伙伴们可能会问,为啥要用个 flags 来标识类型呢?不是已经分了五个 sds 类型吗,直接通过结构体的类型区分不就好了?

这是因为 Redis 在 5 个 sds 结构体上层,又封了一层,在 sds.h 里面,我们可以看到一行 typedef 代码,typedef 是 C 语言里面用来定义别名的,这里给 char 指针起了个别名,别名叫作 sds。

typedef char* sds; 

大名鼎鼎的 sds 居然只是一个 char 指针,那这个 char 指针指向哪里呢?其实指向的就是我们前面介绍的 5 种 sdshdr 中的一种 。比如我用 sdshdr8 来存储一个字符串,那 sds 指向的就是 sdshdr8 实例里面,buf 数组的起始位置,就是这种结构:
在这里插入图片描述

Redis 使用字符串的时候,都是使用的 sds 这个指针。Redis 只需要从 sds 指针往前找一个字节,就可以拿到这个 flags 值,通过读这个 flags 的低三位值,Redis 就可以知道当前的这个 sds 实例,是 5 种类型的哪一种。比如,图里面的 flags 低三位是 1,那就是 sdshdr8,这样也就确定了 len 和 alloc 的具体长度都是 8 位。接下来 Redis 就可以继续往前读数据,拿到 len 和 alloc 值。根据这两个值,我们就可以从 sds 指针往后的位置,读写 buf 数组了。

说明白了 flags 字段的作用,其实我们也就明白了,为什么 Redis sds 不进行内存对齐了,对吧?因为 sds 这个指针要向前读取 flags、len、alloc 这些值,要是读到空白字符,就跪了。必须要让这些字段紧凑连在一起,才能实现刚才说的这种效果

sdshdr5 真的没用吗?

最后来看一个前面留下的坑,前面并没有提到 sdshrd5 这个结构体。小伙伴们可以看一下 Redis 源码对 sdshdr5 结构体的注释里面,其中有这样一句话 Note: sdshdr5 is never used,翻译过来是说 “sdshdr5 没有被使用”,真的是这样吗?

先说结论:并不是没用!Redis 里面 Key 都是字符串,Key 小于 32 个字节的时候,会用 sdshdr5;value 的话,即使小于 32 字节,也会用 sdshdr8。

这主要是因为我们的 Key 是不变的,而 Value 值呢,可能会经常变化,sdshdr5 可能很快就发生扩容了

这里还有一个要说明的地方。我们来看 sdshdr5 这个结构体的代码,它为了节省空间,并没有再单独搞个 len 字段,而是用了 flags 字段的高 5 位来存了 len 字段,也就是字符串的使用长度。它里面也没有再搞个 alloc 字段出来,总之,就是为了省内存

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; // 低3位存sdshdr的类型,高5位存储len信息
    char buf[];
};

Redis 为什么自定义字符串实现?

前面介绍了string的结构类型, 那么Redis 为什么不用 C 语言的字符串,而非要自己搞一个出来呢?

第一个原因是“安全的二进制存储”,在有的场景里面,我们在字符串里可能需要存 \0 这种特殊字符

比如说,Hello \0 World! \0 这种数据,如下:
在这里插入图片描述

如果直接用 C 语言字符串的话,\0 表示字符串结尾,那我就会认为是到 Hello 字符串就完了,对不对?为了要存 \0 这种特殊字符,sds 就不再把 \0 当作字符串的结尾,而是明确地记录字符串的长度,比如说存个 length 字段,我就知道到第一个 \0 的时候,字符串还没结束。这样的话,我们就可以在字符串里面存储 \0 这种特殊字符了,我们也把这种能存储特殊字符的方式叫作 “安全的二进制存储”。

第二个原因是减少 CPU 的消耗,这是明确存储字符串长度的另一个好处

C 语言字符串是个简单的 char 数组,没有 length 之类的属性来记录字符串长度,那我们要获得一个字符串长度的时候,就要从头开始一个一个字符地遍历,直到遇到 \0 这种结束符。那每次拿 length 都遍历一遍,会非常消耗 CPU,所以 sds 记录字符串长度呢,就省下了这部分 CPU。

第三个原因,就是字符串的扩缩容问题

如果用 C 字符串的话,char 数组的长度需要在创建字符串的时候,就确定下来。如果说我需要在这个字符串后边追加数据,就类似于 Java 里面的字符串相加操作,我们就需要重新申请这个 char 数组的空间,把相加之后的字符串拷贝进去,然后把原来字符串空间释放掉,这就会比较消耗资源。

Redis sds 呢,会预先多申请一部分空间预留,比如说我创建了一个长度为 50 的字符串,sds 实际上是申请了 100 个字符的空间,这样的话我后面有新的字符加进来的时候,就可以不用再进行扩容了。

在缩容的场景里面也是类似的。把一个原生的 C 字符串变短的话,需要立刻进行内存拷贝;要是用 Redis sds 的话,直接修改里面的 len 字段就行,不用进行任何内存拷贝,是不是很 nice!

例如我们添加添加一个字符串"abcd"到redis中, 它的实际内存布局是'a' 'b' 'c' 'd' '\0'
在这里插入图片描述

小结一下

  1. redis使用5中sds结构体自己实现了string类型, 为了减少内存以及方便结构体读取数据, 取消了内存对齐
  2. sds可以根据实际存储的数据动态地调整长度,而不需要像 C 字符串那样预先分配固定长度的空间。这使得 SDS 更加灵活,能够有效地利用内存
  3. 缓冲区预分配: SDS 在创建时就会为字符串分配额外的空间,预分配的大小通常是字符串的长度加上一定的冗余空间。这种方式可以降低字符串长度变化时的频繁内存重新分配的开销。
  4. 二进制安全: SDS 不以空字符 \0 结尾,因此可以存储二进制数据而不受限于空字符的特殊含义。这使得 Redis 的字符串可以存储任意二进制数据,而不仅仅是文本
  5. O(1) 复杂度的长度获取: SDS 记录了字符串的长度,因此获取字符串长度的操作是 O(1) 复杂度的,而不需要像 C 字符串那样遍历整个字符串。
  6. 减少缓冲区溢出风险: SDS 在缓冲区的末尾预留了空间,可以防止由于字符串的追加操作导致缓冲区溢出。

扩容

情况1: 不扩容

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // 计算剩余空间,点进去看看,就是alloc-len
    size_t avail = sdsavail(s);
    // 分支一:检查sds剩余可用空间要是足够的话,就不用扩容了
    if (avail >= addlen) return s;
    // ...
}

sds剩余空间够用, 不用扩容, 直接将内容追加到buf末尾。

例如创建sds的时候 len为50, alloc为100, 再追加50以内的内容, 都不会引起扩容

情况2:翻倍扩容

sds sdsMakeRoomFor(sds s, size_t addlen) {
    // ....
	len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    reqlen = newlen = (len+addlen);
    // 添加内容之后的字符串长度和原有分配空间比较
    assert(newlen > len);
    // 小于1M, 翻倍扩容
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        // 大于1M, 每次扩容增加1M
        newlen += SDS_MAX_PREALLOC;
    // ...
}

当分配的内存不足以存放添加的字符串时, 并且新的字符串长度小于1M, 那么直接扩容到原来的2倍

情况3:每次加1M

当分配的内存已经大于了1M, 那么再追加字符串时, 分配的内存每次增加1M(上面的else代码)

要是扩容前后,sds 的类型没变,比如说始终都是 sdshdr8 的话,就没必要拷贝 sds 里面原有的字符串,直接走 realloc,扩 buf 的长度就行了,比如下面这张图
在这里插入图片描述

要是 sds 的类型发生了变化,比如从 sdshdr8 变成了 sdshdr16,就如下图一样,那 len、alloc 的长度就变了,buf 里面的字符就向后移动。这时候呢,就要走 malloc 新分配一个 sds 实例,然后把原来 sds 里面的数据拷贝过去,然后再追加新字符串
在这里插入图片描述

缩容

Redis sds 在缩容的时候,是直接修改 len 值,比如说,sdsclear() 函数,它就是直接把 sds 的 len 字段设置成 0。那你可能会问:这种字符串多了,buf 的空间不回收,岂不是内存泄露了吗?

1、如果没有剩余空间了, 不进行缩容

sds sdsRemoveFreeSpace(sds s) {
	// ...
    size_t len = sdslen(s);
    size_t avail = sdsavail(s);
    sh = (char*)s-oldhdrlen; // 前移指针,拿到当前sdshdr首地址

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

2、要是 sds 类型没发生变化,或者是从一个大 sds 缩容到另一个大 sds (长度大于 sdshdr8 的都属于大 sds),就没必要更新 sds 类型,就直接用 realloc() 这个方法,缩一下 buf 的长度就行了

sds sdsRemoveFreeSpace(sds s) {
    // ...
    // sds类型不变或者缩容到另一个大的sds
    if (oldtype==type || type > SDS_TYPE_8) {
        // 缩buf
        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);
        // 新的sds
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        // 设置新sds的长度
        sdssetlen(s, len);
    }
    // ...
}

3、如果sds类型缩到小于当前的类型, 比如缩到了 sdshdr8,这个时候就要通过 malloc 新申请一块内存区域,然后拷贝 buf 里面的数据,这个时候 buf 比较小,拷贝的代价不大,而且 sdshdr 类型的变化,会缩小几个字节,可以提高整个 sdshdr 的有效负载(上面的else代码)

搜索个人公众号: 行云代码

参考文章

说透 Redis 7

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

uncleqiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值