注:本系列文章来自于对《redis设计与实现》的总结,并结合redis 5.0.3的源码进行分析
redis没有直接使用C语言传统的字符串表示方式(以空字符串结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS作为redis的默认字符串表示。在redis里,包含字符串值的键值对在底层都是有SDS实现的。
redis针对不同长度的字符串定义了不同SDS结构体用于节省空间,如下所示:
// sds类型是个字节数组
typedef char *sds;
/* 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[];
};
// __attribute__ ((__packed__))告诉gcc编译器,不要进行字节对齐操作
struct __attribute__ ((__packed__)) sdshdr8 {
// sdshdr8用一个字节表示buf数组已经使用的字节长度,即保存的字符串真正的长度,不包含空终止字符
uint8_t len; /* used */
// 用一个字节表示buf最大的容量,不包含最后的空终止字符’\0‘
uint8_t alloc; /* excluding the header and null terminator */
// flags用于区分sdshdr的类型,比如flags如果是1,那么代表sds所指向的是一个sdshdr8
unsigned char flags; /* 3 lsb of type, 5 unused bits */
// 保存真正的字符串
char buf[];
};
// 以下类似
struct __attribute__ ((__packed__)) sdshdr16 {
// sdshdr16用两个字节表示buf数组已经使用的字节长度
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类型其实是char *
类型,事实上sds指向sdshdr中的buf字节数组,因为所有sdshdr的flags都是一个unsigned char类型,所以如果用sds定义一个char数组:
sds s;
那么s[-1]就是sdshdr中的flags,根据flags可以区分是sdshdr8、sdshdr16、sdshdr32还是sdshdr64。
我们以redis中的sdslen函数为例进行说明,sdslen的定义如下:
#define SDS_TYPE_5 0 // 00000000 sdshdr5
#define SDS_TYPE_8 1 // 00000001 sdshdr8
#define SDS_TYPE_16 2 // 00000010 sdshdr16
#define SDS_TYPE_32 3 // 00000011 sdshdr32
#define SDS_TYPE_64 4 // 00000100 sdshdr64
#define SDS_TYPE_MASK 7 // 00000111
#define SDS_TYPE_BITS 3
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1];
// 根据flags判断应该使用何种类型的sdshdr
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
case SDS_TYPE_32:
return SDS_HDR(32,s)->len;
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}
SDS_HDR(8, s)最终得到的结果如下所示:
((struct sdshdr8 *)((s)-(sizeof(struct sdshdr8))))
简化一下即为:
(struct sdshdr8 *)(s - sizeof(struct sdshdr8))
SDS通过len属性可以在常数复杂度获取字符串的长度,而获取C字符串长度的时间复杂度为O(N),因为C字符不会保存长度。
例如在执行sdscat时如果忘记对追加的目标字符串进行扩容,那么容易导致缓冲区溢出,如下图所示:
如果此时直接执行strcat(s1, " Cluster")
,那么就会将字符串s2的内容被覆盖,如下图所示:
与C字符串不同的是,当SDS的API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作,所以SDS不需要手动修改SDS空间的大小,特不会出现前面所说的缓冲区溢出问题。
对C字符串来说,因为它的底层实现总是一个N+1个字符长的数组(最后一个字节是’\0’),所以每次增长或者缩短一个C字符串,程序总要对保存这个C字符串的数组进行一个内存重分配的操作。如果是进行增长操作则需要扩展底层数组的大小,若忘记则会产生缓冲区溢出,如果是缩短操作则需要通过内存重分配来释放字符串不再使用的那部分空间,如果忘了则会产生缓冲区溢出。
而SDS则使用空间预分配和惰性空间释放来减少字符串的重分配操作。
当修改SDS需要对空间进行扩展时,程序不仅会为SDS分配修改所需要的空间,还会为SDS分配未使用的空间。其中额外分配的未使用空间数量由以下公式决定:
通过这种策略如果对C字符增长N次,那么也需要进行N次内存重分配,而使用SDS最多需要进行N次内存重分配。
惰性空间释放用于优化对SDS字符串的缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并未将来可能的增长操作提供了优化。
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里不能包含空字符,否则最先被程序读入的空字符将被认为是字符串结尾,这些限定使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
而SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何的限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样的。redis使用SDS的buf数组来保存二进制数据,而不是字符。
SDS的API会将SDS保存的数据末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据得SDS可以重用一部分