字符串是我们经常用到的数据结构,在c语言中,字符串是采用N+1长度的字符数组来表示长度为N的字符串,其中,字符数组末尾为’\0’,用来代表字符串的末尾。但是,redis并没有直接采用c语言的实现方式,而是自己构建了一种称为简单动态字符串(simple dynamic string, SDS)的数据结构,并将SDS作为默认的字符串表示。
SDS在redis中的定义为:
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 字符数组,用于保存字符串
char buf[];
};
在redis中,字符串的键和值都是SDS的对象,一个可能的SDS示例如下图所示:
redis没有采用c语言的表示方式,总归是有原因的,和c语言的实现方式相比,SDS有以下几个优点。
c语言获取字符串的复杂度,需要对整个字符串进行遍历计数,复杂度为O(N),但是在redis中,获取字符串长度,只需要返回SDS结构体的len属性即可,复杂度从O(N)降低到O(1)。
c语言在对字符串进行操作时,很容易造成字符串溢出,例如采用strcat进行字符串拼接时,内存中有两个相连的字符串s1和s2,其中s1保存了字符串"Hi",s2保存了字符串"Redis",如下图所示。
如果此时执行了strcat(s1, " Mysql")将s1的内容修改为“Hi Mysql”,但却忘记了为s1分配足够的空间,那么在执行完strcat之后,s1的数据将溢出到s2所在空间中,导致s2被意外修改,如下图所示。
和c语言不同,SDS空间分配策略完全杜绝了发生缓冲区溢出的可能。当需要对字符串进行修改时,会先检查SDS空间是否满足修改所需的要求,如果不满足的话,则会将SDS的字符数组扩展至所需大小,然后才执行修改操作,避免了我们忘记给字符串分配足够的空间,以至于发生缓冲区溢出的问题,将这一保证交给了SDS来实现。
SDS中拼接字符串的函数是sdscat,假如s初始值为“Hi”,执行sdscat(s, " Redis")之后,SDS数据结构的变化过程如下:
sdscat不仅进行了字符串拼接操作,还未SDS分配了8个字节的未使用空间,这和SDS的分配策略有关,具体由下一小节说明。
在c语言中,每次对一个字符串进行修改,都需要进行内存重分配操作:
然而,内存重分配操作需要执行内核调用,所以它通常是一个比较耗时的操作,如果内存重分配操作过于频繁,会对程序性能产生过大的影响,针对以上问题,SDS实现了空间预分配和惰性空间释放两种优化策略。
当需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所必需的要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间由以下公式决定:
通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。
当需要对SDS进行空间释放时,程序并不立即使用内存重分配来回收多余的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
与此同时,SDS还提供了相应的API,让我们可以真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
c语言中的字符串必须符合某种编码(比如ASCII),并且除了末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得c字符串只能保存文本数据,而不嗯保存图像、音频等二进制数据。
为了确保redis可以使用于不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,不进行任何过滤、限制,数据写入时是什么样的,它被读取时就是什么样的。
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度,在redis中应用很广泛,当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,redis就会使用链表作为底层实现。
链表节点结构体定义为:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
多个listNode可以通过prev和next指针组成双端链表,如下图所示:
虽然仅仅使用多个listNode结构就可以组成链表,但是使用adlist.h/list来持有链表的话,操作起来会更方便:
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
list结构提供了表头指针head、表尾指针tail以及链表长度计数器len,而dup、free和match是用于实现多态链表所需的类型特定函数:
字典,又称符号表、关联数组或映射,是用来保存键值对的数据结构。redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个节点,每个节点保存了一个键值对。
redis字典所使用的哈希表由dict.h/dictht结构定义:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
其中,size记录了哈希表的大小,即table数组的大小,used记录了哈希表当前已有节点的数量,sizemask总是等于size-1,这个属性和哈希值一起决定一个键被放到table数组的哪个索引上。
table属性是一个数组,数组中的每一个元素都是指向dict.h/dictEntry结构的指针,dictEntry就是哈希表节点,其定义如下:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
dictEntry通过next节点来解决键冲突的问题,一个可能的哈希表结构如下:
其中,k1和k2的索引值相同。
redis中的字典由dict.h/dict结构表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
type和private是为了实现多态字典而设置的,type是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数;private保存了需要传递给函数的可选参数。
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
ht属性是一个包含两个dictht元素的数组,一般只用ht[0],ht[1]只会在对ht[0]进行rehash的时候使用,rehashindex记录了rehash的进度,若没有进行rehash,则其值为-1。
redis计算哈希值和索引值的方法如下:
// 计算key的哈希值
hash = dict->type->hashFunction(key);
// 计算索引值
index = hash & dict->ht[x].sizemask;
redis使用了MurmurHash2算法计算哈希值。
哈希表初始分配的size是有限的,当哈希表中保存的元素数目过多或过少时,需要对哈希表进行相应的扩容或者收缩,这些过程统一通过rehash来完成,rehash的操作过程如下:
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩容操作:
负载因子的计算公式为:
hf[0].used / ht[0].size
因为服务器在执行BGSAVE或者BGREWRITEAOF命令过程中,需要创建当前进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩容操作的负载因子,从而避免在子进程存在期间进行哈希表的扩展操作,从而避免不必要的内存写入操作,最大限度节约内存。
另一方面,当负载因子小于0.01时,程序会自动执行收缩操作。
redis为了避免rehash对服务器性能造成影响,rehash过程不是一次性完成的,而是分多次、渐进式完成,渐进式rehash过程如下:
在渐进式rehash期间,若要进行查找操作,程序会先在ht[0]里面查找,没找到才会在ht[1]里面查找。同时,在此期间,新加入的元素一律会被保存到ht[1]里面,保证了ht[0]数量的只增不减。
跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点,跳跃表是redis中有序集合的底层实现。
redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode表示跳跃表中的节点,而zskiplist用于保存条跳跃表节点的相关信息。
zskiplistNode定义如下:
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度越快。
每创建一个节点时,程序都会根据幂次定律(越大的数出现的概率越小)随机生成一个介于1-32之间的值作为level数组的大小。
每个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。
层的跨度用来记录两个节点之间的距离(level[i].span):两个节点的跨度越大,它们的距离就越远;指向NULL的所有前进指针的跨度都为0。
跨度可以用来计算排位:在查找某个节点的过程中,将沿途访问过的所有层的跨度累加起来,就是目标节点在跳跃表中的排位。
节点的分值(score)是一个double类型的浮点数,跳跃表中的所有节点都按照分值从小到大排序;节点的成员对象(obj)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却是可以相同的,此时,节点将按照成员对象在字典序中的大小进行排序,较小的节点会排在前面。
zskiplist定义如下:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
header和tail分别指向跳跃表的表头和表尾节点,通过length记录节点的数量,level则记录了跳跃表中层高最大的那个节点的层数量(不包括表头结点)。
黄健宏《Redis设计与实现》