redis之动态字符串

什么是简单动态字符串(SDS)

我们知道redis没有使用C语音传统的字符串表示(以空字符(\0)结尾的字符数组,在我所有文章中统一称为C字符串),而是自己构建了一种名为**简单动态字符串(SDS)**的抽象类型,并将SDS用作redis的默认字符串表示。
其实在redis中也有用到C字符串,但是它只会作为字符串字面量用在一些无须对字符串值进行修改的地方,比如打印日志。当redis需要一个不仅仅是一个字符串字面量,而是一个可以修改的字符串值时,redis就会使用SDS来表示字符串值,比如redis数据库中键值对都是由SDS实现的。另外,SDS还被用作缓冲区:AOF缓冲区,以及客户端状态中的输入缓冲区等。
SDS是redis的基本数据类型之一,用于存储字符串和整型数据。SDS兼容C字符串处理函数,且在此基础上保证了二进制安全。

SDS数据结构

SDS的结构体在redis3.2.0前后有一些改变,但对结构体的定义都是定义在sds.h文件中,大家可以去看源码了解更多。

  • Redis3.2.0之前的SDS数据结构
struct sdshdr {
    int len;
    int free;
    char buf[];
};

释义:

len:buf中已经占用的字节数。
free:buf中剩余可用的字节数
buf:数据空间

这种结构体的优点:

有单独的统计变量len和free。可以很方便的得到字符串的长度。
内容放在柔性数组buf中,SDS向上暴露的指针不是指向结构体SDS的指针,而是直接指向柔性数组buf的指针。
由于有长度统计le的存在,可以保证读写字符串时可以不依赖“\0”,保证了二进制的安全。
  • Redis3.2.0之后的SDS数据结构
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:标识buf中已占用的字节数。
 alloc:标识buf中已分配字节数,不同于free,记录的是buf分配的总长度。
 flags:标识当前结构体的类型,低3位用作标识位,高三位预留。
 buf:柔性数组,真正存储字符串的数据空间。

之所以要用新的结构体也是出于对内存更有效的利用。旧的结构体中len和free各占4字节,紧接着存放字符串。但是在很多情况下我们存的字符串是很短的,都用4字节存放长度未免太浪费宝贵的内存空间了,所以新的SDS提供了多种结构体,以适应存放不同长度的字符串,更合理的利用内存空间。由于不同长度的字符串为len和alloc分配的空间不一样,所以我们需要增加一个flags字段来定义类型(1字节、2字节、4字节、8字节、小于1字节五种类型),flags固定分配1字节,flags后面紧挨着buf数组。
另外,我们可以看到sdshdr5 的结构体中没有len和alloc字段,这是因为这种结构体是用来存放长度小于32的短字符串的,字符串长度放到了flags那1字节中,低3位用于存放类型,高5位此时就用来存放字符串的长度。而当字符串长度大于32时,就有专门的字段来存放长度了,此时flags的高五位就空置了,这种空间的浪费是没有办法的。
示例如下:
在这里插入图片描述
还有源码中struct attribute ((packed))需要注意,一般情况下,结构体会按其所有变量大小的最小公倍数做字节对齐,而用packed修饰后,结构体则变为按1字节对齐。使用此关键字的好处:

  • 节省内存,例如sdshsr32可节省3字节
  • sds返回给上层的,不是结构体的首地址,而是指向内容的buf指针,这样做的好处是可以通过偏移迅速定位到SDS结构体的各处成员变量。因此无论是sdshdr8、sdshdr16、sdshdr32,都能通过buf[-1]来获取flags,因为此时按1字节对齐。

SDS的基本操作

创建字符串

在函数中会根据字符串长度选择合适的类型,初始化完相应的统计值后,返回指向字符串内容的指针,根据字符串长度选择不同的类型。
需要注意的是,对于sdshdr5类型,在创建空字符串时会强制转换为sdshdr8。原因可能是创建空字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为sdshdr8。

释放字符串

SDS提供了直接释放内存的方法,该方法通过对buf指针的偏移,可定位到SDS结构体的首部,然后调用s_free释放内存。
但是为了优化性能(减少申请内存的开销),SDS提供了不直接释放内存,而是通过重置统计值达到清空目的的方法。该方法仅将SDS的len归零,此处已存在的buf并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存。

拼接字符串

拼接字符串本身并不复杂,但其中可能涉及到SDS的扩容,拼接函数中会首先对拼接后的新字符串s容量做检查,若无须扩容则直接返回原buf数组的指针;若需要扩容,则返回扩容后重新分配的buf数组新指针。

