想必大家已经了解了Redis的几大数据结构
,那么数据类型
是什么?
其实是我自己编的,为了让自己理解这些东西造出来的~~
数据结构 :像string、set、list、zset,我们可以直接使用的这些Redis具体的类型。
数据类型 :上述数据结构底层的实现。
该如何理解这两个词呢?
学习各种语言都是先学各种数据类型,例如int、float、char…
再学各种数据结构,例如栈、队列、树…
数据结构由数据类型组成,就像栈、队列这些结构的底层可以由数组实现。
本篇将会讲述以下内容 :
RedisObject
再说一句!本文的数据类型、数据结构都是本人杜撰!便于自己理解,实际上它们都是数据类型
看了一些面试题,出现Redis数据类型
这一块知识的,对于SDS和SkipList较多,Dict也有,Intset、ZipList、QuickList就比较少了。
主要还是它们的应用场景。
Redis构建了一种结构体来完成存储字符串的功能:简单动态字符串
(S
imple D
ynamic S
tring),简称SDS。
struct __attribute__ ((__packed__)) sdshdr8{
char buf[];
uint8_t alloc;
uint8_t len;
unsigned char flags;
};
char buf[]
:字节数组,用于存储string/int/float。uint8_t alloc
:记录buf数组申请的总字节数,类型为8位无符号整型。不包括结束标志’\0’。uint8_t len
:记录buf数组中已经使用的字节的数量,类型为:8位无符号整型。不包括结束标志。unsigned char flags
:记录SDS的最大空间,即决定alloc的最大值。因为存储空间不同,SDS类型也不同,有很多不同类型的SDS,例如16位、32位…之所以被称为动态字符串,因为这个字符串有动态扩容机制
:
扩容后
的空间小于1M :扩容后的空间大小乘以 2+1扩容后
的空间大于1M :空间直接 +1M+1# 扩容方案:
ni -> nihao
# 原空间:
len=2
alloc=2
# 扩容后:
len=5
alloc=10
# 为什么没有加一?不应该是5*2 + 1 = 11吗?
因为字符串后面有 '\0', 而这len和alloc两个字段都不计算结束标志。
空间确实有了,但是alloc没有计算进去。
但是alloc的类型是8位无符号整型,只能存储2^8个数字,太有限,所以Redis提供了不同类型的SDS,它们的其他特性都相同,只有alloc、len的类型不同,有5位、8位、16位、32位。
如何区分?使用 flag
这个字段。flag有不同的值,分别代表字节大小,5、8、16、32.
flag的值:
sdshdr5、sdshdr8、sdshdr16、sdshdr32
总结 :flag规定alloc的最大值,需要扩容时需要改变alloc甚至flag。alloc规定字符串可以存储多少元素,一旦超过,需要扩容。len是当前元素个数。buf[]存储当前元素。
Intset是Redis中set集合的一种实现方式,基于整数数组来实现,具有长度可变
、唯一
、有序
等特点。
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
uint32_t encoding
:数据编码方式,支持存放16位、32位、64位的数据。uint32_t length
:元素个数。int8_t contents[]
:整数数组,保存集合数据。数据范围由encoding确认。那么Intset如何维持
有序
、唯一
的特点?
在插入时 :
检查插入数据是否太大或太小,是否需要改变encoding编码。
如果重置编码,假如重置编码后原来的16位变成32位,需要重新拷贝数组的原有数据到升级后的内存而且需要倒序拷贝防止数据丢失。
查看数组中是否已经存在该数据,若已存在就不插入,若不存在,二分法获得该数据的插入位置。
二分法保证数据有序,数据有序可以使用二分法。
数组原地扩容,将待插入元素插入
通过第二点就可以保证Intset的唯一性和有序性。
上述具体步骤可以打开Redis源码阅读。将Redi安装包打开即可。
Redis是典型的键值型(key-value)数据库,它就是靠Dict
来保持键与值的映射关系的。
Java中的Map是基于Hash的字典结构,Redis中的字典Dict也是。
Dict
由三部分组成 :哈希节点(DictEntry)、哈希表(DictHashTable)、字典(Dict)。
从小往大说,先说哈希节点与哈希表,一个哈希表可以包含多个哈希节点,哈希节点是键-值型的。
哈希节点:
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} value;
struct dictEntry *next;
} dictEntry;
key
:哈希节点的键。value
:哈希节点的值,为联合体类型。*next
:为了方便寻址,每个哈希节点都有指向下一个哈希节点的指针。哈希表 :
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
table
:指向键值对数组的指针。数组是指针,table是指向数组的指针,所以table是二级指针。size
:哈希表大小。总等于 2^n (n为整数)。sizemark
:哈希表大小的掩码,总等于size - 1 。used
:哈希节点个数。为什么有了size表示哈希节点个数,还要使用used多此一举呢?
Hash运算会产生Hash冲突,会在数组的基础上多一些链表,size表示数组元素个数,used表示数组+链表的元素个数。
当我们向Dict添加键值对时,Redis首先根据key计算出hash值,然后通过hash & sizemark计算该数据应该放到数组中哪个位置。
除了哈希节点与哈希表之外,Dict最后一个组成:字典 DictHashTable
.
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx;
int16_t pauserehash;
} ;
*type
:本字典的类型,不同类型使用不同Hash函数。*privdata
:私有数据,在做特殊hash运算时使用。ht[2]
:一个字典拥有两个哈希表,一个放哈希节点,一个为rehash时使用。rehashidx
:rehash的进度,-1代表未开始。pauserehash
:rehash是否暂停,1则暂停,0则继续。总结 :Dict底层是基于数组、链表的Hash表,数组中保存的是一个个entry键值对,键值对的类型大多是指针,指向SDS对象。数组中的entry键值对由next指针连接,便于寻址。
压缩链表,为了节省内存而设计的链表,由一系列特殊编码的连续空间
组成,可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为 O(1)
。
但是成也连续,败也连续,ZipList的诞生是为了解决Dict指针过多且内存不连续的问题,不过产生了新问题 :一旦数据量太大,上哪去找这么多连续的空间?所以很多Redis的数据类型只在数据量小的时候用ZipList。
typedef struct ziplist {
uint32_t albytes;
uint32_t zltail;
uint16_t zllen;
entry *entry;
uint8_t zlend;
} ziplist;
zlbytes
:总字节数zltail
:尾节点与起始地址之间的字节数zllen
:entry节点的个数zlend
:结束标志,0xffentry
:ZipList中所有节点,个数、字节大小不定。entry字节大小不确定,那遍历的时候该如何遍历呢?数组中的元素字节大小固定,可以知道每次读取几个字节的空间,链表直接使用指针指向下一个元素,那么entry该如何遍历?
只要在entry这个结构内部记录一下使用的空间就行了。
但是Entry记录的是上一个entry占用的字节数。(Redis7改为此entry字节数)
每一个entry有三个字段 :
previous_entry_length
:前一节点的长度,1-5个字节。encoding
:本节点属性,记录content的数据类型(整数/字符串)以及长度。contents
:保存节点数据,可以是字符串或整数。只要知道前一个节点/本节点的字节数,就可以遍历。
为了解决ZipList的问题,QuickList诞生了。
ZipList的问题是空间连续,但是找不到太大的连续空间。
为了解决这个问题,QuickList采用两种方法 :
一个5M的数据,使用一个ZipList可能找不到连续的5M空间,但如果可以找到5个连续的1M空间,就可以将这个数据分为5份,使用5个ZipList存储。
Redis3.2之后引入的QuickList是一个双端链表,只不过每一个节点都是一个ZipList。
除了控制ZipList的大小,QuickList还对节点的ZipList进行压缩
QuickListNode(节点)源码:
typedef struct quicklistNode {
// 指向前一个结点的指针
struct quicklistNode *pre;
// 指向后一个节点的指针
struct quicklistNode *next;
// 当前节点的ziplist指针
unsigned char *zl;
// 当前节点的ziplist的字节数
unsigned int sz;
// 当前节点所属的ziplist的entry个数
unsigned int count;
// 编码方式 1.ZipList 2.lzf压缩模式
unsigned int encoding;
// 是否被解压缩 1说明被解压了,以后要重新压缩
unsigned int recompress;
}
QuickList源码:
typedef struct quicklist {
// 头节点指针
quicklistNode *head;
// 尾节点指针
quicklistNode *tail;
// 所有ziplist中的entry个数
unsigned long count;
// ziplist个数
unsigned long len;
// ziplist的entry数量上限
int fill; // 默认为2
}
(源码删了一点没用的)
当存储一定数据时,QuickList的模样 :
总结 QuickList特点:
SkipList
SkipList,面试中经常问的跳表,被称为跳表是因为它遍历的时候可以“跳”着遍历。
对于一个有序数组,可以使用二分法快速查找,但是链表怎么快速呢?
正常的链表只有指向前后节点的指针,但是跳表不一样,它有随机个随机指针
。对于1-10这几个数组成的跳表,可能1这个元素中有指向5、6、9、10的指针,2有指向3、4、8、10的,10可能有指向1、4、7的指针。这样就可以通过不断的跳跃、比较
,快速得到想要的值。此结构正因为这个特点被称为跳表。
当然了,跳表的指针是随机生成的,随机性太大,可能一次就找到值,可能需要一个一个遍历。
SkipList(跳表)首先是链表,但与传统链表有几点差异 :
正因为这两个特点,跳表可以边跳跃边比较,大大提高查询效率。
跳表节点
typedef struct zskiplistNode {
sds ele;
double score;
zskiplistNode *backward;
struct zskiplistLevel {
zskiplistNode *forward;
unsigned long span;
} level[];
}zskiplistNode;
ele
:节点存储的值。score
:节点的分数,用于排序。backward
:前一个节点的指针。level
:多级索引数组,所有后继节点forward
:一个节点可能有多个level,forward指向这个level代表的节点。span
:索引跨度,第一个节点指向第十个节点,跨度为9跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
}
header tail
:头节点、尾节点。length
:节点数量。level
:最大索引层级,默认为1,最大为32,即一个节点的后继指针可以有1-32个。SkipList特点:
学习了以上几种数据类型,有什么用呢?对,组成Redis中可以使用的数据结构,那么各个数据结构用什么样的底层实现呢?在哪里可以看到呢?在哪里记录下来呢?
RedisObject
Redis中的任何数据类型都会被封装为一个RedisObject,也叫做Redis对象。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24;
int refcount;
void *ptr;
}
type
:此对象的类型,分别为string、list、set、zset、hash
encoding
:此对象的底层实现,上述SDS、Intset、Dict、SkipList…有11个值。
lru
:记录当前Redis对象最近一次访问时间,以便将长时间未使用的对象回收。
refcount
:对象引用计数器,计数器为0则说明对象无人使用,可以被回收。
*ptr
:指向具体存放数据的空间。
type和encoding字段是本篇文章关注的地方。
先来看看encoding字段的11个值 :
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_INT |
long类型的整数字符串 |
1 | OBJ_ENCODING_RAW |
raw编码的动态字符串 |
2 | OBJ_ENCODING_EMBSTR |
embstr编码的动态字符串 |
3 | OBJ_ENCODING_INTSET |
整数集合 |
4 | OBJ_ENCODING_ZIPLIST |
压缩列表 |
5 | OBJ_ENCODING_QUICKLIST |
快速列表 |
6 | OBJ_ENCODING_SKIPLIST |
跳表 |
7 | OBJ_ENCODING_LINKEDLIST |
双端链表 |
8 | OBJ_ENCODING_HT |
字典 |
9 | OBJ_ENCODING_ZIPMAP |
已废弃 |
10 | OBJ_ENCODING_STREAM |
Stream流 |
可以看到,string有三种编码方式,list有3中编码方式,set有三种编码方式。
string是Redis最常见的数据存储类型。
object head与SDS是一片连续空间
,申请内存时只需要调用一次内存内存分配函数,效率更高。可以看出明显区别 :EMBSTR的空间是连续的,因为字符串体积小,容易申请连续空间。
以下向Redis存储四个值,分别为 数字、短字符串、44位字符串、45位字符串,查看的编码方式结果如下:
yun:0>set a 12
"OK"
yun:0>object encoding a
"int"
yun:0>set b xiaoming
"OK"
yun:0>object encoding b
"embstr"
yun:0>set c 01234567890123456789012345678901234567890123
"OK"
yun:0>object encoding c
"embstr"
yun:0>set d 012345678901234567890123456789012345678901234
"OK"
yun:0>object encoding d
"raw"
list拥有三种编码方式 :linkedlist、ziplist、quicklist。自从QuickList出现后就很少用LinkedList了。
所以在Redis3.2版本后,Redis统一采用QuickList实现List。
当存储的数据全部为整数,且元素数量不超过set-max-intset-emtries时,set使用intset编码
其他情况使用Dict编码,使用它的key,value统一为null。
zset也就是sortedset,每一个元素都有一个用于排序的score值。zset的特点 :
看起来可以用SkipList,但是跳表的键不唯一,而且它的score虽然可以排序,但无法查询。
看起来可以用Dict,但Dict无法排序。
那么我们将它们结合起来不就行了?一个zset包含一个SkipList和一个Dict,添加元素时先向Dict添加,保证元素唯一,如果元素唯一再向SkipList添加,保证可排序。
typedef struct zset {
dict *dict;
zskiplist *zsl;
}
虽然zset使用了两种结构,但它的编码写成skiplist。
但是当元素数量小于128或元素大小小于64字节时,zset会使用ZipList节省空间。
Hash结构与Redis中的Zset很像
二者差别例如zset的值必须是数字,用于排序。而Hash结构不用排序。
Hash底层采用的编码与Zset基本一致,只需要把排序有关的SkipList去掉即可 :
缓存
分布式锁
计数器
。当数据是数字时,string不会创建新的SDS对象,而是将数字存储在RedisObject的ptr上,非常节省空间。用于计数器且并发量高的情况下,假如用于文章浏览量,你的服务拆分为10台机器,这10台机器可能同时抢夺浏览量为100的这个数字,那么只有一台机器可以成功,剩下9台都会失败,效率太低。此时我们可以使用INCRBY
,一台机器过来,我们给它100个浏览量,它自己用去吧,Redis中的数据直接INCRBY 100即可。这样就可以提高效率。
list支持双端插入、访问
用作信息流。例如你关注很多公众号,这些公众号发的文章都可以塞进你的list中
很多人用set的原因估计就是值唯一吧,这个就可以用作点赞列表、好友列表、关注列表、收藏等等…
同时它可以求交集、并集,这样就支持“共同好友”、“你可能认识的人”等等操作
zset可以根据score排序,同时保证值唯一。常用于排行榜。
可用于购物车,hash的操作很契合“购物车”这一功能需求 :增加删除商品、增加减小某个商品的数量、选中全部、获取全部商品的数量…