redis底层数据结构系列-sds

304 阅读7分钟
青春因磨砺而出彩,人生因奋斗而升华

SDS是什么

simple dynamic string; 简单的动态字符串,redis自己构建的数据结构;遵循以空字符串结尾的惯例,保存空字符的1byte空间,以及添加空字符到字符串末尾等操作都是由sds函数自动完成的,所以这个空字符对于sds的使用者来说完全透明的。遵循空字符结尾的惯例,是可以重用一部分c字符串函数库里面的函数;

特点

  • 可动态扩展内存。
  • 二进制安全

SDS结构

sds有5中数据结构,是为了让不同长度的字符串可以使用大小不同的header,这样短字符串就能使用较小的header,从而节省内存

sds结构由两部分组成:

  • header:通常包含字符串长度、最大容量、flags
  • 字符数组:这个字符数组的容量等于最大容量+1;真正有效的字符数据,其长度通常小于最大容量。因为在真正的字符串数据之后,是空余未使用的字节,允许在不重新分配内存的前提下让字符串数据向后做有限的扩展。而且还有一个空字符

五种结构

struct __attribute__((__packed__)) sdshdr5 {
    unsigned char flags;
    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 {
  uint16_t len;
  uint16_t alloc;
  unsigned char flags;
  char buf[];
}

struct __attribute__ ((__packed__)) sdshdr64 {
  uint16_t len;
  uint16_t alloc;
  unsigned char flags;
  char buf[];
}

从以上数据结构来看,除了sds5外,其他4个header的结构都包含3个字段:

  • len:字符串真正的长度(不包含空字符)
  • alloc:字符串的最大容(不包含空字符)
  • flags:动态字符串的header类型;总占用1byte,用低3位表示header类型;

根据源码可知各类型表示的最大容量:

#define SDS_TYPE_5  0  // 最大容量 32(2^5)
#define SDS_TYPE_8  1  // 最大容量 0xff(2^8 - 1)
#define SDS_TYPE_16 2  // 最大容量 0xffff(2^16 - 1)
#define SDS_TYPE_32 3  // 最大容量 oxffffffff(2^32 - 1)
#define SDS_TYPE_64 4  // 最大容量 2^64 - 1

各header注意事项:

  • 各header使用了__attribute__ ((packed)),是为了让编译器以紧凑模式来分配内存。如果没有这个属性,编译器可能会为struct的字段做优化对齐,在其中填充空字节。那样就不能保证header和sds数据部分前后相邻,也不能按照固定向低地址方向偏移1字节方式来获取flags字段

  • 在各个header定义中最后一个buf,是一个没有指明长度的字符数组,是c语言中定义字符数组的一种特殊写法,称为柔性数组,只能定义在一个结构体的最后一个字段上。在这里只起到一个标记作用,表示在flags后面就是一个字符是数组,程序在为header分配内存时,buf并不占用内存空间;eg: sizeof(struct sdshdr16)的值,结果5byte,其中没有buf字段;

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

实例结构图

sds的header,隐藏在真正字符串前面定义的好处:

  • header和数据相邻,而不用分成两块内存空间单独分配。有利于减少内存碎片,提高存储效率;

空间预分配:

redis可以减少连续执行字符串增长操作所需的内存重分配次数,用于优化SDS字符串增长操作,当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所需的空间,还会额外为SDS分配未使用空间;

  • 如果对SDS修改后,SDS长度(len属性) < 1M,那么程序分配和len同样大小的空余空间

  • 如果 > 1M,那么多分配1M的空余空间;

    例子: 如果进行修改之后,SDS的len变成13字节 < 1M,那么程序也会分配13字节未使用空间,SDS的buf数组实际长度将变成13+13+1=27字节(额外的保存空字符)如果对SDS进行修改之后,SDS的长度 >= 1M, 那么程序会分配1M的未使用空间。 eg: 如果进行修改之后,SDS的len变成30M,那么程序会分配1M的未使用空间,SDS的buf实际长度就是 30M + 1M + 1byte

惰性空间释放

用于优化SDS的字符串缩短操作;当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出的字节,而是将这些字节数量记录起来,并等待将来使用。

思考

sds与c字符串的区别

  • 获取字符串的复杂度;

    c语言使用长度为N+1的字符数组表示长度为N的字符串,并且字符数组的最后一个元素为'\0',由于c字符并不纪录自身长度,所以获取一个字符串长度,程序必须遍历整个字符串,直到遇到空字符为止;复杂度为O(N)
    
    sds在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度为O(1),程序只需要访问SDS的len属性,就可以立马知道SDS的长度;确保了获取字符串长度不会称为Redis的性能瓶颈;
    
  • 杜绝缓冲区移除

    由于C字符串不记录本身长度,易造成缓冲区溢出;如果将字符串内容拼接到另一个字符串末尾,如果另一个字符串分配的内存不足以容纳拼接字符串,就会产生缓冲区溢出;
    
    SDS的空间预分配杜绝了这种可能;当SDS API修改字符串时,API会先检查SDS的空间是否满足所需的要求,如果不满足,则进行扩展,才执行实际的修改操作;
    
  • 减少修改字符串时,带来的内存重分配次数

    由于c字符串不记录自身长度,所以c字符串底层实现总是一个N+1的字符数组。因为c字符串的长度和底层数组的长度之间存在关联性,所以每次增长或缩短一个C字符串,程序都要对保存这个C字符串的数组进行一次内存冲分配;
    如
    - 如果增长字符串操作,比如拼接字符串,那么执行前需要先通过内存重分配来扩展底层数组空间,如果忘了,造成内存泄漏;
    - 如果缩短字符串,比如截断字符串,那么执行操作之前,需要先通过内存重分配来释放字符串不再使用的那部分空间,如果忘了就会产生内存泄漏
    
    为了避免这种缺陷,SDS通过未使用空间解除了字符长度与底层数组长度之间的关联,在SDS中,buf数组的长度不一定就是字符数量+1,数组里面可以包含未使用的字节,而这些字节由SDS的free属性记录,通过未使用空间,SDS实现了空间与分配和惰性空间释放两种优化策略
    
  • 兼容部分c字符串函数

    虽然SDS是二进制安全,但同样遵循C字符串的空字符结尾,API总会将SDS保存的数据末尾设置空字符,并且总会在为buf分配空间时多分配一个字节容纳空字符,此举为了让保存文本数据的SDS可以重用一部分C字符串的函数
    

注意

如果创建一个长度为0的空字符,那么不使用SDS_TYPE_5类型的header,而是使用SDS_TYPE_8类型的header;因为创建空字符后一般接下来操作很可能是追加数据,但SDS_TYPE_5类型的sds字符串不适合追加数据(会引发内存重新分配)

#  源码
sds sdsnewlen(const void *init, size_t initlen) {    void *sh;
    sds s;
    char type = sdsReqType(initlen); // 获取flags
      if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp; /* flags pointer. */
    ......