深入理解redis的hash与set以及中间的zipset

在这里插入图片描述

list:(双向链表和压缩列表)

Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的循序插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n),这点让人非常意外。 当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。 Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符 串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。

数据结构为quicklist,首先列表中数据比较少时会使用一块连续的内存存储,这个结构是ziplist,也即是压缩链表,他将所有的元素紧挨着一起存储,分配的是一块的内存,当数据量比较多时才改成quiclist,

即双向链表,将很多个ziplist双向链接。

虽然lists有这样的优势,但同样有其弊端,那就是,链表型lists的元素定位会比较慢,而数组型string的元素定位就会快得多

(只要有关定位的都很慢)。

可以实现

队列:左近右出 lpush/rpop

栈:右进右出 rpush/rpop

查询
llen key//长度

lrange key <start><end>//取值 <0><-1>取所有的值

lindix key index //按照索引下标获得元素

linsert key before value newvalue //在value后面加newvalue
添加、更新、删除、
lpush/rpush key values //从左或从右插入一个或者多个值

lpop/rpop key //从左边或者右边吐出值,键也消失

rpoplpush//右吐出的左加入

lrem key n value //从左边删除n个value

lset key value1 value2 //将列表key下标为value1的值替换value2
慢操作 (链表的查询)

lindex 相当于 Java 链表的 get(int index)方法,它需要对链表进行遍历,性能随着参数 index 增大而变差。

ltrim 和字面上的含义不太一样,个人觉得它叫 lretain(保留) 更合适一 些,因为 ltrim 跟的两个参数 start_index 和 end_index 定义了一个区间,在这个区间内的值, ltrim 要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常 有用。index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元 素。

> rpush books python java golang 
(integer) 3 
> lindex books 1 # O(n) 慎用
"java" 
> lrange books 0 -1 # 获取所有元素,O(n) 慎用
1) "python" 
2) "java" 
3) "golang" 
> ltrim books 1 -1 # O(n) 慎用
OK 
> lrange books 0 -1 
1) "java" 
2) "golang" 
> ltrim books 1 0 # 这其实是清空了整个列表,因为区间范围长度为负
OK 
> llen books 
(integer) 0

快速列表:quicklist

如果再深入一点,你会发现 Redis 底层存储的还不是一个简单的 linkedlist,而是称之为 快速链表 quicklist 的一个结构。 首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是 压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。当数据量比较多的 时候才会改成 quicklist。

因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且 会加重内存的碎片化。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next 。所以 Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是元素少时用 ziplist,元素多时用 linkedlist。

// 链表的节点
struct listNode<T> {
 listNode* prev;
 listNode* next;
 T value;
}
// 链表
struct list {
 listNode *head;
 listNode *tail;
 long length;
}

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的 指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管 理效率。后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

127.0.0.1:6379> debug object skey1

Value at:0x7f8f56e9fec0 refcount:1 encoding:quicklist serializedlength:21 lru:5149403 lru_seconds_idle:44 ql_nodes:1 ql_avg_node:2.00 ql_ziplist_max:-2 ql_compressed:0 ql_uncompressed_size:19

注意观察上面输出字段 encoding 的值。quicklist 是 ziplist 和 linkedlist 的混合体,它 将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串 接起来。

在这里插入图片描述

struct ziplist {
 int32 zlbytes; // 整个压缩列表占用字节数
 int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
 int16 zllength; // 元素个数
 T[] entries; // 元素内容列表,挨个挨个紧凑存储
 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct ziplist_compressed {
 int32 size;
 byte[] compressed_data;
}
struct quicklistNode {
 quicklistNode* prev;
 quicklistNode* next;
 ziplist* zl; // 指向压缩列表
 int32 size; // ziplist 的字节总数
 int16 count; // ziplist 中的元素数量
 int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
 ...
}
struct quicklist {
 quicklistNode* head;
 quicklistNode* tail;
 long count; // 元素总数
 int nodes; // ziplist 节点的个数
 int compressDepth; // LZF 算法压缩深度
 ...
}

上述代码简单地表示了 quicklist 的大致结构。为了进一步节约空间,Redis 还会对 ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度。

每个 ziplist 存多少元素?

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定。

在这里插入图片描述

​ quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 listcompress-depth 决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二 个 ziplist 都不压缩。

ziplist(压缩列表)(应该叫做压缩数组)

Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压 缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任 何冗余空隙。

表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量,以及列表中的entry个数。压缩列表尾还有一个zlend,表示列表结束。

struct ziplist<T> {
 int32 zlbytes; // 整个压缩列表占用字节数
 int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个
节点
 int16 zllength; // 元素个数
 T[] entries; // 元素内容列表,挨个挨个紧凑存储
 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

在这里插入图片描述

压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一 个元素,然后倒着遍历。 entry 块随着容纳的元素类型不同,也会有不一样的结构。