扩容过程

  • 若sds中剩余空闲长度avail大于新增内容的长度addlen,直接在柔性数组buf末尾追加即可,无须扩容
  • 若sds中剩余空闲长度avail小于或等于新增内容的长度addlen,则分情况讨论:新增后总长度len+addlen<1MB,按新长度的2倍扩容;新增后总长度>1MB的,按新长度加上1MB扩容。
  • 最后根据新长度重新选取存储类型,并分配空间。若无须更改类型,通过realloc扩大柔性数组即可;否则需要重新开辟内存,并将原字符串的buf内容移动到新位置。

缩容过程

当一个SDS字符串修改为较短字符串时,理论上应该进行缩容,来避免内存空间的浪费,但是在Redis中并没有这样做,而是使用“惰性空间释放”策略,只是对SDS中的len字段进行修改,并没有真正的释放内存空间,这样做的好处是可以有效的减少内存重分配次数。

与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

C字符串和SDS区别

  • C字符串
    根据传统,C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符‘\0’,通常表达为字符指针的形式(char *)。它不允许字节0出现在字符串中间,因此,它不能用来存储任意的二进制数据。
  • SDS
    每个sds.h/sdshsr结构表示一个SDS值:但是这里具体的定义,可能不同版本的redis是不一样的!!!
    上面我们了解了C字符串和SDS的结构上的差异,现在我们会基于他们结构上的差异来分析为什么redis选择使用SDS而不是C字符串。

SDS相比C字符串的优点

  • 常数复杂度获取字符串长度
    因为C字符串并不记录自身的长度信息,所以获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。而SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度O(1)。因此这确保了获取字符串长度的工作不会成为redis的性能瓶颈。
    设置和更新SDS长度的工作是有SDS的API在执行时自动完成的,使用SDS无需进行任何手动修改长度的工作。
  • 杜绝缓冲区溢出
    除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。我们通过一个例子来说明为什么C字符串容易造成缓冲区溢出,假设程序里有两个在内存中紧邻的C字符串s1和s2,其中s1=“Redis”,s2=“MongoDB”,如果此时我们想进行strcat(s1,“Cluster”)操作来修改s1的值,但是在执行strcat之前却忘了为s1分配足够的空间,那么在strcat函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外地修改。
    与C字符串不同,SDS的内存分配策略完全规避了发生缓冲区溢出的可能性:当SDS的API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小(也叫内存重分配,这个过程很耗时),然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面说的缓冲区溢出问题。
    需要注意的是SDS在自动扩展空间用来存储最新的值的同时,还会为SDS分配和扩展后空间同样大小的未使用空间(这是个非常重要的预留空间,减少内存的从分配次数也是基于这个预留空间来实现的),这和SDS的空间分配策略有关。
  • 减少字符串修改时带来的内存重分配次数
    从以上描述可知,对于一个包含N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或缩短一个C字符串,程序都要进行一次内存重分配操作。
    如果程序执行的是增长字符串的操作,比如拼接操作,如果忘了重新分配内存就会产生缓冲区溢出
    如果程序执行的是缩短字符串的操作,比如截断操作,如果忘了通过内存重分配释放字符串不再使用的那部分空间,就会产生内存泄漏。
    因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
    SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就有SDS的在老版本Redis中使用free属性记录,在新版本Redis中使用alloc表示buf数组的总长度。
    通过未使用空间,SDS实现了空间预分配惰性空间释放两种优化策略。
    • 空间预分配
      空间预分配就是当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间。通过空间预分配策略,redis可以减少连续执行字符串增长操作所需的内存重分配次数
      额外分配的未使用空间数量由一下公式决定:
      • 如果对SDS修改后,SDS的长度(也即是len属性的值)小于1MB,那么程序分配和len属性同样大小的未使用空间。
      • 如果对SDS修改后,SDS的长度大于1MB,那么程序会分配1MB的未使用空间。
        但无论什么时候buf数组的长度都等于len属性的值+free属性的值+1byte(当然这是老版本Redis中的书法),在新版本中使用alloc表示buf数组的总长度。
        至于能降低多少内存重分配次数,这个不一定,但是可以确定的是SDS可以将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。
    • 惰性空间释放
      惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的字节,而是继续保留着此未使用空间等待将来使用。
      通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
      与此同时,SDS也提供相应的API,让我们可以在有需要时,真正的释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
  • 二进制安全
    C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串末尾外,字符串里面不能包含空字符,种种这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频,视频等这样的二进制数据。redis为了适用于各种不同的场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据写入时什么样,被读取时就是什么样。
    这也是我们将SDS的buf数组称为字节数组的原因----redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
    所以我们用SDS保存中间带有空字符的数据就没有问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束。
  • 兼容部分C字符串函数
    虽然SDS是二进制安全的,但是它依然遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。

学习链接

redis之详解

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值