Redis设计与实现读书笔记——第二章

为了做Redis相关实验,在网上粗略看了Redis设计与实现的电子版,感觉收获很多,但是因为是旧版,所以买了第二版,重读第二次。

第二章 简单动态字符串

简介

  1. 字符串值的键值对在底层都是由SDS实现的。
  2. sds的功能:
    1. 存储字符串值
    2. 用作缓冲区
      1. AOF模块缓冲区
      2. 客户端状态的输入缓冲区

2.1 SDS的定义

文件:sds.h/sdshdr 结构体

书中的为3.0版本,4.0版本有较大改动。

version: redis-4.02

参考:https://www.cnblogs.com/chenpingzhao/p/7292182.html

https://www.codesheep.cn/2018/08/09/Redis%E5%AD%97%E7%AC%A6%E4%B8%B2%E7%B1%BB%E5%9E%8B%E5%86%85%E9%83%A8%E7%BC%96%E7%A0%81%E5%89%96%E6%9E%90/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typedef char *sds;  //注意,sds其实不是一个结构体类型,而是被typedef的char*

/* 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[];
};

除了结构体字段对len和alloc的数据类型的不同(unit8, unit16, unit32, unit64), 其字段含义相差无几。其中header记录len, alloc, flags 信息。不同的header的目的是节省内存。header与buf数组在内存地址上前后相邻。

1
2
3
4
5
+--------+-------------------------------+-----------+
| Header | Binary safe C alike string... | Null term |
+--------+-------------------------------+-----------+
|
`-> Pointer returned to the user.
1
2
3
4
len: 记录buf数组中已使用的字节数量 等于保存的字符串的长度 (不算结尾的\0 标识符)
alloc: 字符串最大的容量。(除开header和最后的null终止符)
flags: 总是会占用一个字节 8bit,加上unsigned是因为flags都是非负数 ,其中的最低3个bit用来表示header的类型还有 5个bit没有使用。
buf: 字符数组,用于保存字符串。 柔性数组

buf的大小=alloc+1;

header类型定义中,注意的地方:

  • 在各个header的定义中使用了attribute ((packed)),是为了让编译器以紧凑模式来分配内存,取消字节对齐。

    • 结构体的成员内存是’”连续”的,但是这个连续是以对齐的单位而言的。比如说A成员的内存是3个字节,假设对齐单位是4个字节,会给A成员多分配一个字节。A成员后面才又紧接B成员的内存。
    • 如果没有这个属性,编译器可能会为struct的字段做优化对齐,在其中填充空字节。那样的话,就不能保证header和sds的数据部分紧紧前后相邻,也不能按照固定向低地址方向偏移1个字节的方式来获取flags字段了。
  • 在各个header的定义中最后有一个char buf[]。我们注意到这是一个没有指明长度的字符数组,这是C语言中定义字符数组的一种特殊写法,称为柔性数组flexible array member),只能定义在一个结构体的最后一个字段上。它在这里只是起到一个标记的作用,表示在flags字段后面就是一个字符数组,或者说,它指明了紧跟在flags字段后面的这个字符数组在结构体中的偏移位置。而程序在为header分配的内存的时候,它并不占用内存空间。如果计算sizeof(struct sdshdr16)的值,那么结果是5个字节,其中没有buf字段。

  • sdshdr5与其它几个header结构不同,它不包含alloc字段,而长度使用flags的高5位来存储。因此,它不能为字符串分配空余空间。如果字符串需要动态增长,那么它就必然要重新分配内存才行。所以说,这种类型的sds字符串更适合存储静态的短字符串(长度小于32)。 因为长度的范围是5个bit来存储的

sds字符串的header,其实隐藏在真正的字符串数据的前面(低地址方向)。这样的一个定义,有如下几个好处

  • header和数据相邻,而不用分成两块内存空间来单独分配。这有利于减少内存碎片,提高存储效率(memory efficiency)。
  • 虽然header有多个类型,但sds可以用统一的char *来表达。且它与传统的C语言字符串保持类型兼容。如果一个sds里面存储的是可打印字符串,那么我们可以直接把它传给C函数,比如使用strcmp比较字符串大小,或者使用printf进行打印。

    2.2 SDS与C字符串的区别

c语言使用N+1长度的字符数组来表示长度为N的字符串,因为需要增加一个\0 字符终止

2.2.1 常数复杂度获取字符串长度

因为c语言要知道字符串的长度只能遍历数组,所以复杂度为O(N)。

而获取sds的字符串长度,只需要返回len的值就可以了复杂度为O(1)。这样对一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响。

2.2.2 杜绝缓冲区溢出

C字符串不记录自身长度会带来易造成缓冲区溢出的问题。 比如使用strcat函数拼接两个字符串,被拼接的字符串要是没有提前分配空间,就会造成缓冲区溢出。(溢出的字节会导致这个字符串内存紧邻的其他字符串的内容被修改)

而SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能,SDS的API需要修改SDS时,会先检查空间alloc是否满足修改所需的要求。不满足的话会先将空间扩展至修改所需的大小,再执行修改。

2.2.3 减少修改字符串时带来的内存重分配次数

C语言字符串用N+1个字节长的数组来保存N个字节的字符串,因为这个关联性所以每次每次增长或者缩短一个C字符串,都要对这个字符串进行一次内存重分配操作。

  • 执行增长操作 比如append,需要首先通过内存重分配来扩展底层数组的空间大小,否则产生缓冲区溢出
  • 执行所动操作比如截断操作trime,需要首先通过内存重分配来释放字符串不再使用的空间,否则造成内存泄漏。

内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以通常是一个比较耗时的操作。这对Redis经常用于速度要求严苛,数据被频繁修改的场合来说,是不可接受的。

因此SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:buf的长度可以大于len的长度。 (4.0版本的源码还未找到对应的函数,所以可能和书上说的有变化了)

  1. 空间预分配 ——减少连续执行字符串增长操作所需的内存重分配次数。

    • 用于优化字符串增长操作。

      当需要对SDS的空间进行空间扩展时,不仅会对SDS分配修改所必需的空间,还会额外分配未使用空间。

      当len < 1Mb时 alloc = 2*len; 当len >= 1 mb时 alloc= len +1Mb。

    源码分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    /* Enlarge the free space at the end of the sds string so that the caller
    * is sure that after calling this function can overwrite up to addlen
    * bytes after the end of the string, plus one more byte for nul term.
    *
    * Note: this does not change the *length* of the sds string as returned
    * by sdslen(), but only the free buffer space we have. */
    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;

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen); // 预分配
    if (newlen < SDS_MAX_PREALLOC)
    newlen *= 2;
    else
    newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
    * not able to remember empty space, so sdsMakeRoomFor() must be called
    * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {
    newsh = s_realloc(sh, hdrlen+newlen+1);
    if (newsh == NULL) return NULL;
    s = (char*)newsh+hdrlen;
    } else {
    /* Since the header size changes, need to move the string forward,
    * and can't use realloc */
    newsh = s_malloc(hdrlen+newlen+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, newlen);
    return s;
    }
  1. 惰性空间释放

    • 用于优化SDS的字符串缩短操作

      缩短SDS保存的字符串时,并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性,将这些字节的数量记录起来,并等待将来使用。

