Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景

Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景

看到这篇文章,感觉自己对redis的数据结构更加清晰了

Redis 有 5 种基础数据结构,它们分别是:string(字符串)、list(列表)、hash(字典)、set(集合) 和 zset(有序集合)。这 5 种是 Redis 相关知识中最基础、最重要的部分。

Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第1张图片

1、字符串 string:

Redis 中的字符串是一种 动态字符串,这意味着使用者可以修改,它的底层实现有点类似于 Java 中的 ArrayList,有一个字符数组,从源码的 sds.h/sdshdr 文件 中可以看到 Redis 底层对于字符串的定义 SDS,即 Simple Dynamic String 结构:

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
     
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
     
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
     
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
     
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
     
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

 
   
   
   
   

    你会发现同样一组结构 Redis 使用泛型定义了好多次,为什么不直接使用 int 类型呢

    因为当字符串比较短的时候,len 和 alloc 可以使用 byte 和 short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示

    SDS 与 C 字符串的区别:

    为什么不考虑直接使用 C 语言的字符串呢?因为 C 语言这种简单的字符串表示方式 不符合 Redis 对字符串在安全性、效率以及功能方面的要求。我们知道,C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 ‘\0’。(下图就展示了 C 语言中值为 “Redis” 的一个字符数组)

    Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第2张图片
    这样简单的数据结构可能会造成以下一些问题

    • 获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
    • 不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
    • C 字符串 只能保存文本数据 → 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),例如中间出现的 '\0’可能会被判定为提前结束的字符串而识别不了;

    我们以追加字符串的操作举例,Redis 源码如下:

    /* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
     * end of the specified sds string 's'.
     *  * After the call, the passed sds string is no longer valid and all the
     * references must be substituted with the new pointer returned by the call. */
    sds sdscatlen(sds s, const void *t, size_t len) {
         
        // 获取原字符串的长度
        size_t curlen = sdslen(s);
    
      • 注:Redis 规定了字符串的长度不得超过 512 MB

      对字符串的基本操作:

      安装好 Redis,我们可以使用 redis-cli 来对 Redis 进行命令行的操作,当然 Redis 官方也提供了在线的调试器,你也可以在里面敲入命令进行操作:http://try.redis.io/#run

      • 设置和获取键值对
      > SET key value
      OK
      > GET key
      "value"
      
       
         
         
         
         

        正如你看到的,我们通常使用 SET 和 GET 来设置和获取字符串值

        值可以是任何种类的字符串(包括二进制数据),例如你可以在一个键下保存一张 .jpeg 图片,只需要注意不要超过 512 MB 的最大限度就好了。

        key 存在时,SET 命令会覆盖掉你上一次设置的值

        > SET key newValue
        OK
        > GET key
        "newValue"
        
         
           
           
           
           

          另外你还可以使用== EXISTS 和 DEL 关键字来查询是否存在和删除键值对==:

          > EXISTS key
          (integer) 1
          > DEL key
          (integer) 1
          > GET key
          (nil)
          
           
             
             
             
             
            • 批量设置键值对
            > SET key1 value1
            OK
            > SET key2 value2
            OK
            > MGET key1 key2 key3    # 返回一个列表
            1) "value1"
            2) "value2"
            3) (nil)
            > MSET key1 value1 key2 value2
            > MGET key1 key2
            1) "value1"
            2) "value2"
            
             
               
               
               
               
              • 过期和 SET 命令扩展

              可以对 key 设置过期时间,到时间会被自动删除,这个功能常用来控制缓存的失效时间。(过期可以是任意数据结构)

              > SET key value1
              > GET key
              "value1"
              > EXPIRE name 5    # 5s 后过期
              ...                # 等待 5s
              > GET key
              (nil)
              
               
                 
                 
                 
                 

                等价于 SET + EXPIRE 的 SETNX 命令

                > SETNX key value1
                ...                # 等待 5s 后获取
                > GET key
                (nil)
                > SETNX key value1  # 如果 key 不存在则 SET 成功
                (integer) 1
                > SETNX key value1  # 如果 key 存在则 SET 失败
                (integer) 0
                > GET key
                "value"             # 没有改变
                
                 
                   
                   
                   
                   
                  • 计数

                  如果 value 是一个整数,还可以对它使用 INCR 命令进行 原子性 的自增操作,这意味着及时多个客户端对同一个 key 进行操作,也决不会导致竞争的情况

                  > SET counter 100
                  > INCR count
                  (interger) 101
                  > INCRBY counter 50
                  (integer) 151
                  
                   
                     
                     
                     
                     
                    • 返回原值的 GETSET 命令

                    对字符串,还有一个 GETSET 比较让人觉得有意思,它的功能跟它名字一样:为 key 设置一个值并返回原值

                    > SET key value
                    > GETSET key value1
                    "value"
                    
                     
                       
                       
                       
                       

                    这可以对于某一些需要隔一段时间就统计的 key 很方便的设置和查看,例如:系统每当由用户进入的时候你就是用 INCR 命令操作一个 key,当需要统计时候你就把这个 key 使用 GETSET 命令重新赋值为 0,这样就达到了统计的目的。

                    2、列表 list:

                    Redis 的列表相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)

                    可以看到,多个 listNode 可以通过 prev 和 next 指针组成双向链表

                    Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第3张图片
                    虽然仅仅使用多个 listNode 结构就可以组成链表,但是使用 adlist.h/list 结构来持有链表的话,操作起来会更加方便:

                    Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第4张图片
                    链表的基本操作:

                    • LPUSH 和 RPUSH 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;
                    • LRANGE 命令可以从 list 中取出一定范围的元素;
                    • LINDEX 命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的 get(int index) 操作;

                    示范:

                    > rpush mylist A
                    (integer) 1
                    > rpush mylist B
                    (integer) 2
                    > lpush mylist first
                    (integer) 3
                    > lrange mylist 0 -1    # -1 表示倒数第一个元素, 这里表示从第一个元素到最后一个元素,即所有
                    1) "first"
                    2) "A"
                    3) "B"
                    
                     
                       
                       
                       
                       

                    list 实现队列:

                    队列是先进先出的数据结构,常用于消息排队和异步逻辑处理,它会确保元素的访问顺序

                    > RPUSH books python java golang
                    (integer) 3
                    > LPOP books
                    "python"
                    > LPOP books
                    "java"
                    > LPOP books
                    "golang"
                    > LPOP books
                    (nil)
                    
                     
                       
                       
                       
                       

                    list 实现栈:

                    栈是先进后出的数据结构,跟队列正好相反

                    > RPUSH books python java golang
                    > RPOP books
                    "golang"
                    > RPOP books
                    "java"
                    > RPOP books
                    "python"
                    > RPOP books
                    (nil)
                    
                     
                       
                       
                       
                       

                    性能总结:

                    • 它是一个字符串链表,left,right都可以插入添加
                    • 若键不存在,创建新的链表
                    • 若键存在,新增内容
                    • 若值全移除,对应的键也消失
                    • 链表的操作无论是头和尾效率都极高,但若对中间元素进行操作,效率就很惨淡了。

                    3、字典 hash:

                    Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 "数组 + 链表" 的链地址法来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点

                    table 属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针,而每个 dictEntry 结构保存着一个键值对:

                    typedef struct dictEntry {
                         
                        // 键
                        void *key;
                        // 值
                        union {
                         
                            void *val;
                            uint64_t u64;
                            int64_t s64;
                            double d;
                        } v;
                        // 指向下个哈希表节点,形成链表
                        struct dictEntry *next;
                    } dictEntry;
                    
                     
                       
                       
                       
                       

                    可以从上面的源码中看到,实际上字典结构的内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁 (下面说原因)。

                    渐进式 rehash:

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

                    Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第5张图片
                    渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,如上图所示,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之

                    扩缩容的条件:

                    正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容

                    当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用所用的条件是 元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做 bgsave

                    字典的基本操作:

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

                    > HSET books java "think in java"    # 命令行的字符串如果包含空格则需要使用引号包裹
                    (integer) 1
                    > HSET books python "python cookbook"
                    (integer) 1
                    > HGETALL books    # key 和 value 间隔出现
                    1) "java"
                    2) "think in java"
                    3) "python"
                    4) "python cookbook"
                    > HGET books java
                    "think in java"
                    > HSET books java "head first java"  
                    (integer) 0        # 因为是更新操作,所以返回 0
                    > HMSET books java "effetive  java" python "learning python"    # 批量操作
                    OK
                    
                     
                       
                       
                       
                       

                    4、集合 set:

                    Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL

                    集合 set 的基本使用:

                    由于该结构比较简单,我们直接来看看是如何使用的:

                    > SADD books java
                    (integer) 1
                    > SADD books java    # 重复
                    (integer) 0
                    > SADD books python golang
                    (integer) 2
                    > SMEMBERS books    # 注意顺序,set 是无序的
                    1) "java"
                    2) "python"
                    3) "golang"
                    > SISMEMBER books java    # 查询某个 value 是否存在,相当于 contains
                    (integer) 1
                    > SCARD books    # 获取长度
                    (integer) 3
                    > SPOP books     # 弹出一个
                    "java"
                    
                     
                       
                       
                       
                       

                    5、有序列表 zset:

                    这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重

                    它的内部实现用的是一种叫做 「跳跃表」 的数据结构,由于比较复杂,所以在这里简单提一下原理就好了:

                    Redis有哪几种数据结构,分别如何使用,以及每种数据结构的应用场景_第6张图片
                    想象你是一家创业公司的老板,刚开始只有几个人,大家都平起平坐。后来随着公司的发展,人数越来越多,团队沟通成本逐渐增加,渐渐地引入了组长制,对团队进行划分,于是有一些人又是员工又有组长的身份

                    再后来,公司规模进一步扩大,公司需要再进入一个层级:部门。于是每个部门又会从组长中推举一位选出部长。

                    跳跃表就类似于这样的机制,最下面一层所有的元素都会串起来,都是员工,然后每隔几个元素就会挑选出一个代表,再把这几个代表使用另外一级指针串起来。然后再在这些代表里面挑出二级代表,再串起来。最终形成了一个金字塔的结构

                    想一下你目前所在的地理位置:亚洲 > 中国 > 某省 > 某市 > …,就是这样一个结构!

                    有序列表 zset 基础操作:

                    > ZADD books 9.0 "think in java"
                    > ZADD books 8.9 "java concurrency"
                    > ZADD books 8.6 "java cookbook"
                    > ZRANGE books 0 -1     # 按 score 排序列出,参数区间为排名范围
                    1) "java cookbook"
                    2) "java concurrency"
                    3) "think in java"
                    > ZREVRANGE books 0 -1  # 按 score 逆序列出,参数区间为排名范围
                    1) "think in java"
                    2) "java concurrency"
                    3) "java cookbook"
                    > ZCARD books           # 相当于 count()
                    (integer) 3
                    > ZSCORE books "java concurrency"   # 获取指定 value 的 score
                    "8.9000000000000004"                # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题
                    > ZRANK books "java concurrency"    # 排名
                    (integer) 1
                    > ZRANGEBYSCORE books 0 8.91        # 根据分值区间遍历 zset
                    1) "java cookbook"
                    2) "java concurrency"
                    > ZRANGEBYSCORE books -inf 8.91 withscores  # 根据分值区间 (-, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
                    1) "java cookbook"
                    2) "8.5999999999999996"
                    3) "java concurrency"
                    4) "8.9000000000000004"
                    > ZREM books "java concurrency"             # 删除 value
                    (integer) 1
                    > ZRANGE books 0 -1
                    1) "java cookbook"
                    2) "think in java"
                    
                     
                       
                       
                       
                       

                    六、五种数据类型的应用场景:

                    • String:最常规的set/get操作,value可以是String也可以是数字。一般做一些复杂的计数功能的缓存(INCR 命令进行 原子性 的自增操作)
                    • Hash:这里value存放的是结构化的对象,比较方便的是操作其中的某个字段
                    • List:使用list的数据结构可以做简单的**消息队列的功能。另外还有一个就是,可以利用lrang命令,做基于redis的分页功能,性能极佳,用户体验好。List还可以很好的完成排队,先进先出的原则**。
                    • Set:set存放的是一堆不重复的集合,所以可以做**全局去重的功能,还可以利用交集,并集,差集等操作,可以计算共同爱好,全部的爱好,自己独有的喜好**等。
                    • Sorted set:多了一个权重参数score,集合中的元素能够按score进行排列。可以做**排行榜应用,取Top N操作**。

                    七、Redis适合的场景:

                    • 1.会话缓存(Session Cache):

                    最常用的一种使用redis的情景是会话缓存。用redis缓存会话比其他的存储(如mamcached)的优势在于:redis提供持久化。当维持一个不是严格要求一致性的缓存时,若用户的购物车信息全部丢失,大部分人都会不高兴的,现在,幸运的是,随着redis这些年的改进,很容易找到怎么恰当的使用redis来缓存会话的文档。

                    • 2.全页缓存(FPC):

                    除基本的会话token之外,redis还提供了很简单的FPC平台。回到一致性问题,即使重启了redis实例,因为磁盘的持久化,用户也不会看到页面加载速度的下降,这是一个很大的改进。

                    • 3.队列:

                    redis在内存存储引擎领域的一大优点是:提供了list和set操作,这使得redis能作为一个很好的消息队列平台来使用。

                    • 4.排行榜/计数器:

                    redis在内存中对数字进行递增/减的操作实现的非常好。集合(Set)和有序集合(zset)也使得我们在执行这些操作的时候变的非常简单。

                    • 5.发布/订阅:

                    发布/订阅的场景用到的时候也非常多。

                    ***------ 我是“道祖且长”,一个在职场互联网苟且偷生的Java程序员***

                    你可能感兴趣的:(redis,数据结构,redis,java)