Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。
这些数据类型的底层实现如下图所示。
一、字符串
在Redis里面,C字符串只会作为字符串字面量用在一些无须对字符串值进行修改的地方。当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值。
SDS的定义:
struct sdshdr{
int len; //已使用的字节数;
int free; //未使用的字节数;
char buf[]; //字节数组,用来保存字符串
}
因为C字符串并不记录自身的长度信息,所以获取长度的时间复杂度的时间复杂度为O(N),而SDS在len属性里记录了SDS本身的长度,所以获取长度的时间复杂度为O(1)。通过使用sds使得redis获取字符串长度的时间复杂度从O(N)降为O(1),确保了获取字符串长度的工作不会成为redis的性能瓶颈。
另外,C字符串在修改时可能会导致缓冲区溢出,而SDS进行修改时,API会先检查SDS的空间是否满足所需的要求,如果不满足的话API会自动将SDS的空间扩展至执行修改需要的大小。杜绝了C字符串可能出现的缓冲区溢出问题。
C字符串每次进行变更(增加或者缩短)时都要进行一次空间重分配,对redis而言,频繁的内存分配不符合redis对性能的要求,所以redis通过空间预付配和空间惰性释的策略来提高性能。所谓的空间预分配是指每次需要对SDS进行空间扩展的时候,程序不仅会为SDS分配其所必需的空间,还会分配额外的未使用空间。所谓的惰性释放是指当SDS需要缩短操作时,程序并不立即使用内存重分配来回收多出来的内存,而是使用free属性记录多出来的字符以备未来使用。
二、列表
redis的底层实现是ziplist或双向列表。
List类型的key创建时使用zip list结构存储,robj对象的encoding字段设置为REDIS_ENCODING_ZIPLIST。zip list实现细节可参考[3]。概况来讲,zip list通过一个连续的内存块实现list结构,其中的每个entry节点头部保存前后节点长度信息,实现双向链表功能。这个头部可根据前后entry长度进行内存压缩,而如果直接使用指针的话则至少需要两个指针,对64位系统来说将占用16个字节,使用zip list时最好情况下只需要两个字节,这在具有大量list类型的key-value对且各个value较小的应用来说,可以节省大量内存。
当list的elem数小于配置值: hash-max-ziplist-entries 或者elem_value字符串的长度小于 hash-max-ziplist-value, 可以编码成 REDIS_ENCODING_ZIPLIST 类型存储,以节约内存;但由于在zip list添加和删除元素会涉及到数据移动,因此当list内容较多时,转而使用双向链表。
2.1 压缩列表
zlbytes: 整个列表所占的字节数
zltail: 尾节点距离起始地址有多少字节,通过该属性可以直接访问到列表结尾。
zllen: 节点数
zlend: 0XFF,用于标记末端
每个节点有3部分组成
previous_entry_length: 前一个节点的长度,当前一节点长度小于254时其长度为1字节,当前一节点长度大于等于254时其长度为5字节。因此在极端情况下插入大节点或删除小节点可能引发连锁更新。通过该属性可以实现列表节点的倒序访问,达到类似链表的性能。
encoding: 编码方式,用于标识节点存的内容是数字还是字节数组。
content: 保存的内容。
2.2 双向链表
struct list{
listNode* head;
listNode* tail;
unsigned long len; //链表节点数量
}
三、哈希对象
哈希对象的底层实现是压缩列表或字典。
3.1 字典
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned longsize;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned longsizemask;
//该哈希表已有节点的数量
unsigned longused;
}dictht
typedefstructdictEntry{
//键
void*key;
//值
union{
void*val;
uint64_tu64;
int64_ts64;
}v;
//指向下一个哈希表节点,形成链表
structdictEntry *next;
}dictEntry
key 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数。
注意这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突。
①、哈希算法:Redis计算哈希值和索引值方法如下:
#1、使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
#2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
index = hash & dict->ht[x].sizemask;
②、解决哈希冲突:这个问题上面我们介绍了,方法是链地址法。通过字典里面的 *next 指针指向下一个具有相同索引值的哈希表节点。因为dictEntry节点组成的链表没有指向链表结尾的指针,所以为了速度考虑总是将新节点添加到链表的表头位置。
③、扩容和收缩:当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:
1、如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
2、重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
3、所有键值对都迁徙完毕后,释放原哈希表的内存空间。
④、触发扩容的条件:
1、服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。
2、服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。
ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小。
⑤、渐近式 rehash
从0开始,在每次对字典的增加、删除、查找和更新操作后,对某一hash值的所有键值对rehash到另一新的哈希表上直至所有键值对都完成操作。也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
redis自身的底层数据结构就是一个字典。
四、集合
set的底层实现是intset和table。
4.1 整数集合
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。
length 属性记录了 contents 数组的大小。
需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。
①、升级
当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:
1、根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
2、将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
3、将新元素添加到整数集合中(保证有序)。
升级能极大地节省内存。
②、降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
五、有序集合
有序集合的底层实现是压缩列表或跳跃表。
5.1 跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:
1、由很多层结构组成;
2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;
3、最底层的链表包含了所有的元素;
4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;
①、搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
②、插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。
③、删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
跳跃表支持时间复杂度平均O(logN)的查找,效率可以和平衡树相媲美,因此很多程序使用跳跃表来代替平衡树。