2.2.4 二进制安全

C字符串中的字符必须符合某种编码(如ASCII),除了末尾字符串中间不能有\0 这个空字符,否则最先被程序读取的空字符将被认为是结尾,导致C字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制数据。

所谓二进制安全:以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,被读取是就是什么样的。因为SDS使用len来判断字符串是否结束。

所以buf是字节数组,而不是字符数组。

2.2.5 兼容部分C字符串函数

因为遵循C字符串以\0结尾的惯例,所以可以兼容<string.h>/strcasecmp ,<stdio.h>/printf 这些函数。但是是否是书上的使用结构体指针还是博客说的可以直接使用sds来调用?还需验证。

书:printf("%s", sds->buf) sds是指向结构体的指针。

博客:https://blog.csdn.net/yangbodong22011/article/details/78419966 :printf(%s, sds)

源码中是直接使用sds

2.2.6 总结

C字符串 SDS
获取字符串长度复杂度为O(N) 获取字符串长度复杂度为O(1)
API不安全,可能造成缓冲区溢出 API安全,不会造成缓冲区溢出
修改字符串长度N次必然执行N次内存重分配 最多执行N次内存重分配
只能保存文本数据 二进制安全文本与二进制数据皆可
可使用<string.h>库中所有函数 部分使用<string.h>库中函数

2.3 SDSAPI

函数 作用
sdslen(const sds s) 获取sds字符串长度 O(1)
sdssetlen(sds s, size_t newlen) 设置sds字符串长度
sdsinclen(sds s, size_t inc) 增加sds字符串长度
sdsalloc(const sds s) 获取sds字符串容量
sdssetalloc(sds s, size_t newlen) 设置sds字符串容量。
sdsavail(const sds s) 获取sds字符串空余空间(即alloc - len)
sdsHdrSize(char type) 根据header类型得到header大小
sdsReqType(size_t string_size) 根据字符串数据长度计算所需要的header类型。

sdsReqType函数源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5) // string_size < 2^5
return SDS_TYPE_5;
if (string_size < 1<<8) //string_size < 2^8
return SDS_TYPE_8;
if (string_size < 1<<16) //string_size < 2^16
return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
if (string_size < 1ll<<32) //string_size < 2^32
return SDS_TYPE_32;
#endif
return SDS_TYPE_64;
}

采用左移来计算对应多少位的范围,而不是用2^5 这样的乘法。直接移位比使用幂来计算快很多

1<<5 计算出来就是2^5 次方。1是int型,4byte32位。最低8bit位的二进制为:00000001 左移5位后变成了:00100000 对应的十进制既是32。

计算n个bit位的最大值:(1<<n) -1

但是需要注意位数不够的情况。因为1是int型,只有32个bit。所以在左移32个bit时,需要使用long long int型。用1ll来表示,此时1ll为64个bit。

还得考虑机器是否为64位机器,在32位机器上LONG_MAX = 2147483647L,64位机器上LONG_MAX = 9223372036854775807L 。不论32位机器还是64位机器上 LLONG_MAX 都是9223372036854775807L 。所以当LONG_MAX == LLONG_MAX 说明字长为64bit。加上条件编译,说明在32位机器上不使用sdshdr32而直接跳到了sdshdr64,仅仅在64位机器上使用sdshdr32。原因是什么?还没想通

问题

  1. 为什么Redis需要自己实现字符串功能,而不直接使用c语言的传统字符串?
    • 见第二节。
  2. 执行SET 与GET命令的过程。
  3. char buf[] 为什么没有指定大小?一个数组占用的内存大小
    • 在各个header的定义中最后有一个char buf[]。我们注意到这是一个没有指明长度的字符数组,这是C语言中定义字符数组的一种特殊写法,称为柔性数组(flexible array member),只能定义在一个结构体的最后一个字段上。它在这里只是起到一个标记的作用,表示在flags字段后面就是一个字符数组,或者说,它指明了紧跟在flags字段后面的这个字符数组在结构体中的偏移位置。而程序在为header分配的内存的时候,它并不占用内存空间。如果计算sizeof(struct sdshdr16)的值,那么结果是5个字节,其中没有buf字段。
    • 数组内存大小为分配的的长度*数组类型的内存大小
  4. 为什么redis 在32位机器上不使用sdshdr32?