一、定义
Redis没有直接使用C语言传统字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。整体结构类似于Java的StringBuilder。
SDS整体定义如下
struct sdshdr {
int len;/* 已使用长度 */
int alloc; /* 全部可用长度,当发生扩容时,alloc会变为新长度的的二倍或者新长度+1MB,避免多次扩容导致 数据迁移 */
unsigned char flags; /* 类型, 低三位用于标识类型,redis中定义了占用字节为8、16、32、64位的len和 alloc,其余5位没有被使用 */
char buf[]; /* 字节数组,用于保存字符串 */
};
如下举例所示
-
len的属性为5,表示这个字符串的长度为5
-
alloc为10,表示发生扩容,之前是1个小于5的,在扩容之后扩成了新长度的二倍10
-
flags是2,表示它的长度小于16
-
buf是一个char类型的数组,数组的前5个字节分别保存了’R’、‘i’、‘d’、‘i’ 、‘s’五个字符,而最后一个字节则保存了空字符’\0’
SDS遵循C语言以空字符串结尾的惯例,保存的空字符的1字节不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串库函数里的函数。
SDS定义的源码:
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[];
};
可以看到不同的sds仅是len和alloc的类型有区别,通过这种区别可以选用最合适的类型,避免浪费内存。
二、与C字符串的区别
既然有了C字符串,那为什么还要做SDS呢?
根据传统,C语言字符串使用长度为N+1的字符数组来表示长度为N的字符串,并且字符串数组的最后一个元素总是空字符’\0’。例如,下图就展示了一个值为“Redis”的C字符串。
C语言使用这种简单的字符串表示存在一些问题,下面详细介绍二者的区别。
2.1 SDS可以常数复杂度获取字符串长度
因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。
而SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)。
2.2 SDS可以减少修改字符串时带来的内存重分配次数
如前面所说,因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个字符串,程序都总是要对底层的字符数组进行一次内存重分配操作。
因为内存重分配设计复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:
- 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都职系那个一次内存重分配是可以接受的。
- 但是Redis作为数据库,经常被用于数据要求严苛、数据被频繁修改的场合,如果每次修改该字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生的话,可能还会对性能造成影像。
Redis通过未使用空间,实现了空间预分配解决了这个问题。
2.2.1 空间预分配
空间预分配用于优化SDS字符串增长操作:当SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间。
其中,分配额外的未使用空间数量由以下公式决定:
- 如果修改后,SDS的长度小于1MB,那么程序分配和len属性同样大小的未使用空间。
- 如果修改后,SDS的长度大于1MB,那么程序分配1MB的未使用空间。
源码如下
if (greedy == 1) {
if (newlen < SDS_MAX_PREALLOC) // SDS_MAX_PREALLOC是1M
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
}
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
2.3 SDS是二进制安全的
C字符串中的字符必须符合某种拜尼马(比如ASCII),并且除了字符串的末尾之外,字符串里不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以二进制额方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据进行任何限制,数据写入时是什么样的,读取时就是什么样的。
保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以二进制额方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据进行任何限制,数据写入时是什么样的,读取时就是什么样的。