Redis的数据类型有string、list、set、zset、hash,那么这些数据类型底层如何实现的呢?
Redis是用C语言写的,底层数据结构包括六种:动态字符串、链表、字典、跳跃表、整数集合和压缩列表。
1、动态字符串
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
以存储“Redis”字符串为例:
Redis的动态字符串相比于C语言的string有如下优势:
- 获取字符串长度的时间复杂度为O(1)
C 语言获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n),而动态字符串获取长度只需要读取 len 属性,时间复杂度为 O(1)。Redis中通过 strlen key 命令可以获取 key 的字符串长度。 - 防止缓冲区溢出
C 语言中使用strcat()函数来进行两个字符串的拼接,如果没有分配足够长度的内存空间会导致缓冲区溢出。而动态字符串在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。 - 减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存,如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而动态字符串由于len属性和free属性的存在,能很方便的修改字符串,比如增长的字符串长度小于free可以直接进行修改而无需内存重新分配。
2、双向链表
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode
Redis链表的特性有:
- 双向:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
- 链表长度计数:通过 len 属性获取链表长度的时间复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。
3、字典
Redis字典使用哈希表实现
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry
key 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是整数。
Redis解决哈希冲突是使用的链地址法,当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩,每次扩展后容量为原来2倍,或收缩后容量为原来1/2。
Redis哈希表特性:渐近式 rehash
Redis哈希表的扩容和收缩操作不是一次性完成的,而是多次渐进式完成。
Redis在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
4、跳跃表
typedef struct zskiplistNode {
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
}level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode
跳跃表是有序集合的底层实现之一,基于多指针有序链表实现的,可以看成多个有序链表。
- 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
- 插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。
- 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
5、整数集合
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。
- 升级
当新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。
1、根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
2、将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
3、将新元素添加到整数集合中(保证有序)。
升级能极大地节省内存。 - 降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
6、压缩列表
- previous_entry_ength:记录压缩列表前一个字节的长度。可能是1个字节或者是5个字节,如果上一个节点的长度小于254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于254,则previous length的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。
- encoding:节点的encoding保存的是节点的content的内容类型以及长度,一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长。
- content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。
压缩列表是为了节省内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。