redis对外公开的有5种数据结构,分别是String,List,Hash,Set,ZSet。这几种底层分别用了不同的数据结构来实现。
redis介绍:
redis是一个开源的使用C语言编写的一个kv存储系统,是一个速度非常快的非关系内存数据库。它支持包括String、List、Set、Zset、hash五种数据结构。
与关系型数据库相比,redis的命令请求不需要经过查询分析器或查询优化器进行处理,也避免了更新数据时引起的随机读\写,这些慢操作。它直接读写内存中的数据,并且数据是按照一定的数据结构存储的。所以它的速度非常快。
Redis采用redisObjec结构来统一五种不同的数据类型,这样所有的数据类型就都可以以相同的形式在函数间传递而不用使用特定的类型结构。
typedef struct redisObject {
unsigned type:4; //保存信息的类型(String,List,Set,Zset,Hash)
unsigned encoding:4;//保存信息的编码方式(底层数据结构)
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
int refcount;//引用次数
void *ptr;//保存的指针
} robj;
/* Object types */
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
String:
由于redis是C语言写的,C语言并没有实现好的字符串类型,是用指针或则char数组实现的,C语言字符串结尾是以'\0'来标识。redis自定义了一种自定义结构,SDS(动态字符串)。
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
SDS的性质:
1.C 语言的字符串不会记录自己的长度,而是需要进行遍历获得,时间复杂度为 O(n) ,而 SDS 已经封装了 len 属性,直接读取 len 的值就可以获得长度,不需要遍历,时间复杂度 O(1) 。
2.二进制安全的(C语言是以\0来表示字符串结束的),如果二进制中有 \0 会结束字符串。所以redis是可以存储图片和视频的。
3. 如果修改后的 SDS 长度 len 小于 1MB,那么程序分配和 len 属性相等的未使用空间,此时 free 和 len 的值相同。所以此时数组的实际长度为 free + len + 1byte(额外的空字符 1 个字节)。
4. 如果修改后的 SDS 长度大于 1MB,那么程序分配 1MB 的未使用空间。实际长度为 len + 1MB + 1byte。
在扩展 SDS 之前,会检查未使用空间是否够用,如果足够,就不用内存重分配,直接使用剩余空间即可。
5.惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。
在redis中,key都是用sds这种来表示的,key最大是512M,value也是512M。
EMBSTR和RAM的区别:这两个的编码方式是一样的,不同之处是在于embstr是通过调用一次内存分配函数来分配一块内存空间,这一块内存空间包含了redisObject和sds两个结构;而raw是调用两次内存分配函数,分别来创建redisObject和sds。
采用一次内存分配的好处?
1.创建embstr字符串只需调用一次内存分配函数,释放的时候也只需调用一次。更快。
embstr的缺点:embstr是只读的,是不能修改的,一旦修改,会自动转raw格式。
当我们对于int编码的字符串对象修改,将其修改为一个不再是整数值,而是一个字符串值时,redis就会将该字符串对象的编码从int转为raw
当我们对于embstr编码的字符串对象执行修改时,由于embstr编码字符串对象是只读的,redis也会将其转为raw编码的字符串对象后再执行修改命令
List:
在3.2之前的版本,List底层是由ziplist和linkedlist实现,而在3.2之后,List都是由quicklist实现的。
ziplist(压缩列表):
域 | 长度/类型 | 域的值 |
---|---|---|
zlbytes | uint32_t | 整个 ziplist 占用的内存字节数,对 ziplist 进行内存重分配,或者计算末端时使用。 |
zltail | uint32_t | 到达 ziplist 表尾节点的偏移量。 通过这个偏移量,可以在不遍历整个 ziplist 的前提下,弹出表尾节点。 |
zllen | uint16_t | ziplist 中节点的数量。 当这个值小于 UINT16_MAX (65535 )时,这个值就是 ziplist 中节点的数量; 当这个值等于 UINT16_MAX 时,节点的数量需要遍历整个 ziplist 才能计算得出。 |
entryX | ? | ziplist 所保存的节点,各个节点的长度根据内容而定。 |
zlend | uint8_t | 255 的二进制值 1111 1111 (UINT8_MAX ) ,用于标记 ziplist 的末端。 |
域 | |
---|---|
pre_entry_length | 记录了前一个节点的长度,通过这个值,可以进行指针计算,从而跳转到上一个节点。
根据编码方式的不同,
|
encoding和length | 1、一字节、二字节或者五字节,值的最高位为00,01或者10的字节编码,这样的编码表示content保存的是字节数组,数组的长度保存在剩余的其他位。 2、一字节长,值的最位为11,表示整数编码,具体的整数类型对应如下: 11000000-int16_t,11010000-int32_t,11100000-int64_t,11110000-24位有符号整数 11111110-8位有符号整数,1111xxxx——xxxx用于保存0-12的整数。 |
content | content记录保存节点的值,节点值可以是一个字节数组或者整数。 |
ziplist是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。ziplist可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供push
和pop
操作。
实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片(频繁的插入删除),而且地址指针也会占用额外的内存。而ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。
另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。
ziplist的数据结构定义,我们介绍完了,现在我们看一个具体的例子。
上图是一份真实的ziplist数据。我们逐项解读一下:
- 这个ziplist一共包含33个字节。字节编号从byte[0]到byte[32]。图中每个字节的值使用16进制表示。
- 头4个字节(0x21000000)是按小端(little endian)模式存储的
<zlbytes>
字段。什么是小端呢?就是指数据的低字节保存在内存的低地址中(参见维基百科词条Endianness)。因此,这里<zlbytes>
的值应该解析成0x00000021,用十进制表示正好就是33。 - 接下来4个字节(byte[4..7])是
<zltail>
,用小端存储模式来解释,它的值是0x0000001D(值为29),表示最后一个数据项在byte[29]的位置(那个数据项为0x05FE14)。 - 再接下来2个字节(byte[8..9]),值为0x0004,表示这个ziplist里一共存有4项数据。
- 接下来6个字节(byte[10..15])是第1个数据项。其中,prevrawlen=0,因为它前面没有数据项;len=4,相当于前面定义的9种情况中的第1种,表示后面4个字节按字符串存储数据,数据的值为”name”。
- 接下来8个字节(byte[16..23])是第2个数据项,与前面数据项存储格式类似,存储1个字符串”tielei”。
- 接下来5个字节(byte[24..28])是第3个数据项,与前面数据项存储格式类似,存储1个字符串”age”。
- 接下来3个字节(byte[29..31])是最后一个数据项,它的格式与前面的数据项存储格式不太一样。其中,第1个字节prevrawlen=5,表示前一个数据项占用5个字节;第2个字节=FE,相当于前面定义的9种情况中的第8种,所以后面还有1个字节用来表示真正的数据,并且以整数表示。它的值是20(0x14)。
- 最后1个字节(byte[32])表示
<zlend>
,是固定的值255(0xFF)。
总结一下,这个ziplist里存了4个数据项,分别为:
- 字符串: “name”
- 字符串: “tielei”
- 字符串: “age”
- 整数: 20
LinkedList:双向链表,额外的信息多,占用内存,频繁的创建节点和删除节点,容易造成内存碎片。
quickList:相当于链表和ziplist的结合体。ziplist在我们程序里面来看将会是一块连续的内存块。它使用内存偏移来保存next从而节约了next指针。这样造成了我们每一次的删除插入操作都会进行remalloc,从而分配一块新的内存块。当我们的ziplist特别大的时候。没有这么大空闲的内存块给我们的时候。操作系统其实会抽象出一块连续的内存块给我。在底层来说他其实是一个链表链接成为的内存。不过在我们程序使用来说。他还是一块连续的内存。这样的话会造成内存碎片,并且在操作的时候因为内存不连续等原因造成效率问题。或者因为转移到大内存块等进行数据迁移。从而损失性能。
Hash:
ZipList是如何实现hash结构的?
ziplist是一个列表的结构,它分别存了key和value,查找的过程:
1.首先调用ziplistFind函数,在压缩列表中查找指定键对应的节点。
2.然后调用ziplistNext函数,将指针移动到键节点旁边的值节点,最后返回值节点。
为什么要从ziplist转dict结构?
ziplist每次插入或修改引发的realloc操作会有更大的概率造成内存拷贝,从而降低性能。
一旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更大的一块数据。
当ziplist数据项过多的时候,在它上面查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。(ziplist属于用时间换空间)
在数据量小的时候,ziplist和dict结构操作时间是相差不大的,可以接受的。但数据量增大,dict的查找,插入,删除,都接近O(1),相对于ziplist是有非常大的优势的。
Set:
intset:会根据插入的数据来进行编码升级。比如:在intset里都是int16,接下来要插入的是一个int32的数,那么intset里所有的数都会升级int32;
dict:key保存元素,value为null。
Zset:
ziplist:entry第一个节点保存元素成员,紧接着保存的是分值。
skiplist:
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 节点在该层和前向节点的距离
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
头结点默认层数是为32的,但描述符中指示跳跃表层数初始化为1。
层
跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候, 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小(redis最高是32层)。
图 5-2 分别展示了三个高度为 1 层、 3 层和 5 层的节点, 因为 C 语言的数组索引总是从 0 开始的, 所以节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。
跨度
层的跨度(level[i].span 属性)用于记录两个节点之间的距离:
两个节点之间的跨度越大, 它们相距得就越远。
指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。
初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。
举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。
后退指针
节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点。
图 5-6 用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点: 程序首先通过跳跃表的 tail 指针访问表尾节点, 然后通过后退指针访问倒数第二个节点, 之后再沿着后退指针访问倒数第三个节点, 再之后遇到指向 NULL 的后退指针, 于是访问结束。
分值和成员
节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。
在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。
举个例子, 在图 5-7 所示的跳跃表中, 三个跳跃表节点都保存了相同的分值 10086.0 , 但保存成员对象 o1 的节点却排在保存成员对象 o2 和 o3 的节点之前, 而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前, 由此可见, o1 、 o2 、 o3 三个成员对象在字典中的排序为 o1 <= o2 <= o3 。
跳表还可以快速的访问头尾节点。