当前位置:   article > 正文

【Redis】(一)一文读懂Redis基本数据类型及其常见应用场景与底层实现原理_redis应用场景及实现思路

redis应用场景及实现思路

前言

在Redis官网中,我们可以看到这样的一段介绍:

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如字符串(strings), 散列(hash), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。简单来说,Redis的数据结构主要分为五种基本的数据结构+三种高级的数据结构。

本文中,我们主要介绍一下Redis的五种基本数据类型:字符串(string), 散列(hash), 列表(list), 集合(set), 有序集合(sorted set)常用命令、底层实现以及相应的应用场景。在此之前先明确一个问题,Redis中的key只支持String类型,而具体key对应的数据才是上面介绍的数据类型,key设置规范一般为:表名:主键名:主键值:字段名,这点需要明确。

此外,在五种基本数据类型中,每种基本数据类型又有自己的编码,如下图所示:
在这里插入图片描述
上面我们了解到了关于Redis键的基本数据结构。而Redis中的所有value都是以object的形式存在的。其通用结构结构源码如下:

typedef struct redisObject {
    unsigned type:4; //表示值的数据类型,如string,list等
    unsigned encoding:4;//值的编码方式,就是其底层的数据结构实现,如string可以用int来实现也可以用char[]来实现。
    unsigned lru:LRU_BITS;//记录了对象最后一次被访问的时间,用于淘汰过期的键值对,用于有限内存下长时间不访问的对象清理。
    int refcount;//记录了对象的引用计数,用于GC。
    void *ptr;//指向数据的指针。比如ptr指向String,Zest。
} robj;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

