redis底层结构
转载:https://www.zhihu.com/people/liu-shuai-99-72
SDS
因为redis使用c语言开发,所以自然没有java和c++的那些字符串类库,在redis中,其自己定义了一种字符串格式,叫做SDS(Simple Dynamic String),即简单动态字符串。
这个结构定义在sds.h中:
typedef char *sds;
但是这个sds类型仅作为参数和返回值使用,并不是真正用于操作的类型,真正核心的部分是下面的这些类:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len;
uint8_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len;
uint64_t alloc;
unsigned char flags;
char buf[];
};
除掉第一个结构体(已经弃用),sds具体类型的结构可以分为以下部分:
- len:已使用的长度,即字符串的真实长度
- alloc:除去标头和终止符(’\0’)后的长度
- flags:低3位表示字符串类型,其余5位未使用(我暂时没发现redis在哪里使用过这个属性)
- buf[]:存储字符数据
这里和老版本做一下对比,因为我手头只有4.x和5.x的版本,它们sds的实现是一致的,但是据其他人说sds之前的版本实现方式不同,有时间我会去下载下来看一下,其将字符串分为以下部分:
- len:buf中已经占有的长度(表示此字符串的实际长度)
- free:buf中未使用的缓冲区长度
- buf[]:实际保存字符串数据的地方
redis同时写重写了大量的与sds类型相关的方法,那redis为什么要这么下功夫呢,有以下4个优点:
-
c语言中的字符串并不会记录自己的长度,因此每次获取字符串的长度都会遍历得到,时间的复杂度是O(n),而Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。
-
SDS还提供空间预分配和惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。
-
当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过
free
属性将不使用的空间记录下来,等后面使用的时候再释放。具体的空间预分配原则是:当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB。
-
-
c语言中两个字符串拼接,若是没有分配足够长度的内存空间就会出现缓冲区溢出的情况;而SDS会先根据len和alloc属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以不会出现缓冲区溢出的情况。
-
SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。
LinkedList 双向链表
当链表中entry结点的数量超过512个、或单个value 长度超过64字节,底层就会转化成linkedlist编码。linkedlist是标准的双向链表,Node节点包含prev和next指针,可以进行双向遍历。还保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为(1)——这是高效实现 LPUSH 、 RPOP、 RPOPLPUSH 等命令的关键。linkedlist结构比较简单。
Qicklist
3.2版本开始采用quickList作为list的底层实现,结合了ziplist和LinkedList的优点。其核心数据结构如下:
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* ziplist所有节点的个数*/
unsigned long len; /* quicklistNode节点的个数*/
int fill : 16; /*单个节点的填充因子*/
unsigned int compress : 16; /*压缩端结点的深度*/
} quicklist;
我们可以明显地看出,quicklist是一个双向链表的结构,但是内部又涉及了ziplist,我们可以这么说,在宏观上,quicklist是一个双向链表,在微观上,每一个quicklist的节点都是一个ziplist。
在redis.conf中,可以使用下面两个参数来进行优化:
- list-max-ziplist-size:表示每个quicklistNode的字节大小。默认为2,表示8KB
- list-compress-depth:表示quicklistNode节点是否要压缩。默认为0,表示不压缩
这种存储方式的优点和链表的优点一致,就是插入和删除的效率很高,而链表查询的效率又由ziplist来进行弥补,所以quicklist就成为了list数据结构的首选
Dict 字典
字典作为一种常用的数据结构,也被内置在很多编程语言中,比如 Java 的 HashMap 和 Python 的 dict。适于存储大规模的数据,其格式如下:
typedef struct dict {
dictType *type; /*指向自定义类型的指针,可以存储各类型数据*/
void *privdata; /*私有数据的指针*/
dictht ht[2]; /*两个hash表,一般只有h[0]有效,h1[1]只在rehash的时候才有值*/
long rehashidx; /*-1:没有在rehash的过程中,大于等于0:表示执行rehash到第几步*/
unsigned long iterators; /*正在遍历的迭代器个数*/
} dict;
- type 是一个指向 dict.h/dictType 结构的指针,保存了一系列用于操作特定类型键值对的函数
- privdata 保存了需要传给上述特定函数的可选参数
- ht[2]两个hash表,使用两个hash表的作用之后会说明
- rehashidx 用于标记rehash的进度,若当前这个值为-1,则表示字典没有在执行rehash操作
- Iterators 记录正在迭代的迭代器的数量
哈希表
typedef struct dictht{
//哈希表的数组
dictEntry **table;
//哈希表的大小
unsigned long size;
//哈希表的大小的掩码,用于计算索引值,总是等于 size-1
unsigned long sizemasky;
//哈希表中已有的节点数量
unsigned long used;
}
如果我们不想更深入的话了解到这种程度就可以了,其中真正存储数据的是dictEntry结构,如下:
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
- key表示键,v表示值
- next是指向下一个结点的指针,因为这里的hash表是通过拉链法来解决冲突的。
哈希算法
哈希表添加一个元素首先需要计算当前键值的 hash 值,之后根据 hash 值来定位即将它即将被放入的槽。由于 hash 值可能冲突,因此 hash 算法的选择尤其重要,要将 key 值打散的足够均匀。
Redis 选用了业内的一些算法来实现 hash 过程。
- 在 Redis 5.0 以及4.0 版本,都使用了 siphash 哈希算法。siphash 可以在输入的 key 值很小的情况下,产生随机性比较好的输出。
- 在 Redis 3.2,3.0 以及2.8 版本,使用 Murmurhash2 哈希算法,Murmurhash 可以在输入值是有规律时,也能给出比较好的随机分布。
当然以上两个算法,都有一个共同点,就是计算性能很好,这才符合 Redis 的产品特性。hash 结束之后,会根据当前哈希表的长度,来确定当前键值所在的 index,而由于长度有限,那么迟早会产生两个键值要放到同一个位置的问题,也就是常说的 hash 冲突问题。
哈希冲突
既然是哈希表,那么就也有 hash 冲突问题。Redis 的哈希表处理 Hash 冲突的方式和 Java 中的 HashMap 一样,选择了分桶的方式,也就是常说的链地址法。Hash 表有两维,第一维度是个数组,第二维度是个链表,当发生了 Hash 冲突的时候,将冲突的节点使用链表连接起来,放在同一个桶内。
由于第二维度是链表,我们都知道链表的查找效率相比于数组的查找效率是比较差的。那么如果 hash 冲突比较严重,导致单个链表过长,那么此时 hash 表的查询效率就会急速下降。
扩容与缩容
当哈希表过于拥挤,查找效率就会下降,当 hash 表过于稀疏,对内存就有点太浪费了,此时就需要进行相应的扩容与缩容操作。负载因子是用来描述哈希表当前被填充的程度。计算公式是:负载因子=哈希表以保存节点数量/哈希表的大小.
在 Redis 的实现里,扩容缩容有三条规则:
- 当 Redis 没有进行 BGSAVE 相关操作,且负载因子>1的时候进行扩容。
- 当负载因子>5的时候,强行进行扩容。
- 当负载因子<0.1的时候,进行缩容。
根据程序当前是否在进行 BGSAVE 相关操作,扩容需要的负载因子条件不相同。这是因为在进行 BGSAVE 操作时,存在子进程,操作系统会使用写时复制(Copy On Write)来优化子进程的效率。Redis 尽量避免在存在子进程的时候进行扩容,尽量的节省内存。
rehash
扩容期间涉及到到 rehash 的问题。因为需要将当前的所有节点挪到一个大小不一致的哈希表中,且需要尽量保持均匀,因此需要将当前哈希表中的所有节点,重新进行一次 hash,也就是 rehash。在 Java 的 HashMap 中,实现方式是新建一个哈希表,一次性的将当前所有节点 rehash 完成,之后释放掉原有的 hash 表,而持有新的表。而 Redis 使用了一种名为渐进式 hash 的方式来满足自己的性能需求。
rehash 需要重新定位所有的元素,这是一个 O(N)效率的问题,当对数据量很大的字典进行这一操作的时候,比较耗时。
Redis 实现了渐进式 hash.过程如下:
- 假如当前数据在 ht[0]中,那么首先为 ht[1]分配足够的空间;
- 在字典中维护一个变量,rehashindex = 0.用来指示当前 rehash 的进度;
- 在 rehash 期间,每次对字典进行增删改查操作,在完成实际操作之后,都会进行一次 rehash 操作,将 ht[0]在rehashindex位置上的值 rehash 到 ht[1]上。将 rehashindex 递增一位;
- 随着不断的执行,原来的 ht[0]上的数值总会全部 rehash 完成,此时结束 rehash 过程。将 rehashindex 置为-1。
rehash过程中,如何执行的增删改查呢
- 增加操作:直接将数据添加到dictht[1]当中
- 修改操作:首先寻找数据在不在dictht[0]当中,若存在,则修改,否则去dictht[1]当中去找,若存在则修改。
- 删除操作:首先寻找数据在不在dictht[0]当中,若存在,则删除;否则去dictht[1]当中去找,然后执行删除操作。
- 查找操作:首先寻找数据在不在dictht[0]当中,若存在,则返回;否则去dictht[1]当中去找,然后执行返回操作。
上面增删改查的操作,保证了dictht[0]当中的数据只会减少不增加,最终就没有数据了。
渐进式rehash的优缺点
优点:采用了分而治之的思想,将 rehash 操作分散到每一个对该哈希表的操作上以及定时函数上,避免了集中式rehash 带来的性能压力。
缺点:在 rehash 的时间内,需要保存两个 hash 表,对内存的占用稍大,而且如果在 redis 服务器本来内存满了的时候,突然进行 rehash 会造成大量的 key 被抛弃。
注:假如这个服务器很空余呢?中间几小时都没有请求进来,那么同时保持两个 table,岂不是很浪费内存?
解决办法是:在 redis 的定时函数里,也加入帮助 rehash 的操作,这样子如果服务器空闲,就会比较快的完成 rehash。
Ziplist(压缩列表)
压缩列表的定义为:
压缩列表设计的初衷就是为了节约内存。它是由一系列特殊编码的内存块构成的,使用一块连续的内存空间存储。每个元素长度不同,采用的是变长编码。
如何起到节约内存的呢?
假设列表中的元素内容都很小,但是如果是双向列表的话就需要维护头尾两个指针,这是很浪费空间的。所以zipList在结构上可以得到上一个结点的长度和当前结点的长度。那么通过上一个结点的长度,就可以将指针定位到上一个元素起始的位置,而通过当前结点的长度,就可以将指针定位到下一个元素的起始位置。
ziplist内部采取数据压缩的方式进行存储,压缩方式就不是重点了,我们仅从宏观来看,ziplist类似一个封装的数组,通过zltail可以方便地进行追加和删除尾部数据、使用entries可以方便地计算长度。但是其依然有数组的缺点,就是当插入和删除数据时会频繁地引起数据移动,所以就引出了quicklist数据类型。
struct ziplist<T>{
//整个压缩列表占用字节数
int32 zlbytes;
//最后一个节点到压缩列表起始位置的偏移量,可以用来快速的定位到压缩列表中的最后一个元素
int32 zltail_offset;
//压缩列表包含的元素个数
int16 zllength;
//元素内容列表,用数组存储,内存上紧挨着
T[] entries;
//压缩列表的结束标志位,值永远为0xFF.
int8 zlend;
}
为什么需要zltail_offset这个属性,因为压缩列表只能顺序遍历,所以为了提升效率,我们需要可以从首尾双端来遍历,用这个属性可以很快的找到压缩列表的尾部。
压缩列表节点的定义
struct entry{
//前一个 entry 的长度
int<var> prevlous_entry_length;
//编码方式
int<vat> encoding;
//内容
optional bute[] content;
}
-
prevlous_entry_length
为了反向遍历而记录的。首先拿到尾部节点的偏移量,找到最尾部的节点,然后调用prevlous_entry_length属性,就可以拿到前一个节点,然后不断向前遍历了。这个字段的长度并不是一定的,它可以是1 个字节,也可以是5 个字节。当前一个 entry 的长度在254 字节以内的时候,这个属性用一个字节来记录。否则就会用5 个字节来记录。
-
encoding
记录了节点的 content 属性所保存的数据的类型以及长度。
-
content
用来真正的保存节点的值,可以是一个字节数组或者整数。它的类型和长度由 encoding 来决定。
新增节点
某些情况下列表键会使用压缩列表,就是在列表键的内容比较少时,那么压缩列表为什么不能用于大的列表键呢?
ziplist 是连续存储的数据结构,内存是没有冗余的(前面的文章讲过的 SDS 中就有冗余空间),也就是说,每一次新增节点,都需要进行内存申请,然后将如果当前内存连续块够用,那么将新节点添加,如果申请到的是另外一块连续内存空间,那么需要将所有的内容拷贝到新的地址。
也就是说,每一次新增节点,都需要内存分配,可能还需要进行内存拷贝。当 ziplist 中存储的值太多,内存拷贝将是一个很大的消耗。也是因此,Redis 只在一些数据量小的场景下使用 ziplist.
问题:级联更新
在讲prevlous_entry_length的时候,我们提到它的长度变化会导致一个问题,那就是级联更新。
当前一个 entry 的长度在254 字节以内的时候,这个属性用一个字节来记录。否则就会用5 个字节来记录。那么我们设想一个极端的场景,在这个 ziplist 内部,所有的节点的长度都是253 字节,也就意味着所有节点的prevlous_entry_length属性都是一个字节。
此时,我们给压缩列表最前端插入一个大于254 字节的节点,那么此时原来的第一个节点的prevlous_entry_length属性会从1 个字节变成5 个字节,这个节点的总长度也就来到了257 字节,大于254 字节,那么下一个节点(原来的第二个节点)的prevlous_entry_length属性也会变成5 个字节,这又会导致下一个节点的变化。引起连锁变化,所有节点的prevlous_entry_length值都需要更新一遍。
级联更新的时间复杂度很差,最多需要进行 N 次空间的重分配,每次空间的重分配最差需要 O(N),所以级联更新的时间复杂度最差是 O(N2)。与新增节点相似,删除节点也有可能会造成级联更新的情况。
但是其实不用怕,因为级联更新造成 Redis 性能压力的概率极其低。首先,级联更新需要连续的节点大小为250-253之间,这本就少见,而大范围的连续就更加少见了。如果运气不好出现了三五个的级联更新,也绝不会对 Redis 的性能有压力。
总结
ziplist 是 Redis 单独开发,用连续的内存空间来存储 list 的一个数据结构。它的优势是没有链表的前后指针的内存占用,但是在数据量大的时候,性能有压力。因此只用于数据量小的场景。
ziplist 是 list 键和 hash 键的底层实现数据结构之一。
ziplist 有一个问题,就是添加节点或者删除节点,有极小的概率会触发级联更新,引起性能差异。但是这个事真的极小概率,不用担心。
Intset
在redis中,我们可以查看intset.h
文件,这是一个存储整数的集合,其结构如下:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
其中各字段含义如下:
- encoding:数据编码格式,表示每个数据元素用几个字节存储(可取的值有2、4,和8)
- length:元素个数
- contents:柔性数组,这部分内存单独分配,不包含在intset中
整数集合的升级
整数集合本身就是来存储整数的,为什么还需要编码方式?
每当一个整数被添加到整数集合时,都需要先去判断这个整数是否大于当前编码方式所能容放的最大整数,如果大于,就需要对当前的整数集合进行升级。
升级是指什么呢?假如当前的整数集合中只有一个数字2.那么我们用16 位的整数的数组就可以放下。
当此时进来一个大于32767(16 位整数的最大值)的整数,我们就需要将当前的整数数组升级成一个32 位整数的数组,同时,要将原来的所有整数转换成新的编码。
对于64 位的升级类似于上面这样。
整数集合分级的好处
- 用能容纳数字的最小编码进行存储,可以有效的节约内存。
- 整数集合封装了对三种整数之间的转换,使用我们不用考虑类型错误,可以不断的向整数集合内添加整数。提升了操作的灵活性。
不支持降级
与升级相对应的,当大的数字被删除之后,整数集合不会进行降级。
如果集合过大,会采用dict的方式来进行存储
Skiplist 跳表
在zsetkey中放入了两个简单的值时,编码为ziplist,而当我插入一个较长的值,zset 的编程方式成为了 skiplist。
typedef struct zskiplist{
//表头结点和尾节点
struct zskiplistNode *header,*tail;
//表中节点的数量
unsigned int length;
//表中层数最大的节点的层数
int level;
} zskiplist;
其中header, tail可以在 O(1)的时间复杂度内定位到跳跃表的头部和尾部,length可以在 O(1)时间复杂度内得到跳跃表的长度。level可以知道当前跳跃表最高的层,从而开始从高向低进行查找。
其中 skiplistNode 的节点的定义为:
typedef struct zskiplistNode{
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
} level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode;
zskiplistLevel这个结构是一个数组,用一个数组来保存本节点,以及本节点在所有层的索引.
每个索引中:
-
forward:指向右侧的指针,可以在当前层,继续向右走
-
跨度:可以用它来计算当前节点在跳跃表中的一个排名,这就 zset 提供了查看排名的功能。
-
backward:后退的指针,如果在高层索引向右走的太多了,可以用后退指针来向后退。
-
score and obj:这两个属性用来保存当前节点的真正值以及分值
层级问题
在 Java 中的ConcurrentSkipListMap的实现中,索引每一次向上升级或者不升级,都是随机的,因此:
- 一个节点是否是一级索引的概率是50%
- 是否是二级索引的概率是25%…
而在 Redis 中,新添加一个节点时,会给该节点随机一个索引层数,而且概率是25%.之后将该节点的各层索引与左右的索引相链接。由于概率是25%,因此 Redis 的跳跃表相对于 Java 中的跳跃表,结构更加扁平一些,在查找的时候,在同级索引上可能需要多查询几个。
也是因为结构扁平,因此索引的数量并不是完全的等同于节点数,额外的内存占用只有50%.可以为 Redis 服务器节省一点内存。
顺序问题
在 zset 中,是可以存储分数一样的值的,此时内部如何存储?直接进行无序存储吗?
如果是这样,当一个 zset 中,所有元素的分值都一样,跳跃表表的性能就会退化成链表的性能吗?
不是这样的,Redis 除了按照分值排序之外,还会按照字符串的字典序来存储。
排名问题
前面提到了跨度这个属性,当我们需要查找某个元素的排名时,跳跃表首先开始一次查询过程,找到该节点时,也可以找到从顶层索引找到该节点的查找路径,将路径上的所有节点的跨度值相加就是该节点的排名。
总结
Redis 的跳跃表,和其他语言实现的跳跃表,总体思路一样,在实现方式上有一些自己的小技巧。
跳跃表示有序集合键的底层实现之一,表中元素按照 score 大小进行排序,当 score 相同时,元素按照字符串的字典大小进行排序。
相比于 Java 的跳跃表,Redis 的跳跃表的索引层级更加扁平,可以节省一些内存。
listpack(紧凑列表
它是 Redis 的 Stream 用到的数据结构之一。
定义
Redis 设计listpack的目的就是取代 ziplist
struct ziplist<T>{
//整个压缩列表占用字节数
int32 zlbytes;
//最后一个节点到压缩列表起始位置的偏移量,可以用来快速的定位到压缩列表中的最后一个元素
int32 zltail_offset;
//压缩列表包含的元素个数
int16 zllength;
//元素内容列表,用数组存储,内存上紧挨着
T[] entries;
//压缩列表的结束标志位,值永远为0xFF.
int8 zlend;
}
listpack的定义和上方基本一致,只是去掉了zltail_offset属性。
让我们回想一下,ziplist 中用这个属性做什么?用来方便的找到最后一个节点,然后方便进行反向的遍历。新的 listpack 当然是解决了这个问题,才能放心的删除掉这个属性。
listpack节点的定义如下:
int<var> encoding;
optional byte[] content;
int<var> length;
相比于 ziplist 的定义,它有两点改动:
- 记录的长度不再是前一个节点的长度,而是自己的长度。
- 将记录自己的长度放到了节点的尾部。
这样做的好处是:
- 不再需要 zltail_offset 属性也可以快速定位到最后一个节点。用listpac 的总长度-最后一个节点的长度.
- 每个节点记录自己的长度,当本节点的值发生了改变,只需要更改自己的长度即可。不再需要更改别的节点的属性,也就彻底的解决掉了级联更新问题。
总结
listpack 是 Redis 设计用来取代掉 ziplist 的数据结构,它通过每个节点记录自己的长度,且放在节点的尾部,来彻底解决掉了 ziplist 存在的级联更新的问题。
listpack 在5.0 版本引入,但是由于 ziplist 在 Reids 内部的使用太过于广泛,有一些兼容问题,我们可以预见这将是一个逐步的替换过程。
同样在5.0 版本引入的 Stream 数据结构中,就使用了 listpack 而不是 ziplist.
Redis作为一个开源的用C编写的非关系型数据库,基于优秀的CRUD效率,常用于软件系统的缓存,其本身提供了以下五种数据格式:
- string:字符串
- list:列表
- hash:散列表
- set:无序集合
- zset:有序集合
字符串对象 String
涉及到的数据结构SDS。
字符串对象的底层实现有三种可能:int, raw, embstr.
int
如果一个字符串对象,保存的值是一个整数值,并且这个整数值在 long 的范围内,那么 redis 用整数值来保存这个信息,并且将字符串编码设置为int
.
比如:
raw
如果字符串对象保存的是一个字符串,并且长度大于32 个字节,它就会使用前面讲过的SDS(简单动态字符串)数据结构来保存这个字符串值,并且将字符串对象的编码设置为
raw.
embstr
如果字符串对象保存的是一个字符串,但是长度小于32 个字节,它就会使用embstr
来保存了,embstr
编码不是一个数据结构,而是对 SDS 的一个小优化,当使用 SDS 的时候,程序需要调用两次内存分配,来给字符串对象和 SDS 各自分配一块空间,而embstr
只需要一次内存分配,因为他需要的空间很少,所以采用连续的空间保存,即将 SDS 的值和字符串对象的值放在一块连续的内存空间上。这样能在短字符串的时候提高一些效率。
比如:
浮点数如何保存?
redis 的字符串数据类型是支持保存浮点数,并且支持对浮点数进行加减操作,但是 redis 在底层是把浮点数转换成字符串值,之后走上面三种编码的规则的。对浮点数进行操作时,也是从字符串转换成浮点数进行计算,然后再转换成字符串进行保存的。
编码转换条件
这块的知识其实是很符合我们的认知的,比如int
编码只可以保存整数,那么当我们对一个 int 编码的字符串对象,修改它的值,它自然就会使用 raw 编码了。
但是有一个特性,Redis 没有为embstr
编码提供任何的修改操作,这也就是为什么它只是个编码而不是一个数据结构的原因。
所以在 Redis 中,embstr
编码的值其实是只读的,只要发生修改,立刻将编码转换成raw
.
总结
字符串对象底层的数据结构或者说编码有三种,分别是int
,raw
,embstr
.他们之间的使用条件如下:
编码|使用条件
int |可以用 long 保存的整数
embstr |字符串长度小于32 字节(或者浮点数转换后满足)
raw |长度大于32 的字符串
列表对象 List
涉及到的数据结构,压缩列表,双向链表,快速列表。
在 Redis 3.2 版本之前,列表对象底层由压缩列表和双向链表配合实现,当元素数量较少的时候,使用压缩列表,当元素数量增多,就开始使用普通的双向链表保存数据。
但是这种实现方式不够好,双向链表中的每个节点,都需要保存前后指针,这个内存的使用量对于 Redis 这个内存数据库来说极其不友好。
因此在3.2 之后的版本,作者新实现了一个数据结构,叫做quicklist.所有列表的底层实现都是这个数据结构了。它的底层实现基本上就是将双向链表和压缩列表进行了结合,用双向的指针将压缩列表进行连接,这样不仅避免了压缩列表存储大量元素的性能压力,同时避免了双向链表连接指针占用空间过多的问题。
总结
编码|使用条件
quicklist |所有数据
集合对象 Set
涉及到的数据结构:intset,dict(hashtable)。
集合对象的编码可以是intset或者hashtable(字典).
intset
当集合中的所有元素都是整数,且元素的数量不大于512 个的时候,使用 intset 编码。
intset 编码时,底层使用intset
数据结构。
hashtable
当元素不符合全部为整数值且元素个数小于512`时,集合对象使用的编码方式为hashtable.
字典的每一个键都是一个字符串对象,其中保存了集合里的一个元素,字典的值全部被设置为 NULL.
总结
编码|使用条件
intset |所有元素都是整数且元素个数小于512
hashtable |其他数据
有序集合对象 Zset
涉及到的数据结构,压缩列表,跳跃表,字典。
有序集合对象的编码可以是ziplist以及skiplist.
ziplist 编码
当使用 ziplist 编码时,有序集合对象的实现数据结构为ziplist
(听起来像句废话),每个集合的元素(key-value),使用两个紧挨着的压缩列表的节点来表示,第一个节点保存集合元素的成员(member),第二个节点保存集合元素的分支(score).
在压缩列表的内部,集合元素按照分值从小到大进行排序。
skiplist 编码
当使用 skiplist 编码的时候,内部使用zset
来实现数据的保存,zset
的定义如下:
typedef struct zset{
zskiplist *zsl;
dict *dict;
}zset;
为什么需要同时使用跳跃表以及字典呢?
其实如果我们细想,单独使用字典或者跳跃表,都是可以实现有序集合的所有功能的,但是性能太差劲了。
- 当我们只使用字典来实现,我们可以以 O(1)的时间复杂度获取成员的分值,但是由于字典是无序的,当我们需要进行范围性操作的时候,需要对字典中的所有元素进行排序,这个时间复杂度至少需要 O(nlogn).
- 当我们只使用跳跃表来实现,我们可以在 O(logn)的时间进行范围排序操作,但是如果要获取到某个元素的分值,时间复杂度也是 O(logn).
因此,将字典和跳跃表结合进行使用,可以在 O(1)的时间复杂度下完成查询分值操作,而对一些范围操作,使用跳跃表可以达到 O(logn)的是缠绵复杂度。
可以看到,我在上一次的例子中,添加了一个很长的 key 之后,有序集合的编码方式就成为了skiplist
.
总结
编码|使用条件
ziplist |元素数量少于128 且所有元素成员的长度小于64 字节
skiplist |不满足上述条件的其他情况
散列对象 Hash
涉及到的数据结构,压缩列表,字典。
哈希对象的编码可以是ziplist或者hashtable.
ziplist 编码
ziplist 编码下的哈希对象,使用了压缩列表作为底层实现数据结构,用两个连续的压缩列表节点来表示哈希对象中的一个键值对。实现方式类似于上面的有序集合的场景。
如图中所示,当我放入了两个简单的键值对,此时哈希对象的编码为 ziplist.
hashtable 编码
这是对 hashtable 最直观的应用了
哈希结构本身在结构上和字典(hashtable)就颇为相似,因此哈希对象中的每一个键值对都是字典中的一个键值对。
- 字典的每一个键都是一个字符串对象,对象中保存了键值对的键。
- 字典的每一个值都是一个字符串对象,对象中保存了键值对的值。
如图中所示,当我在上一个示例中额外加入一个很长的值,那么编码方式就来到了hashtable.
总结
hash这种结构在redis的使用时最为常见,在redis中,hash这种结构有两种表示:ziplist和dict。
hash对象只有同时满足以下条件,才会采用ziplist编码:
- hash对象保存的键和值字符串长度都小于64字节
- hash对象保存的键值对数量小于512 ziplist存储的结构如下
全文总结
基础数据类型|可能的编码方式
- 字符串| int, raw, embstr
- 列表|之前是 ziplist 和 linkedlist,现在是 quicklist ;
- 集合| intset 或者 hashtable
- 有序集合| ziplist 或者skiplist, skiplist 编码中使用了跳跃表+字典
- 散列| ziplist 或者 hashtable
至于他们的转换条件,如下: