一 数据结构与对象
1 简单动态字符串(SDS)
Redis并未使用传统的C语言的字符串(以空字符结尾的字符数组),而是自己构建一种简单动态字符串(simple dynamic string)(SDS),SDS不仅被用于保存数据库中字符串值,SDS还被用于缓冲区:AOF模块中的AOF缓冲区,客服端的输入缓冲区。
1.1 SDS的定义
struct sdshdr{
//记录buf数组中已使用的字节的数量,等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组
char buf[];
}
注:SDS遵循C字符串以空字符结尾的惯例,字符串末尾有一个空字符,但空字符不记录在len里面,并且为空字符额外分配1字节的空间,这样做的好处就是SDS可以直接重用部分C字符串函数库中的函数。
1.2 SDS与C字符串
C字符串采用的在结尾以空字符结尾的简单表示方法不能满足Redis在安全与效率及功能上的要求。
1.2.1 常数复杂度获取字符串长度
- 传统的C字符串若要获取字符串的长度需要一次遍历,所以复杂度是O(N)
- SDS由于len属性的存在,获取长度的复杂度为O(1),设置与更新SDS长度的工作由SDS的API在执行时自动完成,无需手动修改。
1.2.2 杜绝缓冲区溢出
- 传统的C字符串会出现缓冲区溢出的现象,例如在使用strcat拼接字符串的时候,由于C字符串不会记录本身的长度,长度超长且未再次进行空间分配的情况下就会出现溢出的情况。
- SDS的空间分配策略完全杜绝了缓冲区溢出的可能,当SDS的API对SDS进行修改时,会优先根据结构体中的属性来判断空间大小是否合适,若不够,会先进行拓展,再进行修改,所以不用担心溢出问题。
1.2.3 减少修改字符串带来的内存重分配次数
- C字符串在增长和缩短一个字符串长度时总会对保存C字符串的数组进行一次内存重分配的操作:
- 如果是增长字符串,就会拓展底层数组的空间大小,不然就会引起缓冲区溢出
- 如果是缩短字符串,就会释放掉不用的那部分空间,不然就会引起内存泄漏
在一般的程序里面,如果修改字符串长度的情况不经常出现,那么每次修改都执行一次内存重分配是可以接受的,但Redis作为数据库,经常用于速度要求严格,数据被频繁修改的场合,如果每修改一次就进行一次内存重分配,就会对性能产生很大的影响。
- 为了避免上述的缺陷,SDS解除了字符串长度和底层数组长度的关联,buf数组的长度不一定是len+1,里面可包含未使用的字节,未使用字节的数量就有free来记录。通过这些方法,SDS实现了空间预分配和惰性空间释放两种优化策略。
- 空间预分配
当SDS字符串需要增长的时候,优先检查free剩余长度是否足够,若足够就直接增长,不够就进行空间拓展,进行空间扩展的时候,不仅会对其分配所需的空间,还会为SDS分配额外的未使用空间,此策略使得内存重分配次数从必定进行n次,到最多进行n次,分配策略如下。- 若字符串拓展后len的长度小于1MB,那么free的大小将于len大小一样,例如len从5byte长度的字符串需要拓展到13byte大小,此时free的剩余长度不足以支撑拓展,进行内存重分配,len的大小变成13byte,free大小也为13byte,buf数组的实际长度就为13byte+13byte+1byte。
- 若字符串拓展后len的长度大于等于1MB,那么free的大小将于1MB,例如len从5MB长度的字符串需要拓展到10MB大小,此时free的剩余长度不足以支撑拓展,进行内存重分配,len的大小变成10MB,free大小就为1MB,buf数组的实际长度就为10MB+1MB+1byte。
- 惰性空间释放
- 当SDS字符串需要进行缩短操作时,并不会立即使用内存重分配来回收多余的字节,而且使用free属性将这些字节的数量记录下来,等待将来的使用。通过此操作避免了缩短字符串时进行的内存重分配,也优化了增长操作。
- 当然SDS也提供相依的API,在需要时真正的释放SDS的未使用空间,来避免惰性空间释放策略带来的内存浪费问题。
1.2.4 二进制安全
- 由于C字符串以ASCII编码,并以空字符结尾的特点,使得其只能存储文本数据,
不能存储图片,音频,视频等二进制文件。 - 而SDS保存文本或是二进制数据都是没有问题的,因为SDS使用len来判断结尾,而不是空字符,并且SDS的所有API都是二进制安全的,SDS的所有API都会以处理二进制的方式来处理SDS存放在buf数组中的数据。
1.2.5 兼容部分C字符串函数
由于SDS字符串保留了C字符串以空字符结尾的特点,所以SDS字符串可以兼容C字符串函数库的部分函数。
1.2.6 总结
C字符串 | SDS |
---|---|
获取字符串长度复杂度O(N) | 获取字符串长度复杂度O(1) |
API不安全,可能出现缓冲区溢出 | API安全,不会出现缓冲区溢出 |
修改字符串长度N次需要执行N次内存重分配 | 修改字符串长度N次最多执行N次内存重分配 |
只能保存文本数据 | 可以保存文本和二进制数据 |
可以使用<string.h> 中所有函数 | 可以使用<string.h> 中部分函数 |
2 链表
Redis使用的C语言未内置链表这样的结构,使用Redis自助构建的链表结构,链表在Redis中使用非常频繁,例如在一个列表键包含过多个元素,还在列表中包含的元素都是比较长的字符串时,Redis就会使用链表来作为列表键的底层实现。
并且除了链表键之外,发布与订阅,慢查询,监视器等功能也用到了链表。Redis服务器本身还使用链表来保存多个客户端的状态信息,构建客户端的输出缓冲区。
//单个节点的结构
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点值
void *value;
}listNode;
//使用list来持有链表
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void *(*free)(void *ptr);
//节点值对比函数
void *(*match)(void *ptr,void *key);
}
- dup函数用来复制链表节点的值;
- free函数用于释放链表节点的值;
- match函数用于对比链表节点的值与另一个输入的值是否相等;
Redis链表特征
- 双端:双向链表;
- 无环:链表不会成环;
- 带表头与表尾指针:head和tail;
- 带链表长度计数器:len属性;
- 多态:值value与三种方法dup,free,match使用void,可强转来保存不同类型的值。
具体的链表数据结构算法,可参考算法书。
3 字典
字典,又称为符号表,关联数组或映射,例如java中的map,是一种储存多个键值对的抽象数据结构。
例:
redis>HLEN website(integer) 10066
redis>HGETALL website
1)"Redis"
2)"Redis.io"
3)"MariaDB"
4)"MariaDB.org"
#...
//website键的底层实现就是一个字典,字典包含10066个键值对,例如"Redis"为键,"Redis.io"为值
3.1 字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面有多个哈希表节点,每个节点保存一组键值对。
3.1.1 哈希表
//哈希表结构
typedef struct dictht{
//哈希表数组,存放键值对结构
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,总是为size-1
unsigned long sizemask;
//已有节点个数
unsigned long used;
}dictht;
3.1.2 哈希表节点
//哈希表节点结构
typedef struct dictEntry{
//键
void *key
//值,使用联合体使得值可以是多种类型,分别为指针,unsigned long int,long int类型。
union{
void *val
uint64_t u64;
int64_t s64;
}v;
//指向下一个节点的指针。这样就能将多个索引值相同的键连接在一起了。
struct dictEntry *next;
}dictEntry;
3.1.2 字典
typedef struct dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2]
//rehash索引,若rehash不在进行时,值为-1
int rehashidx;
}dict;
-
type和privdata属性是针对不同类型的键值对,为创建多态字典而设置。
-
type属性是一个纸箱dictType结构的指针,每个dictType结构保存了一簇用于超重特定类型键值对的函数,Redis为不同的字典设置不同的类型特定函数。
-
privdata属性保存了需要传给那些类型特定函数的可选参数
-
ht属性为两个项的数组,每个项都是一个dictht哈希表,一般情况下只会用到ht[0],ht[1]只有在进行rehash时才会用到
-
trehashidx用来记录rehash的进度
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);
}
3.2 哈希算法
当需要新添加键值对进字典时,程序需要先计算键对应的哈希值,然后根据索引值将节点放到指定索引上
- 使用字典里的哈希函数计算键的哈希值
hash=dict->type->hashFunction(key); - 使用哈希表的sizemask和哈希值计算索引
index=hash & dict->ht[x].sizemask; - 键值对放入dict->ht[x].table下对应索引的地方。
3.3 解决键冲突
当多个键值对最后计算出来的索引一样时,我们称此时发生了键冲突,此时我们利用哈希表节点中的next属性,形成链表将多个键值对放在一个哈希表节点中,而为了追求效率,插入时采用复杂度为O(1)的头插法。
3.4 rehash
随着操作的不断执行,哈希表保存的键值对会逐渐增多或减少,为了让哈希表的负担因子维持在一个合理的范围内,当哈希表保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展或收缩,这个操作就是rehash(重新散列)。
3.4.1 rehash的步骤
- 为ht[1]哈希表重新分配空间,空间大小取决于要发生的操作
- 如果是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的 2^n ,比如ht[0]的大小为62,那么因为2^8< 62*2 < 2^9 ,所以ht[1]的大小为2^9
- 如果执行的是收缩操作,那么ht[1]的大小为第一个小于等于 ht[0].used的2^n ,比如ht[0]的大小为62,那么因为2^7< 62 < 2^8 ,所以ht[1]的大小为2^7
- 将保存在ht[0]上的键值对全部rehash到ht[1]上(rehash指的是重新计算键值对的哈希值与索引值,然后再将键值对放到ht[1]上的指定位置)
- ht[0]上的值全部rehash到ht[1]上以后(ht[0]变为空表),释放ht[0]的空间,将ht[1]设置为ht[0],并为ht[1]新创建一个空白空间为下一次rehash做准备。
3.4.2 哈希表的拓展与收缩
负载因子=哈希表保存节点个数 / 哈希表大小(load_factor = ht[0].used / ht[0].size)
- 当个哈希表的负载因子小于0.1时,程序对哈希表执行收缩操作。
- 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。,程序对哈希表执行拓展操作
- 服务器正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。,程序对哈希表执行拓展操作
Redis之所以根据BGSAVE命令或者BGREWRITEAOF命令是否正在执行使用不同的负载因子来判定,是因为在执行这两种命令时,Redis会创建当前服务器进程的子进程,而大多数操作系统会使用写入时复制技术来优化子进程的使用效率,所以子进程存在期间,应尽量避免对哈希表进行拓展操作,最大限度节约内存。
写入时复制(Copy-on-write)是一个被使用在程序设计领域的最佳化策略。其基础的观念是,如果有多个呼叫者(callers)同时要求相同资源,他们会共同取得相同的指标指向相同的资源,直到某个呼叫者(caller)尝试修改资源时,系统才会真正复制一个副本(private copy)给该呼叫者,以避免被修改的资源被直接察觉到,这过程对其他的呼叫只都是通透的(transparently)。此作法主要的优点是如果呼叫者并没有修改该资源,就不会有副本(private copy)被建立。
3.5 渐进式rehash
相较与rehash,渐进式rehash是指在键值对从ht[0]中rehash到ht[1]中时,分多次进行,这样做的原因在于,若ht[0]中的键值对较少时直接全部rehash没有影响,但ht[0]中的键值对较多时,一次性全部rehash可能导致服务器在一段时间内停止服务。
步骤:
- 为ht[1]分配空间,字典同时持有ht[0]和ht[1]两个哈希表
- rehashidx置0,表示rehash开始工作
- rehash期间,每次对字典进行增删改查的同时,还会顺带将ht[0]哈希表rehash到ht[1]上,rehash工作结束后rehashidx增加1
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会rehash到ht[1]上,这是程序将rehashidx置-1,表示rehash完成
渐进式rehash采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典每次的增删改查上,避免了集中式rehash而带来的庞大计算量
渐进式rehash执行期间的哈希表操作
字典的删改查会先访问ht[0],再访问ht[1],新增的键值对会直接保存的ht[1]上。
4 跳跃表
跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问其他节点的目的。跳跃表支持平均O(logN),最坏O(N)复杂度查找,大部分情况下,跳跃表的效率可以和平衡树相媲美,并且实现比平衡树更加简单。
在Redis中只有两个地方用到了跳跃表,一个是有序集合键,另一个是在集群节点中用作内部数据结构。
关于跳跃表的更多信息,推荐阅读请点击
4.1 跳跃表的实现
4.1.1 跳跃表节点
typedef struct zskiplistNode{
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj
//层
struct zsliplistlevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
}
}zskiplistNode
- 层
跳跃表节点的level数组可以包含多个元素,每个元素都有一个前进指针指向其他跳跃表节点,程序可以通过这些指针来加快遍历速度,一般来说,层的数量越多,访问速度就越快。
每次创建新跳跃表节点时,程序会根据幂次定律(越大的数出现概率越小)随机生成一个介于1-32之间的值作为level数组的大小,也就是层的高度 - 前进指针
每个层都有一个指向表尾方向的前进指针,用于从表头向表尾遍历节点。 - 跨度
用于记录两个节点间的距离 - 两个节点间的间距跨度越大,说明他们相距越远
- 指向null的所有前进指针的跨度都是0
- 跨度与遍历无关,遍历只需要前进指针,跨度用于计算节点的rank,初始节点以0开始算,那么到达某个节点经过的跨度和就是其rank值
- 后退指针
用于从表尾向表头前进,不像每个节点有多个前进指针,后退指针,每个节点只有一个。所以后退时只能一个节点一个节点的后退,不能类似前进指针那样跳跃移动。 - 分值与成员
分值用于节点的排序依据,成员为其保存的值。若分值相同时,按照成员的字典序大小来排序。
4.1.2 跳跃表
跳跃表由多个跳跃表节点组成
typedef struct zskiplist{
//表头,表尾节点
struct zskiplistNode *header,*tail;
//节点数量
unsigned long length;
//层数最大节点的层数
int level;
}zskiplist;