一、String(字符串

1.1 String常用命令

命令格式描述
set key value将字符串值 value 关联到 key ,如果 key 已经持有其他值, SET 就覆写旧值,无视类型。可以配合EX/PX参数指定key的有效期,通过NX/XX参数针对key是否存在的情况进行区别操作,时间复杂度O(1)
setnx key value如果key不存在,则给key设置value,否则不进行任何操作
get key取key的值value,时间复杂度O(1)
getset key value为一个key设置value,并返回该key的原value,时间复杂度O(1)
del key删除key:删除操作成功 返回(integer)1;删除操作失败 返回(integer)0
mset k1 v1 k2 v2 ...一次性添加或修改多个键值对,时间复杂度O(N)
msetnx k1 v1 k2 v2 ...如果指定的key中有任意一个已存在,则不进行任何操作,时间复杂度O(N)
mget k1 k2 ...一次性获取k1 k2 k3…的value
strlen key获取key对应的v的字符串长度
append key value往key对应的value尾部追加数据,如果不存在就新建,这时候相当于set key value
incr key对应的value加1
decr key对应的value减1
incrby key increment对应的value+increment
decrby key increment对应的value-increment

1.2 String主要应用场景

1、缓存功能:字符串最经典的使用场景,redis作为缓存层,Mysql作为储存层,绝大部分请求数据都是 redis中获取,由于redis具有支撑高并发特性,所以缓存通常能起到加速读写和降低后端压力的作用。例如对于一篇文章,我们可以将其序列化为json对象之后存储到Redis中。
2、库存控制:主要是利用String的incr、decr等能进行原子操作的命令进行库存控制,常见用于分布式锁时进行库存预扣。
3、简单的计数器:同上利用String的incr、decr等原子性的操作,可以实现正着计数和倒计时操作,在并发下也可以保证一个线程安全的问题。如果我们常见的论坛,网站的点赞数或者视频播放数就是使用redis作为计数的基础组件。
4、接口限流:利用set结合expire过期时间限制某个用户在某段时间访问接口的次数。
5、共享Session:出于负载均衡的考虑,分布式服务会将用户信息的访问均衡到不同服务器上,这样可能我们用户在第一次访问和第二次访问的时候不是同一台服务器的话,session不同步,就会导致重新登录。为避免这个问题可以使用redis将用户session集中管理。
在共享Session中,当客户端第一次发送请求后,nginx将这个请求分发给服务器实例M ,然后将服务器实例M 产生的Session 放入Redis中,此时客户端、服务器实例M 和Redis中都会有一个相同的Session,当客户端发送第二次请求的时候,nginx将请求分发给服务器实例N (已知服务器实例N 中无Session),因为客户端自己携带了一个Session,那么服务器实例N就可以拿着客户端带来的Session中的ID去Redis中找到Session,找到这个Session后,就能正常执行之后的操作。

在这里插入图片描述

1.3 String底层实现

String底层采用Simple Dynamic String(即简单动态字符串,SDS),其中动态的含义是内存的分配是动态的,SDS的定义如下:
在这里插入图片描述从SDS结构中我们可以看到主要为三个属性:

  • len:buf中已经占有的长度(表示此字符串的实际长度)
  • free:buf中未使用的缓冲区长度。
  • buf[]:实际保存字符串数据的地方,buf属性是一个char类型的数组,数组最后保存了空字符‘\0’,这是为了兼容C语言的部分标准C字符串库函数。

但是这个SDS类型仅作为参数和返回值使用,并不是真正用于操作的类型,真正核心的部分是下面的这些类:

//根据长度选择合适的类型,类型也不一样
struct __attribute__ ((__packed__)) sdshdr5 {//当字符串长度 <32,用这种
   unsigned char flags; 
   char buf[];
}; 
struct __attribute__ ((__packed__)) sdshdr8 { //当字符串长度 < 2^8 -1,用这种
   uint8_t len; 
   uint8_t alloc; 
   unsigned char flags; 
   char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {//当字符串长度 < 2^16 -1,用这种
   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[];
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

从源码的文件里面可以看见,同样一组结构Redis使用泛型定义了好多次。

那么为什么不直接用int类型呢?

主要是因为当字符串比较短的时候,lenalloc可以使用byteshort来表示,Redis为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

那么为什么不直接使用C字符串呢?

我们为什么要重新在定义一个SDS的动态字符串的结构?其实呢这主要是为了从Redis对字符串安全性和效率以及功能方面的要求。C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 ‘\0’。而在Redis中’\0‘可能会被判定为提前结束而识别不了字符串,因此,C语言的字符串总结下来有以下问题:

(1)获取字符串长度为O(n),因为C字符串需要去遍历。
(2)不能很好的杜绝缓冲区溢出/内存泄漏的问题,因为原因同上,进行字符串拼接等其他操作获取长度的时候易出现问题。
(3)C字符串只能保存文本数据,因为必须符合某种编码(如ASCLL),像一些\0就不易处理。

因此,可以总结出Redis使用SDS的与C语言原本字符串的区别如下:

C字符串SDS
获取字符串长度:获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
安全性:API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
内存重分配:修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配
数据保存:基于ASCII码,只能保存文本数据可以保存文本或者二进制数据(语音、图像等),二进制安全,输入什么,输出就是什么,比如c字符串中就不能保存带有’\0’标识的字符串
兼容C字符串库:可以使用所有<string.h>库中的函数保留C中的“\0”结尾特性,可以使用一部分<string.h>库中的函数
文本文件和二进制文件数据区别: 我们都知道,计算机在物理内存上面存放的都是二进制数据,所以文本文件和二进制文件的主要区别是在逻辑上的而不是物理上的。而从文件的编码方式来看,文件可以分为文本文件和二进制文件。文本文件是基于字符编码的文件,常见的有ASCII、Unicode等,而二进制文件是基于值编码的文件,可以看成是变长编码,你可以根据自己的需要,决定多少个比特代表一个值。
  (1)ASCII文件也称为文本文件,这种文件在磁盘中存放时每个字符对应一个字节,用于存放对应的ASCII码。例如,数5678的存储形式为:
         ASC码: 00110101 00110110 00110111 00111000
       对应十进制码:5     6   7   8 共占用4个字节。 ASCII码文件可在屏幕上按字符显示, 例如源程序文件就是ASCII文件,用DOS命令TYPE可显示文件的内容。 由于是按字符显示,因此能读懂文件内容。
  (2)二进制文件是按二进制的编码方式来存放文件的。 例如, 数5678的存储形式为: 00010110 00101110只占二个字节。二进制文件虽然也可在屏幕上显示, 但其内容无法读懂。C系统在处理这些文件时,并不区分类型,都看成是字符流,按字节进行处理。 输入输出字符流的开始和结束只由程序控制而不受物理符号(如回车符)的控制。 因此也把这种文件称作“流式文件”。

总的来说:
1、二进制文件是把内存中的数据按其在内存中的存储形式原样输出到磁盘上存放,也就是说存放的是数据的原形式。
2、文本文件是把数据的终端形式的二进制数据输出到磁盘上存放,也就是说存放的是数据的终端形式。

二、List(列表)

底层是使用双向链表(double linked list)实现的,所以向列表两端添加元素的时间复杂度为0(1),获取越接近两端的元素速度就越快。即使是一个有几千万个元素的列表,获取头部或尾部的10条记录也是极快的。

2.1 List常用命令

命令格式描述
lpush key value1 value2向指定List(key)的左侧(即头部)插入1个或多个元素,返回插入后的List长度。时间复杂度O(N)
rpush key value1 value2同LPUSH,向指定List的右侧(即尾部)插入1或多个元素
lpop key从指定List的左侧(即头部)移除一个元素并返回,时间复杂度O(1)
rpop key从指定List的右侧(即尾部)移除1个元素并返回
LPUSHX/RPUSHX与LPUSH/RPUSH类似,区别在于,LPUSHX/RPUSHX操作的key如果不存在,则不会进行任何操作
LLEN返回指定List的长度,时间复杂度O(1)
lindex key index找到index位置的数据
lrem key count value删除list列表中number个value(因为list元素可以重复,所以要指定count),当count<0时, lrem会从列表后边开始删除,当count=0时, lrem删除所有值为value的元素
lrange key start stop返回指定List中指定范围的元素(双端包含,即lrange key 0 10会返回11个元素,如果是负数则表示截取到末尾),时间复杂度O(N)。应尽可能控制一次获取的元素数量,一次获取过大范围的List元素会导致延迟,同时对长度不可预知的List,避免使用LRANGE key 0 -1这样的完整遍历操作。
ltrim key start stop只保留列表中start开始到stop结束之间指定片段的数据
linsert key before/after pivot value该命令首先会在列表中从左到右查找值为pivot的元素,然后根据第二个参数是before还是after来决定将value插入到该元素的前面还是后面

2.2 list常见应用场景

1、评论列表: 朋友圈评论,按顺序显示评论的朋友。
2、关注列表:显示有哪些关注人。
3、文章列表:显示文章列表,如果要按照日期等顺序显示,如果要按某种顺序显示,则需要先排好序之后再放入Redis中。
4、消息队列:redis的lpush+brpop命令组合即可实现阻塞队列,生产者客户端是用lupsh从列表左侧插入元素,多个消费者客户端使用brpop命令阻塞时的“抢”列表尾部的元素,多个客户端保证了消费的负载均衡和高可用性。
5、排行榜:适用于定时计算的排行榜。 list类型的lrange命令可以分页查看队列中的数据。可将每隔一段时间计算一次的排行榜存储在list类型中,如京东每日的手机销量排行、学校每次月考学生的成绩排名、斗鱼年终盛典主播排名等排行榜。

2.3 List底层实现

list列表的数据结构使用的是压缩列表(ziplist)和普通的双向链表(linkedlist)组成。元素少的时候会用ziplist,元素多的时候会用linkedlist。然后针对这两种的弊端又设计出了一个快速列表(quicklist)。下面我们将重点介绍ziplist和quicklist,并介绍他们的原因。

ziplist

ziplist是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是在连续的内存区域当中的。当列表对象元素不大,每个元素也不大的时候,就采用ziplist存储。但当数据量过大时就ziplist就不是那么好用了。因为为了保证他存储内容在内存中的连续性,插入的复杂度是O(N),即每次插入都会重新进行realloc。如下图所示,对象结构中ptr所指向的就是一个ziplist。整个ziplist只需要malloc一次,它们在内存中是一块连续的区域。

ziplist的结构表如下:
在这里插入图片描述

zlbytes:用于记录整个压缩列表占用的内存字节数。
zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节。
zllen:记录了压缩列表包含的节点数量。
entryX:要说列表包含的各个节点。
zlend:用于标记压缩列表的末端。

那么为什么数据量大不使用ziplist?

我们在上面也说到了,因为它的插入的时间复杂度是O(n),而且插入一个新的元素就要调用realloc进行扩展内存。取决于内存分配器算法和当前的ziplist内存大小,realloc可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能直接原地扩展。而如果我们的数据量大的话,那么重新分配内存和拷贝内存就会有很大的消耗。所以ziplist不适合大型字符串,存储的元素也不宜过多。

原来数据量大时候使用linkedlist,那为什么又诞生出quicklist呢,主要是由于linkedlist的附加空间相对太高,prev和next指针就要占去16个字节,而且每一个结点都是单独分配,会加剧内存的碎片化,影响内存管理效率。

quicklist

quicklist是ziplist和linkedlist的混合体,它将linkedlist按段切分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双指针串接起来。
在这里插入图片描述
quicklist内部默认单个ziplist长度为8k字节,超出了这个字节数,就会新起一个ziplist。关于长度可以使用list-max-ziplist-size来决定。quicklist下是用多个ziplist组成的,同时为了进一步节约空间,Redis还会对ziplist进行压缩存储,使用LZF算法压缩,可以选择压缩深度。quicklist默认的压缩深度是0,也就是不压缩。压缩的实际深度由配置参数list-compress-depth决定。为了支持快速的 push/pop操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

关于更多quicklist数据结构的我们可以参见:Redis数据结构之quicklist

三、Set(集合

3.1 Set常用命令

命令格式描述
sadd key value1 value2 …添加数据,如果重复添加,会添加失败,向指定Set中添加1个或多个value,如果指定Set不存在,会自动创建一个,时间复杂度O(N)
spop key value
smembers key获取全部的数据,返回该Set中所有的value,时间复杂度O(N)
scard key获取Set中全部value个数,时间复杂度O(1)
srem key value从指定Set中移除1个或多个value,时间复杂度O(N),N为移除的value个数
sismember key value判断value是否是key集合内的数据
srandmember key value从指定Set中随机返回1个或多个value,时间复杂度O(N),N为返回的value个数

3.2 Set常见应用场景

1、共同关注/共同喜好/共同好友:利用集合的求交集运算,完成共同关注功能。
2、交集,并集,差集。这里如交集可以用来如一个用户对娱乐 、体育比较感兴趣,另一个可能对新闻比较感兴趣,他们就有共同的标签,可以做互相推荐的功能,喜欢体育的人还喜欢娱乐。类似其他的功能都可以抽象一点去想象用法。
3、随机数。这里可以使用spop/srandmember命令来获取随机数,可以做一个抽奖功能等。
4、社交需求。类似sadd/sinter命令可以添加你有多少个朋友的共同好友等操作,类似可能认识的人。

3.3 Set底层实现

Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。集合Set类型底层编码包括HashTable和inset。HashTable相信大家也是很熟悉了,这里我们就介绍下inset。

intset底层本质是一个有序的、不重复的、整型的数组、支持不同类型整数,结构如下:

typedef struct intset {
    uint32_t encoding; //encoding 的值可以是以下三个常量的其中一个:sizeof(int16_t)、sizeof(int32_t)、sizeof(int64_t)
    uint32_t length; //数组的实际长度
    int8_t contents[]; //contents 数组是实际保存元素的地方,没有重复元素,元素在数组中从小到大排列;
} intset;
  • 1
  • 2
  • 3
  • 4
  • 5

因此,intset是一个有序集合,查找元素的复杂度为O(logN)(采用二分法),但插入时不一定为O(logN),因为有可能涉及到升级操作。比如当集合里全是int16_t型的整数,这时要插入一个int32_t,那么为了维持集合中数据类型的一致,那么所有的数据都会被转换成int32_t类型,涉及到内存的重新分配,这时插入的复杂度就为O(N)了。是intset不支持降级操作。

四、Sorted Set(有序集合,也称为Zset)

Sorted Set有序集合和Set集合有着一定的的联系,他保留了集合不能有重复成员的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下标作为排序依据不同的是,它给每个元素设置一个分数,作为排序的依据。 (元素不可以重复,但是score可以重复,就和一个班里的同学学号不能重复,但考试成绩可以相同)。

简单来说,它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。

4.1 Sorted Set常用命令

命令格式描述
zadd key score1 value1 score2 value2…添加数据,向有序集合中加入一个元素和该元素的分数,如果该元素已经存在则会用新的分数替换原有的分数,返回值是新加入到集合中的元素个数,时间复杂度O(Mlog(N)),M为添加的member数量,N为Sorted Set中的member数量。
zrange key start stop [WITHSCORES]获取数据按照元素分数从小到大的顺序返回索引从start到stop之间的所有元素(包含两端的元素),如果WITHSCORES在末尾,则会把score也输出出来。
zrevrange key srart stop [WITHSCORES]按照元素分数从大到小的顺序返回索引从start到stop之间的所有元素,如果WITHSCORES在末尾,则会把score也输出出来。
zcard key获取Sorted Set中的数据总量,时间复杂度O(1)
zcount key min max获取[min, max]范围内的数据数量,O(log(N))
zrem key value从指定Sorted Set中删除1个或多个member,时间复杂度O(Mlog(N)),M为删除的member数量,N为Sorted Set中的member数量。
zsore key value返回指定Sorted Set中指定member的score,时间复杂度O(1)
zrank/zrevrank key value获取value在key中根据score的升序/降序排名,时间复杂度O(log(N))
zincrby key increment value给value对应的score进行自增,时间复杂度O(log(N))
zrange/zreverange返回指定Sorted Set中指定排名范围内的所有member,zrange为按score升序排序,zreverange为按score降序排序,时间复杂度O(log(N)+M),M为本次返回的member数
ZRANGEBYSCORE/ZREVRANGEBYSCORE返回指定Sorted Set中指定score范围内的所有member,返回结果以升序/降序排序,min和max可以指定为-inf和+inf,代表返回所有的member。时间复杂度O(log(N)+M)
ZREMRANGEBYRANK/ZREMRANGEBYSCORE移除Sorted Set中指定排名范围/指定score范围内的所有member。时间复杂度O(log(N)+M)

4.2 Sorted Set常见应用场景

1、排行榜:按照Score得分获取排行前100等比较方便。
2、最新事件列表、或者文章列表,按照时间作为得分进行排序,并且取数据时候可以进行分页。

4.3 Sorted Set底层实现

Sorted Set的底层编码有两种数据结构,一个压缩链表(ziplist),一个是跳表(skiplist)。同样,当数据量少的时候使用ziplist,当数据量大时候使用skiplist。

而skiplist是和字典dict一起结合使用的,我们先来看一下数据结构定义:

/*
 * 跳跃表
 */
typedef struct zskiplist {
    // 头节点,尾节点
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 目前表内节点的最大层数
    int level;
} zskiplist;
/* ZSETs use a specialized version of Skiplists */
/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {
    // member 对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 这个层跨越的节点数量
        unsigned int span;
    } level[];
} zskiplistNode;

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

header和tail分别指向头节点和尾节点,然后每个skiplistNode里面的结构又是分层的(即level数组)。每一列都代表一个节点,保存了member和score,按score从小到大排序。每个节点有不同的层数,这个层数是在生成节点的时候随机生成的数值,创建结点的时候,为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。每一层都是一个指向后面某个节点的指针,最底层为按照score升序的双向链表,这一点从上面的zskiplistNode中可以看出,这种结构使得跳跃表可以跨越很多节点来快速访问。(前进可以跳跃式的跳过几个节点,而后退只能后退一个节点,可以看看下面示意图可比较容易理解)。
在这里插入图片描述
这种前进一次可以跳跃多个节点就加速进行查询的过程,相当于一次比较多个值,例如下面绿色箭头为查找22这个值时候的过程:
在这里插入图片描述
首先沿着最上层链表要比较的是7,发现22比7大,接下来我们就知道只需要到7的后面去继续查找,从而一下子跳过了7前面的所有节点。可以想象,当链表足够长的时候,这种多层链表的查找方式能让我们跳过很多下层节点,大大加快查找的速度。然后找到25,22比25小,然后往25的下一层比较,发现22比15大,比25小,然后继续降层,比15大,然后找下一个则找到了22,而做范围查找时候也是,找到范围最小值之后,再一个个往后面找就可以找到范围最大值了(需要注意的是,最底层为双向指针,这也是上图的不足之处)。

为什么要使用skiplist与dict结合使用呢?

首先是我们的跳跃表,可以跨过多个节点进行查询,时间复杂度是O(logn)左右,但是可以保持有序性。
我们的dict是用hashtable实现,因为是哈希,所以查询是O(1)的大小,但是是无序性。

而我们使用两者混合,在进行分数索引的时候查询使用跳跃表,进行数据索引查找的时候,可以使用哈希的O(1)查找。设想如果没有字典, 如果想按数据查分数, 就必须进行遍历O(logn)。两套底层数据结构均只作为索引使用, 即不直接持有数据本身.。数据被封装在SDS中, 由跳跃表与字典共同持有,而数据的分数则由跳跃表结点直接持有(double类型数据), 由字典间接持有。

那么为什么不使用平衡树,而使用跳跃表?

这里redis的设计者antirez也给出了原因,主要是从内存占用、对范围查找、实现难易程度来考虑的。

(1)在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

(2)平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

(3)从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

如果你还想看跳表源码等,可以参见:跳表

五、Hash(哈希)

hash叫散列类型,它提供了字段和字段值的映射。字段值只能是字符串类型,不支持其它类型。哈希类型的底层编码可以是ziplist也可以是我们的hashtable,Redis中的Hash采用如下存储方式:
在这里插入图片描述

5.1 Hash常用命令

命令格式描述
hset key field value新增/修改某个field的v,新增时返回1,修改时返回0,时间复杂度O(1)
hsetnx key field value如果key中没有field字段则设置field值为value,否则不做任何操作
hget key field获取某个field的value
hgetall key获取这个key的所有field-value,返回结果为数组,数组中field和value交替出现,时间复杂度O(N)
hdel key field删除某个field,可以删除一个或多个,返回值是被删除的字段个数
del key删除整个key
hmset key f1 v1 f2 v2 f3 v3 …批量新增/修改,时间复杂度O(N),N为一次操作的field数量
hmget key f1 f2 f3…获取某个field的f1、f2、f3 的值
hlen key获取key的字段数量,就是field的数量
hexists key field判断key中是否存在field这个字段,存在返回1,不存在返回0,时间复杂度O(1)
HINCRBY同INCRBY命令,对指定Hash中的一个field进行INCRBY(自增),时间复杂度O(1)
HKEYS/HVALS返回指定Hash中所有的field/value,时间复杂度O(N)

注意事项:

(1) hash类型的value只能存储string,不允许嵌套存储。如果获取不到对应的数据,返回的是(nil)(2) 每个hash最多存储2^32 -1 个键值对。
(3) hash最初设计不是为了存对象,不要把hash当成对象列表使用。
(4) hgetall 可以获取全部属性,如果field过多,遍历一次会很慢,影响程序效率。
  • 1
  • 2
  • 3
  • 4

5.2 Hash常见应用场景

1、电商购物车:添加购物车、浏览购物车商品、更改购物车商品数量、删除商品、清空商品均可实现。key为userID,field为商品ID,value为商品购买数量。
2、用户资料,key为userID,field为其余字段(比如用户名),value为对应字段值

购物车中涉及命令及字段可以参看下面:
在这里插入图片描述

5.3 Hash底层实现

哈希类型的底层编码是ziplist和字典,数据量少的时候使用的ziplist,数据量多时候使用字典,其中ziplist在上面我们已经了解了,这里我们着重介绍一下字典。

​ Redis的字典底层使用哈希表(Hashtable)实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

下面看一下字典的实现:

//其中dict的结构为:
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; //rehash的索引,没有进行rehash操作时值都为-1.
    unsigned long iterators; /* number of iterators currently running */
} dict;

//其中dictht的结构为
typedef struct dictht { 
    dictEntry **table;//table里面放的就是一个一个dictEntry,使用拉链法解决哈希冲突。
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

//其中dictEntry的结构为  可以把dictEntry理解为node
typedef struct dictEntry {
    void *key; //key就相当于一个下标索引
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; //指向下一个节点
} dictEntry;

//其中dictEntry中union中的val的数据为redisObject
typedef struct redisObject {
    unsigned type:4; //表示值的数据类型。
    unsigned encoding:4;//值的编码方式,就是其底层的数据结构实现。
    unsigned lru:LRU_BITS;//记录了对象最后一次被访问的时间,用于淘汰过期的键值对。
    int refcount;//记录了对象的引用计数。
    void *ptr;//指向数据的指针。比如ptr指向String,zest
} robj;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

从上面我们可以看到,Redis每个字典(dict)中包含两个hashtable(ht[0]和ht[1]),虽然dict结构有两个hashtable,但是通常情况下只有一个hashtable是有值的。但是在dict扩容缩容的时候,需要分配新的hashtable,然后进行渐近式搬迁,这时候两个hashtable存储的旧的hashtable和新的hashtable。搬迁结束后,旧hashtable删除,新的取而代之。

hashtable的结构和Java的HashMap几乎是一样的,都是通过分桶的方式来解决hash冲突的。第一维是数组,第二维是链表。而数组中存储的是第二维链表的第一个元素的指针。

随着操作的不断执行,哈希表保存的键值对有可能增多或者减少,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,此时需要重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上,这个过程称为rehash,

rehash步骤一般可以总结为如下:

1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
2、定时维护一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash开始;
3、在rehash进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一;
4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束;
5、将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表。有安全迭代器可用, 安全迭代器保证, 在迭代起始时, 字典中的所有结点, 都会被迭代到, 即使在迭代过程中对字典有插入操作。

在上面rehash过程中,需要将ht[0]中的数据重新计算hash值之后映射到ht[1]中,并且不是一次将所有数据进行rehash到ht[1]中,而是采取一种类似慢性的策略进行搬迁,这种方式称为渐进式rehash。

渐进式rehash

所谓渐进式rehash是指我们的大字典的扩容是比较消耗时间的,需要重新申请新的数组,然后将旧字典所有链表的元素重新挂接到新的数组下面,是一个O(n)的操作。但是因为我们的redis是单线程的,无法承受这样的耗时过程,所以采用了渐进式rehash小步搬迁,虽然慢一点,但是可以搬迁完毕。

此外,采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。特别的在进行rehash是只能对ht[0]进行使得h[0]元素减少的操作,如查询和删除;而查询是在两个哈希表中查找的,而插入只能在ht[1]中进行,ht[1]也可以查询和删除。

最后,再简单介绍一次Redis的Hash结构的扩容条件和缩容条件:
扩容条件

我们的扩容一般会在Hash表中的元素个数等于第一维数组的长度的时候,就会开始扩容。扩容的大小是原数组的两倍。不过在redis在做bgsave(RDB持久化操作的过程),为了减少内存页的过多分离(Copy On Write),redis不会去扩容。但是如果hash表的元素个数已经到达了第一维数组长度的5倍的时候,就会强制扩容,不管你是否在持久化。

这里不扩容主要是为了尽可能减少内存页过多分离,系统后需要更多的开销去回收内存。

缩容条件

当我们的hash表元素逐渐删除的越来越少的时候,第一维数组长度太长也不是太好。redis于是就会对hash表进行缩容来减少第一维数组长度的空间占用。缩容的条件是元素个数低于数组长度的10%,并且缩容不考虑是否在做redis持久化。

这里不用考虑bgsave主要是因为我们的缩容的内存都是已经使用过的,缩容的时候可以直接置空,而且由于申请的内存比较小,同时会释放掉一些已经使用的内存,不会增大系统的压力。

六、巨人的肩膀

1、Redis5种基本数据结构底层实现
2、Redis基本概念知识
3、Redis各种存储结构使用场景
4、Redis六种底层数据结构

本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号