1简介
Redis 和其他很多key-value数据库的不同之处在于,Redis不仅支持简单的字符串键值对,它还提供了一系列数据结构类型值,比如列表、哈希、集合和有序集,并在这些数据结构类型上定义了一套强大的API 。
在Redis的内部,数据结构类型值由高效的数据结构和算法进行支持,并且在Redis 自身的构建当中,也大量用到了这些数据结构。
1.1简单字符串
Sds (Simple Dynamic String,简单动态字符串)是Redis底层所使用的字符串表示,它被用在几乎所有的Redis 模块中。
在C 语言中,字符串可以用一个\0 结尾的char 数组来表示。但是这样直接操作会带来两个问题
- 计算长度 时间复杂度n
- 追加n次,必须n次内存分配
那么为了解决这个问题,redis针对字符串,定义了一种数据结构
struct sdshdr {
// buf 已占用长度
int len;
// buf 剩余可用长度
int free;
// 实际保存字符串数据的地方
char buf[];
};
这样就可以把计算长度的复杂度降为1,而且当字符串追加的时候,当发现free不够,他就会申请当前请求追加字符的2倍空间,所以下次,有字符再增加的时候,不需要申请内存。
所以实用这种数据结构的好处可以总结如下:
- 快速计算长度
- 快速追加
- 功能性函数库
- 二进制安全
- sds 会为追加操作进行优化:加快追加操作的速度,并降低内存分配的次数,代价是多占
用了一些内存,而且这些内存不会被主动释放。
1.2双端链表
链表作为数组之外的一种常用序列抽象,是大多数高级语言的基本数据类型,因为C 语言本身不支持链表类型,大部分C 程序都会自己实现一种链表类型,Redis 也不例外——它实现了一个双端链表结构。
Redis的列表使用两种方式实现:
- 双端列表
- 压缩列表
因为双端链表要比压缩列表占用的内存多,所以会优先使用压缩列表,并在有需要的时候转换成双端列表
双端列表除了自身的列表类型外,还应用于redis的其他应用,如下:
- 事务模块使用双端链表来按顺序保存输入的命令
- 服务器模块使用双端链表来保存多个客户端
- 订阅/发送模块使用双端链表来保存订阅模式的多个客户端
- 事件模块使用双端链表来保存时间事件(time event)
双端链表是由节点和链表构成,节点存储值,以及前后节点地址引用,list构成容器,管理这些节点,并提供一些方法
typedef struct listNode {
// 前驱节点
struct listNode *prev;
// 后继节点
struct listNode *next;
// 值
void *value;//代表值是object类型
} listNode;
typedef struct list {
// 表头指针
listNode *head;
// 表尾指针
listNode *tail;
// 节点数量
unsigned long len;
// 复制函数
void *(*dup)(void *ptr);
// 释放函数
void (*free)(void *ptr);
// 比对函数
int (*match)(void *ptr, void *key);
} list;
- listNode 带有prev 和next 两个指针,可以双向遍历
- list 保存了head 和tail两个指针,因此,对链表的表头和表尾进行插入的复杂度都为1 ——这是高效实现LPUSH 、RPOP 、RPOPLPUSH
- list 带有保存节点数量的len 属性,所以计算链表长度的复杂度仅为1
为了双端列表方便遍历,redis还定义迭代器
typedef struct listIter {
// 下一节点
listNode *next;
// 迭代方向
int direction;
} listIter;
1.3字典表
字典(dictionary),又名映射(map)或关联数组(associative array),它是一种抽象数据结构,由一集键值对(key-value pairs)组成,各个键值对的键各不相同
主要作用:
- 实现redis对应的键空间的映射 key-value
- 实现hash类型的数据存储 value
字典的实现:
redis底层使用hash表作为底层的实现
/*
* 字典
**
每个字典使用两个哈希表,用于实现渐进式rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录rehash 进度的标志,值为-1 表示rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
/**功能函数
创建一个新字典dictCreate O(1)
添加新键值对到字典dictAdd O(1)
添加或更新给定键的值dictReplace O(1)
在字典中查找给定键所在的节点dictFind O(1)
在字典中查找给定键的值dictFetchValue O(1)
从字典中随机返回一个节点dictGetRandomKey O(N)
根据给定键,删除字典中的键值对dictDelete O(1)
清空并释放字典dictRelease O(N)
清空并重置(但不释放)字典dictEmpty O(N)
缩小字典dictResize O(N)
扩大字典dictExpand O(N)
对字典进行给定步数的rehash dictRehash O(N)
在给定毫秒内,对字典进行rehash dictRehashMilliseconds O(N)
**/
下面来看下dictht的结构(哈希表)
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket),看到这里是不是感觉和hashMap有点像
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
再来看下dictEntry结构
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
看图总结下,字典表里有两个桶,至于为啥两个,以及作用,后面分析,每个桶里存的是字典实体数组,而每个数组的的元素(也就是节点)也是个链表,所以字典表的底层数据结构本质是数组+链表,但是为什么那么搞?
接下来模拟添加过程:
新创建的两个哈希表都没有为table 属性分配任何空间:
• ht[0]->table 的空间分配将在第一次往字典添加键值对时进行;
• ht[1]->table 的空间分配将在rehash 开始时进行;
添加键值对到字典表
• 如果字典为未初始化(也即是字典的0号哈希表的table属性为空),那么程序需要对0号哈希表进行初始化;
• 如果在插入时发生了键碰撞,那么程序需要处理碰撞,这里和hashMap类似,通过链表解决碰撞问题
• 如果插入新元素使得字典满足了rehash 条件,那么需要启动相应的rehash 程序;
为了在字典的键值对不断增多的情况下保持良好的性能,字典需要对所使用的哈希表(ht[0])进行rehash操作:在不修改任何键值对的情况下,对哈希表进行扩容,尽量将比率维持在1:1左右。dictAdd 在每次向字典添加新键值对之前,都会对哈希表ht[0] 进行检查,对于ht[0] 的size 和used 属性,如果它们之间的比率ratio = used / size 满足以下任何一个条件的话,rehash 过程就会被激活:
- 自然rehash :ratio >= 1 ,且变量dict_can_resize 为真。
- 强制rehash : ratio 大于变量dict_force_resize_ratio (目前版本中,
dict_force_resize_ratio 的值为5 )。
下面分析下rehash的过程
设置字典的rehashidx 为0 ,标识着rehash 的开始,为ht[1]->table 分配空间,大小至少为ht[0]->used 的两倍
ht[0]->table 的节点会被逐渐迁移到ht[1]->table ,因为rehash 是分多次进
行的(细节在下一节解释),字典的rehashidx 变量会记录rehash 进行到ht[0] 的哪个索引,下面是rehashidx=2时的场景
节点迁移完毕的图像
迁移之后的处理:
- 释放ht[0] 的空间
- 用ht[1] 来代替ht[0] ,使原来的ht[1] 成为新的ht[0]
- 创建一个新的空哈希表,并将它设置为ht[1] ;
- 将字典的rehashidx 属性设置为-1 ,标识rehash 已停止
总结一下:
• 字典由键值对构成的抽象数据结构。
• Redis 中的数据库和哈希键都基于字典来实现。
• Redis 字典的底层实现为哈希表,每个字典使用两个哈希表,一般情况下只使用0 号哈希表,只有在rehash 进行时,才会同时使用0 号和1 号哈希表。
• 哈希表使用链地址法来解决键冲突的问题。
• Rehash 可以用于扩展或收缩哈希表。
• 对哈希表的rehash 是分多次、渐进式地进行的。
1.4跳跃表
跳跃列表是在很多应用中有可能替代平衡树而作为实现方法的一种数据结构。跳跃列表的算法有同平衡树一样的渐进的预期时间边界,并且更简单、更快速和使用更少的空间。
跳跃表的特性:
• 表头(head):负责维护跳跃表的节点指针。
• 跳跃表节点:保存着元素值,以及多个层。
• 层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
• 表尾:全部由NULL 组成,表示跳跃表的末尾。
整体上看,Skip List 就是带有层级结构的链表(结点都是排好序的),最下面一层(level 0)是所有结点组成的一个链表,依次往上,每一层也都是一个链表。而查询的话也是通过头结点,从上层往下找,因为是链表是有序的,可以向判断量表的下一个节点是否为空,或者判断下一个节点的大小,来找到相应的元素,有点类似二分查找。
跳跃表在redis中的使用:
为了适应自身的功能需要,Redis基于WilliamPugh论文中描述的跳跃表进行了调整:
- 允许重复的score 值:多个不同的member 的score 值可以相同。
- 进行对比操作时,不仅要检查score 值,还要检查member :当score 值可以重复时,单靠score 值无法判断一个元素的身份,所以需要连member 域都一并检查才行。
- 每个节点都带有一个高度为1层的后退指针,用于从表尾方向向表头方向迭代:当执行ZREVRANGE
定义的数据结构如下:
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;
总结:
• 跳跃表是一种随机化数据结构,它的查找、添加、删除操作都可以在对数期望时间下完成。
• 跳跃表目前在Redis 的唯一作用就是作为有序集类型的底层数据结构之一,另一个构成有序集的结构是字典。
• 为了适应自身的需求,Redis 基于William Pugh 论文中描述的跳跃表进行了修改,包括:
- score 值可重复。
- 对比一个元素需要同时检查它的score 和memeber 。
- 每个节点带有高度为1 层的后退指针,用于从表尾方向向表头方向迭代