redis的数据结构有哪些以及如何实现的
1.redis几种数据结构
●字符串
●无序列表
●有序列表
●hash
2.字符串
简单动态字符串(simple dynamic string sds)使用这种抽象类型作为redis的默认字符串表示。
redis需要一个可以被修改的字符串值时,redis使用sds来表示字符串值。redis数据库里,字符串值的键值对在底层都是由sds实现的。
set msg “hello world”
底层:键:保存着字符串"msg"的sds;值:保存着字符串"hello world"的sds
sds除了用来保存数据库中的字符串外,还被用作缓冲区。
2.1 sds的定义
/*
-
保存字符串对象的结构
*/
struct sdshdr {// buf 中已占用空间的长度
int len;// buf 中剩余可用空间的长度
int free;// 数据空间
char buf[];
};
2.2 sds与c字符串的区别
2.2.1 常数复杂度获取字符串长度
sds记录len属性,不需要遍历获取字符串长度。
2.2.2 杜绝缓冲区溢出
c的字符串使用一些函数时,如 sdscat(s1,s2)(将s2拼接到s1后)可能会出现缓冲区溢出的情况。但是sds有free属性,api可以先获取free来作为判断是否需要扩展缓冲区。
2.2.3 减少修改字符串时带来的内存重新分配次数
内存重分配是一个比较耗时的操作。但是redis对速度要求严苛,数据会被频繁修改。采取一些策略减少重新分配内存的次数
●空间预先分配,extraBuf(free) = lenafter < 1mb ? lenafter : 1mb
●惰性空间释放:字符串缩短时,不立即执行内存重分配,而是使用free属性将这些空间记录下来
2.2.4 二进制安全
sds使用len属性的值而不是空字符来判断字符串是否结束
2.2.5 兼容部分c字符串函数
保留空字符串结尾的习惯
3.链表
redis中列表键包含了数量比较多的元素,或者在列表中包含的元素都是比较长的字符串时,redis就会使用链表作为列表键的底层实现。
3.1 链表和链表节点的实现
/*
-
双端链表节点
*/
typedef struct listNode {// 前置节点
struct listNode *prev;// 后置节点
struct listNode *next;// 节点的值
void *value;
} listNode;
list结构表示链表
/*
-
双端链表结构
*/
typedef struct list {// 表头节点
listNode *head;// 表尾节点
listNode *tail;// 节点值复制函数
void *(*dup)(void *ptr);// 节点值释放函数
void (*free)(void *ptr);// 节点值对比函数
int (*match)(void *ptr, void *key);// 链表所包含的节点数量
unsigned long len;
} list;
redis的链表特点
双端、无环、表头指针和表尾指针、长度计数器、多态
多态:链表节点使用void*指针来保存节点值,可以通过list结构中的dup、free、match三个属性为节点值设置类型特定函数。用于保存各种不同类型的值。
4.字典
redis实现了自己的字典。redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查也是构建在对字典的操作之上。
4.1 字典的实现
redis的字典使用哈希表作为底层实现。一个hash表里可以有多个哈希表节点。每个哈希表节点就保存了字典中的一个键值对。
4.1.1 哈希表
/* This is our hash table structure. Every dictionary has two of this as we
-
implement incremental rehashing, for the old to the new table. /
/ -
哈希表
-
每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {// 哈希表数组
dictEntry **table;// 哈希表大小
unsigned long size;// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;// 该哈希表已有节点的数量
unsigned long used;
} dictht;
为啥需要size的掩码,为啥已经有了size还需要有节点数量这个属性?是因为哈希表的大小不表示已经被使用的大小,而是表示预分配的大小,而used字段表示的是已经使用的大小?
答:是的,size表示预分配的大小,used表示已经使用的多少。
4.1.2 哈希表节点
/*
-
哈希表节点
*/
typedef struct dictEntry {// 键
void *key;// 值
union {
void *val;
uint64_t u64; //这个字段是用来干嘛的?
int64_t s64;
} v;// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
其中key属性表示键值对中的键,是void类型,所以可以表示多种类型。而v属性则保存着键值对中的值。键值对中的值可以是一个指针,或者是一个uint64_t整数。或者是一个int64_t整数。
next属性是指向另一个hash表节点的指针。这个指针可以将多个hash值相同的键值对连接在一起,以此来解决键冲突的问题。
4.1.3 字典
/*
-
字典
*/
typedef struct dict {// 类型特定函数
dictType *type;// 私有数据
void *privdata;// 哈希表
dictht ht[2];// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
问题:hash表和字典的区别是什么?
字典里有明确地指出数据类型,通过type字段。还有个字段ht表示hash表,但是为啥是2?还有个rehash索引,这是个什么东西?还有个iterators,迭代器。
type属性和private属性是针对不同类型的键值对,为创建多态字典而设置的。(意思是这个字典里保存的键值对类型可以不一样?)
●type:指向dictType结构的指针。每个dictType结构保存了一簇用于操作特定类型键值对的函数。redis会为不同的字典设置不同的类型特定函数。(指向函数,好奇怪啊。表示这套函数是为这种类型的键值准备的,为什么不能用范型?)
●private属性则保存了需要传给那些特定类型函数的可选参数(传给函数的可选参数?)
/*
-
字典类型特定函数
*/
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);
} dictType;
为什么需要有复制键、值的函数,以及为什么要有销毁的函数?删除后回收内存?
ht属性是一个包含两个项的数组。每一个项都是一个dictht哈希表。一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]rehash的时候使用。原来如此,需要有rehash,才会需要两个哈希表。
rehashidx,也是用来rehash的,记录rehash目前的进度。如果没有在进行rehash,它的值为-1。
普通状态下的字典。dict表示字典、里面有两个hash表,dictht里会有指向dictEntry[]数组的指针。dictEntry里包含多个dictEntry节点,节点里有键、值、next指针。
4.2 哈希算法
获取hash值的方法:字典里的类型字段,调用type里的hashFunction来进行hash
hash = dict -> type -> hashFunction(k0)
index = hash & ht[0].sizemask = 8 & 3 = 0
为什么按位与和取模是一样均匀的效果?只是一样均匀还是说和取模效果一样?
按位与不怎么均匀啊,如果size是3,还能比较均匀地分布在不同的位置上。但是如果size是4就完全不均匀了。所以是sizemask是么?sizemask一直是2^n - 1。这样就可以均匀地分布在这些位置。
当字典被用作数据库的底层实现,或者hash键的底层实现时候,redis使用MurmurHash2算法(随机分布额&速度快)来计算键的hash值。
4.3 解决键冲突
链地址法解决冲突。因为dictEntry没有指向链尾指针,所以新插入的节点都是在链表表头。why?这和指向表尾指针有啥关系?
4.4 rehash
rehash的情况
1.扩展
●不执行bgsave或者bgrewriteaof命令时,当负载因子>=1时
●执行bgsave或者bgrewriteaof命令,且负载因子>=5时
2.收缩
●负载因子<0.1时,执行收缩
rehash的步骤
1.为ht[1]分配大小
●扩展:最小的大于等于ht[0].used*2 的2^n
●收缩:最小的大于等于h[0].used的2n,这里为啥是最小大于等于的h[0].used的2n?因为当负载因子小于0.1时才会执行收缩,此时used的值小于是size的0.1,大于等于第一个used的2^n绝大多数情况下都是缩小的
2.遍历ht[0]的节点,并重新计算hash值,table不一样了,并保存在ht[1]中
3.释放ht[0],将ht[1]置为ht[0],并在ht[1]新创建一个空白的hash表,为下一次rehash做准备
rehash负载因子计算公式
load_factor = ht[0].used/ht[0].size
啥时候会出现load_factor>5呢?那就是used是包含冲突节点的。
4.5 渐进式rehash
关键:属性rehashidx
rehashidx记录当前需要rehash的索引。在执行rehash的期间,每次对字典进行添加、删除、查找或者更新操作时,除了执行这些操作,还会把rehashidx索引上的节点rehash到ht[1]。每次执行完成,rehashidx值就+1,直到rehas完成,会被置为-1。
添加操作时,新元素只会出现在ht[1]中。保证ht[1]只增不减
查找操作,先查ht[0],再查ht[1]。
为什么要执行渐进式rehash
答:ht[0]里的元素可能非常多,一次性执行完会耗时比较久。分而治之的思想
4.6 字典api
创建、给定key返回value、添加元素、删除元素、释放字典
dictReplace和dictAdd有什么区别?不都是如果键已经存在于字典,那么用新值取代原有的值么?
答:区别:dictAdd如果key已经存在会返回error,replace会替换
dictAdd先entry->next=ht->table[index]再ht->table[index]=entry的原因是,链式地址解决冲突,新增元素都是放在链表头。entry的next就是原有链表的链表头
/* Add an element, discarding the old if the key already exists.
-
Return 1 if the key was added from scratch, 0 if there was already an
-
element with such key and dictReplace() just performed a value update
-
operation. */
static int dictReplace(dict *ht, void *key, void *val) {
dictEntry *entry, auxentry;/* Try to add the element. If the key
- does not exists dictAdd will suceed. /
if (dictAdd(ht, key, val) == DICT_OK)
return 1;
/ It already exists, get the entry /
entry = dictFind(ht, key);
/ Free the old value and set the new one /
/ Set the new value and free the old one. Note that it is important - to do that in this order, as the value may just be exactly the same
- as the previous one. In this context, think to reference counting,
- you want to increment (set), and then decrement (free), and not the
- reverse. */
auxentry = *entry;
dictSetHashVal(ht, entry, val);
dictFreeEntryVal(ht, &auxentry);
return 0;
}
- does not exists dictAdd will suceed. /
/* Add an element to the target hash table */
static int dictAdd(dict *ht, void *key, void *val) {
int index;
dictEntry *entry;
/* Get the index of the new element, or -1 if* the element already exists. */
if ((index = _dictKeyIndex(ht, key)) == -1)return DICT_ERR;/* Allocates the memory and stores key */
entry = malloc(sizeof(*entry));
entry->next = ht->table[index];
ht->table[index] = entry;/* Set the hash entry fields. */
dictSetHashKey(ht, entry, key);
dictSetHashVal(ht, entry, val);
ht->used++;
return DICT_OK;
}
5.跳跃表
跳跃表:一种有序的数据结构,通过在每个节点中维持指向多个其他节点的指针,从而达到快速访问节点的目的。
平均O(logN)、最坏O(N)复杂度的节点查找。还可以通过顺序性操作来批量处理节点
大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现更为简单。所以有不少程序都是用跳跃表来代替平衡树。
一个有序集合包含的元素数量比较多时/有序集合中元素的成员是比较长的字符串时,使用跳跃表。
另一个用途:集群节点中用作内部数据结构
5.1 跳跃表的实现
5.1.1 跳跃表节点
/* ZSETs use a specialized version of Skiplists /
/
-
跳跃表节点
*/
typedef struct zskiplistNode {// 成员对象
robj *obj;// 分值
double score;// 后退指针
struct zskiplistNode *backward;// 层
struct zskiplistLevel {// 前进指针struct zskiplistNode *forward;// 跨度unsigned int span;
} level[];
} zskiplistNode;
详细说明
1.层:
level数组包含多个元素,每个元素都包含一个指向其他节点的指针。层的数量越多,访问其他节点的速度就越快。
level的size的生成算法:随机生成1-32之间的值作为level的大小
// 层
struct zskiplistLevel {
// 前进指针struct zskiplistNode *forward;// 跨度unsigned int span;} level[];
2.前进指针
每一个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。
3.跨度
层的跨度(level[i].span属性)用于记录两个节点之间的距离
●两个节点之间的跨度越大,它们相距得就越远
●指向null的跨度为0
跨度和遍历操作无关,遍历操作只使用前进指针
跨度实际上是用来计算排位的(rank):查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。(这个排位是用来干啥的?)
4.后退指针
每个节点只有一个后退指针,所以每次只能后退至前一个节点
5.分值和成员
score属性,是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。成员对象(obj)是一个指针,指向一个字符串对象,字符串对象则保存着一个sds值。
同一个跳跃表中,每个节点保存的obj需要唯一,但是score可以相同。score相同的节点将按照成员对象在字典序中的大小来进行排序。
5.1.2 跳跃表
zskiplist结构来持有这些节点
/*
-
跳跃表
*/
typedef struct zskiplist {// 表头节点和表尾节点
struct zskiplistNode *header, *tail;// 表中节点的数量
unsigned long length;// 表中层数最大的节点的层数
int level;
} zskiplist;
level:o(1)获取表中层最大的节点的层数。为啥要记录这个呢?
5.2 跳跃表API
函数 作用 时间复杂度
zslGetRank 返回包含给定成员和分值的节点在跳跃表中的排位, 平均O(logN)、最坏O(N);
N为跳跃表的长度
zslGetElementByRank 返回跳跃表在给定排位上的节点 平均O(logN)、最坏O(N);
N为跳跃表的长度
zslIsInRange 给定一个分值范围(range),比如0到15,20到28,诸如此类。如果跳跃表中有至少一个节点的分值在这个范围内,那么返回1,否则返回0 跳跃表的表头节点和表尾节点,这个检测可以用O(1)复杂度完成
6.整数集合
intset(整数集合)是集合键的底层实现之一,当一个集合只包含整数值元素。并且这个集合的元素数量不多时。redis就会使用整数集合作为集合键的底层实现。
6.1 整数集合的实现
类型:int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
typedef struct intset {
// 编码方式
uint32_t encoding;// 集合包含的元素数量
uint32_t length;// 保存元素的数组
int8_t contents[];
} intset;
contents数组是整数集合的底层实现。整数集合的每个元素都是contents数组的一个组项。各个项在数组中按照值的大小,由小到大有序排列。且不包含重复项。
6.2 升级
添加元素到整数集合里时,并且新元素的类型比现有元素的所有类型都要长时,整数集合需要先进行升级。然后才能将元素添加到集合中。
升级步骤
1.扩充集合底层数组的空间大小,并为新元素分配空间
2.将现有元素变成现在的大小,并且放到正确的位置上,同时保持有序性
3.新元素添加到底层数组里。
向整数集里添加新元素的时间复杂度是O(N)
6.3 升级的好处
●灵活性
●节约内存
6.4 降级
不支持
7.压缩列表
列表键和哈希键的底层实现之一。列表键只包含少量列表项,且每个列表项都是小整数、长度短的字符串。redis就会使用压缩列表来做列表键的底层实现。
127.0.0.1:6379> rpush chen 1 2 “redis” “hello”
(integer) 4
127.0.0.1:6379> object encoding chen
“quicklist”
为啥我这出现的是quicklist。quicklist和ziplist有啥区别?
百度了下,quicklist是一个双向链表,一个quicklist里包含多个ziplist节点。所以ziplist还是有用的?
7.1 压缩列表的构成
压缩列表存在的目的:节约内存
它是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点保存了一个字节数据或者一个整数值。
zlbytes:4个字节,记录整个压缩列表占用的内存字节数,内存重分配,计算zlend的位置时使用。
zltail:4字节,记录压缩列表表尾节点距离压缩列表起始地址能有多少字节,通过这个偏移量,无须遍历整个压缩列表就可以确定表尾节点的地址。
zllen:2字节,记录了压缩列表包含的节点数量,这个属性的值小于uint_max(65535)时,这个属性的值就是压缩列表包含的节点的数量。等于uint_max(65535) ,需要遍历才能得出。也就是说zllen最大等于65535,字节数限制。超过后失效
entryx:节点列表,节点的长度由节点保存的内容决定
zlend:1字节,特殊值0xff,用于标记压缩列表的末端
7.2 压缩列表节点的构成
节点可以保存:一个字节数组或者一个整数值,其中,字节数可以是以下的三种长度之一
●长度小于等于63(2^6 - 1) 字节的字节数组
●长度小于等于16383(2^14 -1 )字节的字节数组
●长度小于等于4294967295(2^32 - 1)字节的字节数组
整数值则可以是以下六种长度的其中一种(why)
●4位长,介于0-12之间的无符号整数
●1字节长的有符号整数
●3字节长的有符号整数
●int16_t类型整数
●int32_t类型整数
●int64_t类型整数
每个节点都由previous_entry_length、encoding、content三个部分组成。
7.2.1 previous_entry_length
记录压缩列表中的前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。
●长度小于254字节时,1字节。否则5字节。由属性的长度决定能存的上限
这个字段的作用,可以通过指针运算。根据当前节点的起始地址来计算出前一个节点的起始地址。表尾向表头遍历时需要。
7.2.2. encoding
该属性记录了节点的content属性所保存数据的类型以及长度:
●00、01、10开头的,都是字节数组,数组的长度由编码除去最高两位之后的其他位记录
●11开头的是整数。整数的类型和长度由编码除去最高两位之后的其他位记录。
7.2.3 content
content属性负责保存节点的值。节点值可以是一个字节数组或者整数。值的类型和长度由节点的encoding属性决定。
●00:节点保存的时候一个字节数组
●001011记录了字节数组的长度11
●content属性保存着节点值“hello world”
7.3 连锁更新
redis将这种在特殊情况下产生的多次空间扩展操作称之为“连锁更新(cascade update)”
连续多个节点长度介于250-253字节的节点,如果在压缩列表的表头节点前添加一个节点,且其长度大于254,此时老的表头节点存前一个节点长度的previous_entry_length的大小会从1字节变成5字节,它本身的长度也会突破254,引发下一个节点需要重新分配内存。
最坏的情况需要对压缩列表执行n次空间重分配操作。每次空间重分配的最坏的复杂度是O(N),连锁更新的最坏复杂度是O(N^2)
●但是因为实际发生的概率较低。对性能的影响会比较小
7.4 压缩列表api
不想写了,一些插入、删除、查找。虽然一个类是由他的属性和行为共同决定的,但是好像没啥特别的。