  • prev_len,表示前一个entry的长度。prev_len有两种取值情况:1字节或5字节。取值1字节时,表示上一个entry的长度小于254字节,否则取5字节。
  • len:表示自身长度,4字节;
  • encoding:表示编码方式,1字节;
  • content:保存实际数据。
struct entry {
 int<var> prevlen; // 前一个 entry 的字节长度
 int<var> encoding; // 元素类型编码
 optional byte[] content; // 元素内容
}

encoding 字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式。

Redis 为了节约存储空间,对 encoding 字段进行了相当复杂的设计。Redis 通过这个字 段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据 encoding 的前缀 位来区分内容的:

1、00xxxxxx 最大长度位 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字 节就是字符串的内容。

2、01xxxxxx xxxxxxxx 中等长度的字符串,后面 14 个位来表示字符串的长度,剩余的 字节就是字符串的内容。

3、10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 特大字符串,需要使用额外 4 个字节 来表示长度。第一个字节前缀是 10,剩余 6 位没有使用,统一置为零。后面跟着字符串内 容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。 4、11000000 表示 int16,后跟两个字节表示整数。

5、11010000 表示 int32,后跟四个字节表示整数。

6、11100000 表示 int64,后跟八个字节表示整数。

7、11110000 表示 int24,后跟三个字节表示整数。

8、11111110 表示 int8,后跟一个字节表示整数。

9、11111111 表示 ziplist 的结束,也就是 zlend 的值 0xFF。

10、1111xxxx 表示极小整数,xxxx 的范围只能是 (0001~1101), 也就是 1~13,因为 0000、1110、1111 都被占用了。读取到的 value 需要将 xxxx 减 1,也就是整数 0~12 就是 最终的 value。

ziplist 不适合存储大型字符串,存储的元素也不宜过多

因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插 入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存 大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。 如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。

联级修改

前面提到每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于 254 字节,prevlen 用 1 字节存储,否则就是 5 字节。

这意味着如果某个 entry 经过了修改 操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen 字段就要更新,从 1 个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的 prevlen 字段还得继续更新。 如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的 修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。

hash(字典):(哈希表和压缩列表)

Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞 时,就会将碰撞的元素使用链表串接起来。

在保存单值的键值对时,可以采用基于Hash类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为Hash集合的key,后一部分作为Hash集合的value,这样一来,我们就可以把单值数据保存到Hash集合中了。

在这里插入图片描述

不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为 Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。

在这里插入图片描述

渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务中以及 hash 的子指令中,循序渐进地将旧 hash 的内容 一点点迁移到新的 hash 结构中。

当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。 hash 结构也可以用来存储用户信息,不同于字符串一次性需要全部序列化整个对象, hash 可以对用户结构中的每个字段单独存储。这样当我们需要获取用户信息时可以进行部分获取。而以整个字符串的形式去保存用户信息的话就只能一次性全部读取,这样就会比较浪 费网络流量。

Redis Hash类型的两种底层实现结构,分别是压缩列表和哈希表

如果我们往Hash集合中写入的元素个数超过了hash-max-ziplist-entries,或者写入的单个元素大小超过了hash-max-ziplist-value,Redis就会自动把Hash类型的实现结构由压缩列表转为哈希表。

一旦从压缩列表转为了哈希表,Hash类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。

为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在Hash集合中的元素个数。所以,在刚才的二级编码中,我们只用图片ID最后3位作为Hash集合的key,也就保证了Hash集合的元素个数不超过1000,同时,我们把hash-max-ziplist-entries设置为1000,这样一来,Hash集合就可以一直使用压缩列表来节省内存空间了。

当然hash 也有缺点,hash 结构的存储消耗要高于单个字符串,到底该使用 hash 还是字符 串,需要根据实际情况再三权衡。

redis hash :键值对集合,是一个string类型的field和value的映射表,hash特别适用于存储对象

keyvalue
user(field) (value)
id 1
name 张三
age 20

设置(更新),查询

hset key field value//给key集合中的field键赋值value ,hset k5:f1 id 1
												:hset k5:f1 name zhang
field=f1 id

hsetnx  key field value//当field不存在时才能存

hget key field //得到一个

hmset key field id 1 name zhang age 23//设置多个值

hkeys key//输出所有field

hvals key //所有的值

增加值

hincrby key field n//加n
sadd key v1 v2 ...//将一个或者多个member元素加入集合key中,重复的将被忽略

srem key v1 v2....//删除元素

smembers key//取出该集合所有的值

scard key//返回集合个数(长度)

sismember key value //判断集合key是否有该value值,有1,无0

spop key//随机吐出一个值

srandmember key n//随机从集合中取出n个值,不会从集合中删除

smove k1 k2 value//将set k1中的值移动到k2中

sinter k1 k2//交集

sunion k1 k2//并集

sunionstore key key1 key2//得到key1,key2并集结果放在key中

sdiff k1 k2//差集

sdiffstore k k1 k2//求k1,k2的差集放在k中(k1中有,且不在k2中的值)

字典内部结构:

dict 是 Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到 字典外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典,还有带过期时间 的 key 集合也是一个字典。

zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结 构实现的。

struct RedisDb {
 dict* dict; // all keys key=>value
 dict* expires; // all expired keys key=>long(timestamp)
 ..
 }
 struct zset {
 dict *dict; // all values value=>score
 zskiplist *zsl;
}

在这里插入图片描述

dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存 储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。

在这里插入图片描述

所以,字典数据结构的精华就落在了 hashtable 结构上了。hashtable 的结构和 Java 的 HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。第一维是数组,第二维是链 表。数组中存储的是第二维链表的第一个元素的指针。

struct dictEntry {
 void* key;
 void* val;
 dictEntry* next; // 链接下一个 entry
}
struct dictht {
 dictEntry** table; // 二维
 long size; // 第一维数组的长度
 long used; // hash 表中的元素个数
 ...
}
渐进式 rehash

大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元 素重新挂接到新的数组下面,这是一个 O(n)级别的操作,作为单线程的 Redis 表示很难承受 这样耗时的过程。步子迈大了会扯着蛋,所以 Redis 使用渐进式 rehash 小步搬迁。虽然慢一 点,但是肯定可以搬完。

搬迁操作埋伏在当前字典的后续指令中(来自客户端的 hset/hdel 指令等),但是有可能客 户端闲下来了,没有了后续指令来触发这个搬迁,那么 Redis 就置之不理了么?当然不会, 优雅的 Redis 怎么可能设计的这样潦草。Redis 还会在定时任务中对字典进行主动搬迁。

hash 函数

hashtable 的性能好不好完全取决于 hash 函数的质量。hash 函数如果可以将 key 打散 的比较均匀,那么这个 hash 函数就是个好函数。Redis 的字典默认的 hash 函数是 siphash。siphash 算法即使在输入 key 很小的情况下,也可以产生随机性特别好的输出,而 且它的性能也非常突出。对于 Redis 这样的单线程来说,字典数据结构如此普遍,字典操作 也会非常频繁,hash 函数自然也是越快越好

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值