数据类型与底层原理
数据类型与底层原理
数据结构
哈希表
redis使用链式哈希来解决哈希冲突,其Hash表实质上是一个二维数组,其中每一项就是一个指向哈希项(dictEntry)的指针
typedef struct dictht {
dictEntry **table; //二维数组
unsigned long size; //Hash表大小
unsigned long sizemask;
unsigned long used;
} dictht;
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//键值对的值是由联合体决定的,如果该值本身是整数或浮点数,就不需要用指针浪费空间了
struct dictEntry *next;
} dictEntry;
当随着链表长度的增加,Hash 表在一个位置上查询哈希项的耗时就会增加,从而增加了 Hash 表的整体查询时间,这样会导致 Hash 表的性能下降,于是redis决定rehash
-
什么时候rehash?
当进行插入或修改键值对的时候,都会通过
_dictExpandIfNeeded
判断,当前 Hash 表当前承载的元素个数(d->ht[0].used)和 Hash 表当前设定的大小(d->ht[0].size)的比值,是否大于5(这个比值叫做负载因子),如果负载因子大于5且当前没有 RDB 子进程也没有 AOF 子进程,即开始rehash -
怎么样进行rehash
redis在
dict
结构体中定义了dictht ht[2];
两个Hash表,交替使用,用于rehash操作,其中备用表ht[1]会先扩容到ht[0]已使用大小的两倍,即dictExpand(d, d->ht[0].used*2);
-
从rehash到渐进式rehash
因为在rehash过程中,键值对会被重新hash到新的位置,这个拷贝过程中redis主线程会被阻塞,为了减少开销而是用渐进式rehash即不会一次性拷贝所有的ht[0]中的数据到ht[1],而是分批拷贝,每次拷贝一个bucket里的数据
主要依赖于两个函数实现:
dictRehash
_dictRehashStep
-
dictRehash
在这其中,dictResh函数通过 rehashidx变量来确定本次迁移的目标bucket(比如说rehashidx为0,即迁移ht[0]第一个bucket,以此类推),如果当前bucket为空则将rehashidx ++,检查下一个bucket
如果当前bucket有数据,则将这些数据重新哈希到ht[1]中,直到bucket为空将rehashidx ++
如果连续检查bucket为空则停止执行(因为在rehash过程中主线程阻塞,避免影响redis性能)
-
_dictRehashStep
:给dictResh函数传入参数为1:即一次迁移一个bucket
-
跳表
跳表:多层的有序链表
其跳表节点的定义如下:
typedef struct zskiplistNode {
//Sorted Set中的元素
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward; //记录该层上的下一个节点的指针
unsigned long span; //跨度:记录forward指针和当前指针跨越了几个level0上的节点
} level[]; //每个节点都对应一个zskiplistLevel结构体,也对应了跳表的一层
} zskiplistNode;
跳表的定义如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; //头节点尾节点
unsigned long length; //跳表长度
int level; //跳表最大层数
} zskiplist;
跳表节点的查询
从头节点的最高层开始查找下一个节点,有两个判断条件:元素权重,以及SDS类型数据
-
如果查找到的节点元素权重 < 要查找的权重,则访问该层的下一个节点
-
如果权重相等,但SDS数据 < 要查找的数据,也继续访问该层下个节点
-
如果以上条件都不满足,则使用level数组里的下一层指针,到下一层指针里寻找
//获取跳表的表头 x = zsl->header; //从最大层数开始逐一遍历 for (i = zsl->level-1; i >= 0; i--) { ... while (x->level[i].forward && (x->level[i].forward->score < score || (x->level[i].forward->score == score && sdscmp(x->level[i].forward->ele,ele) < 0))) { ... x = x->level[i].forward; } ... }
跳表节点层数的设计
- 每一层的结点数是低一层是1/2,类似于二分的思想,可以保证查询效率在O(logN),但当删除、新增节点后,需要调整节点,带来额外的操作开销
- redis采用的是:随机生成每个结点的层数,采用zslRandomLevel决定,先把层数初始化为1,然后生成随机数,每增加一层的概率不超过1/4
内存友好型数据结构设计
redis对三种数据结构针对内存使用效率做了设计优化:简单动态字符串SDS,压缩列表ziplist,整数集合intset
redisObject基本数据对象结构体的设计
redisObject主要功能是用来保存键值对中的值,定义如下
typedef struct redisObject {
unsigned type:4; //redisObject的数据类型,4个bits
unsigned encoding:4; //redisObject的编码类型,4个bits
unsigned lru:LRU_BITS; //redisObject的LRU时间,LRU_BITS为24个bits
int refcount; //redisObject的引用计数,4个字节
void *ptr; //指向值的指针,8个字节
} robj;
采用了C语言中的位域定位方法:当一个变量占用不了一个数据类型的所有bits时,使用该方法把一个数据类型划分成多个位域,每个位域定义一个变量,实现一个数据类型可定义多个变量
比如此处,一个unsigned类型为4个字节32bits,采用位域定位方法只需要4字节就可以存储三个变量;而不需要三个变量分别用unsigned定义消耗12字节,节省8字节开销
字符串SDS
为什么redis不直接使用char*作为字符串的实现?
- char*会导致数据在\0被截断,而redis希望保存任意二进制数据
- char*实现的字符串操作复杂度很高,比如说strlen、strcat都要求要遍历到末尾\0,而redis希望对字符串高效操作
redis的字符串实现:SDS
typedef char* sds;
sds实际上就是char*,只不过在此基础上添加了其他元数据信息
一共有五种不同的类型,sdshdr8,sdshdr16,sdshdr32,sdshdr64等,区别在于len和alloc的类型不同,如下所示,sdshdr8的len和alloc就是uint_t8,只占用1字节
目的是灵活保存不同大小的字符串,从而有效节省内存空间(保存小字符串的时候结构头占用空间也比较小),同时使用了 __attribute__ ((__packed__))
采用紧凑的方式分配内存,这样编译器就不会做字节对齐了,进一步节省内存空间
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 字符数组现有长度*/
uint8_t alloc; /* 字符数组的已分配空间,不包括结构体和\0结束字符*/
unsigned char flags; /* SDS类型*/
char buf[]; /*字符数组*/
};
同时sds通过记录字符数组的使用长度和分配空间大小,避免了对字符串的遍历操作,降低了操作开销,比如sdscatlen用于追加字符串,相比strcat就不需要再遍历字符串才能追加了
此外,在保存较小字符串的时候,SDS还采用了嵌入式字符串的方法
当一个字符串创建时,会判断该字符串是否大于44字节
如果是的话调用createRawStringObject创建普通字符串,创建过程为:分别给redisObject和SDS结构体分配空间,然后将SDS指针赋给redisObject的ptr,需要分配两次内存,既带来内存分配开销,也会导致内存碎片
如果字符串小于44字节,则使用嵌入式字符串方法
createEmbeddedStringObject函数会分配一块连续的内存空间,存放redisObject结构体、sdshdr8、字符串和末尾的\0,让SDS结构指针指向sdshdr8起点,让redisObject指针指向字符串起点,最后把参数中的字符串拷贝到sds中的字符数组并添加结束字符,紧凑放置两个结构体避免内存碎片以及两次分配开销
压缩列表的设计
ziplist压缩列表本身就是一块连续的内存空间,使用不同的编码来保存数据
其创建函数如下:
unsigned char *ziplistNew(void) {
//初始分配的大小
unsigned int bytes = ZIPLIST_HEADER_SIZE + ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
…
//将列表尾设置为ZIP_END,表示列表结束
zl[bytes-1] = ZIP_END;
return zl;
}
以上为初始创建压缩列表,列表里还未存入列表项的空间布局
以上为存入列表项后的布局,每个列表项包含三个内容:前一项长度,当前项的长度编码,实际数据
ziplist对于列表项prevlen和encoding使用到了编码技术:使用不同数量的字节来表示保存的信息,实际数据则是正常用整型或字符串保存,因为如果用相同的字节数保存一个大长度和小长度,对于小长度来说就是一种浪费
- prevlen:如果前一个列表项小于254字节,就是用1字节表示prevlen;如果大于254字节,就用5字节表示prevlen(其中将第一个字节设置为254,然后在2~5个字节表示长度)
- encoding:对于整数,使用1字节来表示;对于字符串的不同长度,分别使用1,2,5个字节来表示encoding
ziplist的不足
查找复杂度高
由于使用不同数量的字节来表示保存的信息,所以中间元素的偏移量都是不确定的,需要从列表头或列表尾遍历,查找中间数据的复杂度太高,一旦ziplist里的元素个数多了,它的查找效率就会降低
连锁更新风险
因为在列表项中的每一项都存储了prevlen,而这个prevlen的字节数还是不固定的,如果在非列表末尾插入元素,后面元素的prevlen和prevlensize可能会发生变化,可能会引起后续项需要新增空间,然后导致连锁更新,这会导致ziplist占用的内存空间需要多次重新分配,影响其性能
影响性能的地方在于:多次扩容导致的多次分配内存
quicklist
为了解决ziplist的弊端,redis设计了quicklist,一个quicklist是一个链表,而链表中每个元素是一个ziplist
其quicklistnode的数据结构如下:
typedef struct quicklistNode {
struct quicklistNode *prev; //前一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
unsigned char *zl; //quicklistNode指向的ziplist
unsigned int sz; //ziplist的字节大小
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;
而quicklist的数据结构定义如下:
typedef struct quicklist {
quicklistNode *head; //quicklist的链表头
quicklistNode *tail; //quicklist的链表尾
unsigned long count; //所有ziplist中的总元素个数
unsigned long len; //quicklistNodes的个数
...
} quicklist;
当在插入一个新元素的时候,会检查插入位置的ziplist是否能够容纳这个元素,然后判断新插入数据大小是否满足要求:单个ziplist是否不超过8KB || 单个ziplist里元素个数是否满足要求,如果满足一个就在当前quicklistnode节点的ziplist上插入,否则就新增一个ziplist
减少了数据插入时内存空间的重新分配和内存数据的拷贝,也限制了单个节点上ziplist的大小
整数集合
intset的设计作为底层结构来实现set数据类型,也是一块连续的内存空间,避免内存碎片并提高内存使用效率
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
共享对象
由于redis实例运行时有些数据可能会被经常访问,比如常见的整数、redis协议中常见的回复信息、报错信息等,为了避免在内存反复创建这些数据,就将这些数据创建为共享对象,当上层应用需要访问时直接读取即可(但这种对象主要适用于只读场景)
sorted set
核心结构设计采用跳表:支持高效的范围查询,ZRANGEBYSCORE;同时采用哈希表进行索引:可以以O(1)返回某个元素的权重,ZSCORE
其结构体定义如下,采用了哈希表dict 以及 跳表zskiplsit,zset创建时会分别调用dictCreate和zslCreate
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
它将元素保存在了哈希表中作为哈希表的key,然后将value指向元素在跳表中的权重
当新增元素时,调用zsetAdd,首选使用哈希表的dictFind查询插入元素是否存在
- 不存在:直接调用跳表元素插入函数和哈希表元素插入函数
- 已经存在:zsetAdd判断是否增加元素权重值
- 权重值发生变化,调用zslUpdateScore,更新条表中的元素权重值,然后把哈希表中该元素的value指向权重值
redis的内存管理
我觉得这个可以分成五个方面来说
一个是内存优化:redis使用了很多特殊的数据结构来减少存储重复数据的开销,比如说使用压缩算法来减小存储的数据量,用位图来紧凑地表示一组布尔值之类的
第二个是内存回收,redis使用写时复制来进行内存回收,就是说当一个key被修改的时候,redis会创建一个副本,然后修改应用到副本上,但是保持原始的键不变,这样在多个客户端同时读取一个键的时候,他们读取的都是各自的副本,减少了读取操作的锁竞争;同理的,多个客户端同时修改一个键的时候,也不需要争夺写入权,提高了写入操作的效率
第三个过期键删除策略,redis使用了惰性删除的策略来处理过期键,也就是说它不会立刻删除过期键,只是会在访问数据时检查是否过期,等到了必要的时候再进行删除
第四个是内存淘汰策略,redis提供了比如说lru算法,lfu算法,随机算法,然后还有分成是针对过期数据进行淘汰,还是针对所有数据进行淘汰,并且它还针对自己的特点对lru算法进行了优化
最后是内存碎片整理,因为操作系统的内存分配机制以及redis自身键值对大小不一的原因,它是会产生内存碎片的,redis使用了内存碎片整理技术,可以把散落在内存块里的数据重新整理到连续内存块,但是由于redis是单线程的嘛,所以这种清理是有代价的,它需要redis同步等待数据进行拷贝
redis介绍,为什么它比较快
首先最显而易见的原因就是,它是内存存储型系统,它把所有的数据存储在内存里,这使得它避免了磁盘读写操作的开销,从而可以实现非常低延迟的读写操作,
然后第二是它采用了单线程的事件驱动模型,所有的请求都在一个事件循环里执行,这种架构简化了并发控制和数据一致性的问题,避免了线程切换的开销
第三个原因是它还内置了很多种高效的数据结构,比如说哈希表,字符串,集合等等,这些数据结构都在内部进行了优化,可以高效地进行增删改查
最后是它还使用了自定义的面向文本的协议来进行客户端和服务器之间的通信,同时使用到了批量操作和通道的技术,减少客户端和服务器之间的网络往返次数,提高了网络通信的效率
redis数据类型和数据结构有哪些
一共有五种数据类型,分别是string,List,Hash,Sorted set还有set
其中String的底层数据结构是SDS简单动态字符串,List底层是双向链表和压缩列表,Hash底层是压缩列表和哈希表,Sorted set底层是压缩列表和跳表,然后set底层是哈希表和整数数组
Hash数据结构的底层实现原理
Hash数据结构底层使用了hash table来实现,具体来说,redis里的哈希表就是一个数组,数组的每个元素被称为”桶“,里面存储着一个链表的头节点,然后链表的节点里包含了哈希表里的键值对。
为了将键映射到到数组索引,redis使用到了murmurhash作为哈希函数,将键转化为索引值,让它均匀地分布在数组中;同时edis使用了链式哈希来解决哈希冲突的问题,具体来说就是当发生冲突的时候,新的键值对会被插入到目标桶对应的链表中
redis还维护了一个负载因子来保持哈希表的效率,当负载因子过高的时候,它就会使用渐进式rehash的方式来扩大数组的大小,减少冲突的概率
渐进式rehash的过程:
首先是为新哈希表分配空间,redis会重新创建一个大小为当前哈希表两倍的哈希表,然后将这个新哈希表设置为主哈希表,并将服务器的rehashidx设置为0,表示rehash从索引为0的哈希表节点开始
然后redis会在后台以异步的方式逐步将旧哈希表里的数据迁移到新哈希表,避免了一次性大规模的数据复制,在每次迁移完成后它会逐步增加rehashidx的值,直到rehashidx的值增加到哈希表的大小时,表明迁移完成,新哈希表彻底取代旧哈希表
跳表实现原理
跳表就是在链表的基础上增加多级索引,这些添加的指向其他节点的指针就叫做跳跃指针,通过跳跃指针的跳转,我们就可以在查找的过程中跳过一些元素,来实现数据的快速定位
然后跳表它会有很多层,每一层都是一个有序链表,最底层包含了所有的元素,然后每个更高层链表都是前一层链表的子集,那我们在进行查找操作的时候就可以从最高层级开始,根据分值大小不断地向下层移动,直到找到目标元素为止