Redis的具有很多优势:
(1)读写性能高--100000次/s以上的读速度,80000次/s以上的写速度;
(2)K-V,value支持的数据类型很多:字符串(String),队列(List),哈希(Hash),集合(Sets),有序集合(Sorted Sets)5种不同的数据类型。
(3)原子性,Redis的所有操作都是单线程原子性的。
(4)特性丰富--支持订阅-发布模式,通知、设置key过期等特性。
(5)在Redis3.0 版本引入了Redis集群,可用于分布式部署。
Redis数据类型及其底层实现方式
Redis是由C语言编写的。Redis支持5种数据类型,以K-V形式进行存储,K是String类型的,V支持5种不同的数据类型,分别是:string,list,hash,set,sorted set,每一种数据结构都有其特定的应用场景。从内部实现的角度来看是如何更好的实现这些数据类型。Redis底层数据结构有以下数据类型:简单动态字符串(SDS),链表,字典,跳跃表,整数集合,压缩列表,对象。接下来,就探讨一下Redis是怎么通过这些数据结构来实现value的5种类型的。
一,简单动态字符串(simple dynamic string SDS)
String的数据类型是由SDS实现的。Redis并没有采用C语言的字符串表示,而是自己构建了一种名为SDS的抽象类型,并将SDS作为Redis的默认字符串表示。
redis>SET msg "hello world"
上边设置key=msg,value=hello world的键值对,它们的底层存储是:键(key)是字符串类型,其底层实现是一个保存着“msg”的SDS。值(value)是字符串类型,其底层实现是一个保存着“hello world”的SDS。
注意:SDS除了用于实现字符串类型,还被用作AOF持久化时的缓冲区。
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
为什么要使用SDS
我们一定会思考,redis为什么不使用C语言的字符串而是费事搞一个SDS呢,这是因为C语言用N+1的字符数组来表示长度为N的字符串,这样做在获取字符串长度,字符串扩展等操作方面效率较低,并且无法满足redis对字符串在安全性、效率以及功能方面的要求。
获取字符串长度(SDS O(1))
在C语言字符串中,为了获取一个字符串的长度,必须遍历整个字符串,时间复杂度为O(1),而SDS中,有专门用于保存字符串长度的变量,所以可以在O(1)时间内获得。
防止缓冲区溢出
当我们需要对一个SDS 进行修改的时候,redis 会在执行拼接操作之前,预先检查给定SDS 空间是否足够(free记录了剩余可用的数据长度),如果不够,会先拓展SDS 的空间,然后再执行拼接操作。
减少扩展或收缩字符串带来的内存重分配次数
当字符串进行扩展或收缩时,都会对内存空间进行重新分配。
1. 字符串拼接会产生字符串的内存空间的扩充,在拼接的过程中,原来的字符串的大小很可能小于拼接后的字符串的大小,那么这样的话,就会导致一旦忘记申请分配空间,就会导致内存的溢出。
2. 字符串在进行收缩的时候,内存空间会相应的收缩,而如果在进行字符串的切割的时候,没有对内存的空间进行一个重新分配,那么这部分多出来的空间就成为了内存泄露。
比如:字符串"redis",当进行字符串拼接时,将redis+cluster=13,会将SDS的长度修改为13,同时将free也改为13,这意味着进行预分配了,将buffer大小变为了26。这是为了如果再次执行字符串拼接操作,如果拼接的字符串长度<13,就不需要重新进行内存分配了。
通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。通过惰性空间释放,SDS 避免了缩短字符串时所需的内存重分配操作,并未将来可能有的增长操作提供了优化。
二进制安全
C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存想图片,音频,视频,压缩文件这样的二进制数据。
但是在Redis中,不是靠空字符来判断字符串的结束的,而是通过len这个属性。那么,即便是中间出现了空字符对于SDS来说,读取该字符仍然是可以的。
但是,SDS依然可以兼容部分C字符串函数。
二,链表
链表是list的实现方式之一。当list包含了数量较多的元素,或者列表中包含的元素都是比较长的字符串时,Redis会使用链表作为实现List的底层实现。此链表是双向链表:
typedef struct listNode{
struct listNode *prev;
struct listNode * next;
void * value;
}
一般我们通过操作list来操作链表:
typedef struct list{
//表头节点
listNode * head;
//表尾节点
listNode * tail;
//链表长度
unsigned long len;
//节点值复制函数
void *(*dup) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match)(void *ptr, void *key);
}
链表结构的特点是可以快速的在表头和表尾插入和删除元素,但查找复杂度高,是列表的底层实现之一,也因此列表没有提供判断某一元素是否在列表中的借口,因为在链表中查找复杂度高。
三,字典
在字典中,一个键(key)可以和一个值(value)进行关联,字典中的每个键都是独一无二的。在C语言中,并没有这种数据结构,但是Redis 中构建了自己的字典实现。Redis本身的K-V存储就是利用字典这种数据结构的,另外value类型的哈希表也是通过这个实现的。
哈希表dicy的定义为:
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}
我们可以想到对比Java hashMap的实现方式,在dictht中,table数组的类型是:
typeof struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;
}
我们存入里面的key 并不是直接的字符串,而是一个hash 值,通过hash 算法,将字符串转换成对应的hash 值,然后在dictEntry 中找到对应的位置。这时候我们会发现一个问题,如果出现hash 值相同的情况怎么办?Redis 采用了链地址法来解决hash冲突。这与hashmap的实现类似。
注意:Redis又在dictht的基础上,又抽象了一层字典dict,其定义为:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privedata;
// 哈希表
dictht ht[2];
// rehash 索引
in trehashidx;
}
type 属性 和privdata 属性是针对不同类型的键值对,为创建多态字典而设置的。
ht 属性是一个包含两个项(两个哈希表)的数组
解决hash冲突
采用链地址法来实现。
扩充Rehash
随着对哈希表的不断操作,哈希表保存的键值对会逐渐的发生改变,为了让哈希表的负载因子维持在一个合理的范围之内,我们需要对哈希表的大小进行相应的扩展或者压缩,这时候,我们可以通过 rehash(重新散列)操作来完成。其实现方式和hashmap略有不同,因为dict有两个hash表dictht,所以它是通过这两个dictht互相进行转移的。
渐进式rehash:在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
渐进式rehash 的详细步骤:
1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在几点钟维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始
3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一
4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束