zoukankan      html  css  js  c++  java
  • Redis数据结构

    1、redis特点

      redis是一个key-value内存数据库,key和value的最大长度限制是512M,有以下特点:

      (1)性能极高,读的速度是110000次/s,写的速度是81000次/s。

      (2)支持数据的持久化。

      (3)支持丰富的数据类型。

      (4)支持数据备份(master-slave)。

      (5)所有单个操作都是原子的,多操作支持事务。

      (6)其他高级特性:发布与订阅、二进制位数组、慢日志查询等。

      CentOS7 安装 Redis 单实例 https://gper.club/articles/7e7e7f7ff7g5egc4g6b

      Docker 安装Redis 集群 https://gper.club/articles/7e7e7f7ff7g5egc5g6c 

    2、redis数据结构与数据对象

      redis底层实现了很多中数据结构,如:简单动态字符串(SDS)、链表、字典(dict)、跳跃表、整数集合、压缩列表(ziplist)。redis并不是直接使用这些数据结构来存储键值对,而是基于这些数据结构实现了一个对象系统,包括字符串对象、列表对象、哈希对象、集合对象、有序集合对象五中类型。

    使用对象的好处:

      1)根据对象类型,可以直接判断是否可以执行给定的命令。

      2)针对不同的使用场景,可以根据对象的类型动态地选择存储结构和可以使用的命令,实现节省空间和优化查询速度。

      3)redis实现了基于引用计数的内存回收机制,当程序不再引用某个对象的时候,这个对象就会被回收。

      4)对象共享机制,在适当的条件下,让多个键共享一个对象来节约内存。

      5)redis对象带有访问时间记录信息,用于计算键的空转时长,如果服务器启用了maxmemory功能,空转时长较大的键可能会优先被服务器删除。

      redis中对象的定义:redisObject

    typedef struct redisObject{ 
          unsigned type:4;/* 对象的类型,包括:REDIS_STRING、REDIS_LIST、REDIS_HASH、REDIS_SET、REDIS_ZSET*/ 
          unsigned encoding:4;/* 具体的数据结构 */ 
          unsigned lru:LRU_BITS;/*24 位,对象最后一次被命令程序访问的时间,与内存回收有关 */ 
          int refcount;/* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */ 
          void *ptr;/* 指向对象实际的数据结构 */ 
    }robj;

       对象类型和编码:

          

    3、redis数据库底层数据模型

      redis数据库使用字典作为底层实现,字典dict的定义: 

    typedef struct dict{ 
         dictType *type;/* 字典类型 */ 
         void *privdata;/* 私有数据 */ 
         dicththt[2];/* 一个字典有两个哈希表 */ 
         long rehashidx;/*rehash 索引 */ 
         unsigned long iterators;/* 当前正在使用的迭代器数量 */ 
    }dict;

      字典底层是通过哈希表(hashtable)实现的,字典中hashtable的定义:

    typedef struct dictht{
        //哈希表数组
        dictEntry  **table;
        //哈希表大小
        unsigned long size;
        //哈希表大小掩码,用于计算索引值,总是等于size-1
        unsigned long sizemask;
        //该哈希表已有节点的数量
        unsigned long used;    
    }dictht;

      哈希表中节点的定义为dictEntry,每个哈希表节点(dictEntry)保存了一个键值对,dictEntry的定义: 

    typedef struct dictEntry{ 
        void*key;/*key 关键字定义 */
        union{ 
            void*val; uint64_tu64;/*value 定义 */         
            int64_ts64; doubled; 
        }v; 
        struct dictEntry*next;/* 指向下一个键值对节点 */ 
    }dictEntry;

      在向字典中插入新的键值对时,先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希节点(dictEntry)放到哈希表数组的指定索引上面。

      字典存储结构:从最底层到最高层 dictEntry——dictht——dict。

                                                

      dict会定义两个哈希表,默认使用 ht[0],ht[1]不会初始化和分配空间,其用于扩展与收缩。

    *  解决键冲突

      当有两个以上的键被分配到哈希表数组的同一个索引上面时,就会发生键冲突。redis的哈希表使用链地址法(separate chaining)来解决键冲突(类似于hashMap),每个dictEntry都有一个 next 指针,多个 dictEntry 节点通过 next 指针构成一个单向链表,被分配到同一个索引上的dictEntry 以链表的方式连接起来。

    *  哈希表的扩展和收缩

      哈希表的扩展与收缩取决于哈希表的负载因子,负载因子定义:

               load_factor = ht[0].used  /  ht[0].size  , 即 负载因子 = 哈希表已保存的节点数量 /  哈希表大小

      当以下条件中的任意一个被满足时,程序会自动对哈希表进行扩展操作:

        1)服务器目前没有在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1;

        2)服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

      因为在执行BGSAVE或者BGREWRITEAOF的过程中,redis需要创建子进程,为了提高子进程的使用率,服务器会提高执行扩展所需的负载因子,尽量避免子进程存在期间进行哈希表的扩展操作。(大多数操作系统会使用写时复制技术来优化子进程的使用,此时扩展数据转移会增加不必要的内存写入)

      当哈希表的负载因子小于0.1时,程序会自动对哈希表进行收缩。

    *  扩展与收缩的步骤

      扩展与收缩哈希表通过rehash(重新散列)操作来完成。步骤如下:

        1)为字典的 ht[1] 哈希表分配空间,空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(即 ht[0].used 属性值)

          如果执行扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的2n;(如 ht[0].used = 3,ht[1] 的大小就是8,因为8是大于等于6的第一个2的3次幂)

          如果执行收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的2n

        2)将保存在 ht[0] 中的所有键值对rehash 到 ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放在 ht[1] 指定的位置上。

        3)当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空哈希表,为下次rehash做准备。

    4、数据类型(数据对象)

    4.1、String字符串

    (1)存储类型

      可以用来存储字符串、整数、浮点数。

    (2)实现原理

                                 

      key是字符串,redis并没有直接使用C 的字符数组,而是存储在自定义的SDS(简单动态字符串)中。value则存储在redisObject中。内部编码如下:

            

      字符串类型的内部编码有三种:

        1)int,存储8个字节的长整型(long,2^63-1)。

        2)embstr,代表embstr格式的SDS,存储小于44个字节的字符串。

        3)raw,存储大于44个字节的字符串。

    *  什么是SDS?

      redis中字符串的实现,在3.2以后的版本中,SDS又有多种结果(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同长度的字符串,分别代表2^5=32byte,2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB。

    /*sds.h*/ 
    struct__attribute__ ((__packed__)) sdshdr8{ 
        uint8_tlen;/* 当前字符数组的长度 */ 
        uint8_talloc;/*当前字符数组总共分配的内存大小 */ 
        unsignedcharflags;/* 当前字符数组的属性、用来标识到底是 sdshdr8 还是 sdshdr16 等 */  
        charbuf[];/* 字符串真正的值 */ 
    };

    *  redis为什么用SDS实现字符串?

      C语言本身没有字符串类型(只能用字符数组char[]实现),但是作为字符串的实现会有很多弊端:

        1)使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出。

        2)如果要获取字符长度,必须遍历字符数组,时间复杂度是O(n)。

        3)C字符串长度的变更会对字符数组做内存重分配。

        4)通过从字符串开始到结尾碰到的第一个''来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。

       SDS的特点:

        1)不用担心内存溢出问题,如果需要会对SDS进行扩容。

        2)获取字符串长度时间复杂度为O(1),因为定义了len属性。

        3)通过“空间预分配”( sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。

        4)判断是否结束的标志是len属性(它同样以''结尾是因为这样就可以使用C语言中函数库操作字符串的函数了),可以包含''。 

    *  embstr和raw的区别?

      embstr 的使用只分配一次内存空间(因为RedisObject 和SDS是连续的), 而 raw需要分配两次内存空间(分别为RedisObject和SDS分配空间)。因此与 raw 相比,embstr 的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而 embstr 的坏处也很明

    显,如果字符串的长度增加需要重新分配内存时,整个RedisObject和SDS都需要重新分配空间,因此 Redis 中的 embstr 实现为只读。

    *  编码转换

      当 int 数 据 不 再 是 整 数 , 或 大 小 超 过 了 long 的 范 围时,自动转化为 embstr;如果embstr编码的字符串进行修改,自动转换为raw类型后再进行修改,因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44个字节。编码转换是在redis写数据时完成的,是不可逆的(不包

    含重新set)。

    4.2、Hash哈希

            

    (1)存储类型

      包含键值对的无序散列表。value只能是字符串,不能嵌套其他类型(字符串也是唯一可以嵌套的对象类型)。同样是存储字符串,Hash 与 String的主要区别:

        1)把所有相关的值聚集到一个key中,节省内存空间

        2)只使用一个key,减少key冲突

        3)当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU的消耗

      Hash不适合的场景:

        1)Field不能单独设置过期时间

        2)没有bit操作

        3)需要考虑数据量分布的问题(value值非常大的时候,无法分布到多个节点)

    (2)实现原理

      Hash类型可以使用两种数据结构实现:ziplist(压缩列表):REDIS_ENCODING_ZIPLIST,hashtable(哈希表):REDIS_ENCODING_HT。

      hashtable是一个数组+链表的结构,在前面已经分析过。

    *  ziplist压缩列表

      ziplist是一个经过特殊编码的双向链表(连续的空间),它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点的长度和当前节点的长度。通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想,只用在字段个数少,字段值小的场景里。

      1)内部结构与定义  

    typedef struct zlentry{ 
        unsigned int prevrawlensize;/* 上一个链表节点占用的长度 */ 
        unsigned int prevrawlen; /* 存储上一个链表节点的长度数值所需要的字节数 */ 
        unsigned int lensize; /* 存储当前链表节点长度数值所需要的字节数 */  
        unsigned int len; /* 当前链表节点占用的长度 */ 
        unsigned int headersize; /* 当前链表节点的头部大小(prevrawlensize+lensize),即非数据域的大小 */ 
        unsigned char encoding; /* 编码方式 */ 
        unsigned char *p; /* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */ 
    }zlentry;

                       

      2)Hash对象什么时候使用ziplist存储?

        当hash对象同时满足以下两个条件的时候,使用ziplist编码:

          ——所有的键值对的健和值的字符串长度都小于等于64byte(一个英文字母一个字节);

          ——哈希对象保存的键值对数量小于512个。

         一个哈希对象超过配置的阈值(键和值的长度有>64byte,键值对个数>512个)时,会转换成哈希表(hashtable)。

     4.3、List列表

    (1)存储类型    

      存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。如下:

        

    (2)实现原理

      3.2版本之前,数据量较小时用ziplist存储,达到临界值时转换为linkedlist进行存储,分别对应REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST。

      3.2版本之后,统一用quicklist来存储。quicklist为一个双向链表,每个节点都是一个ziplist。

    *  quicklist(快速列表)

            

      quicklist是ziplist和linkedlist的结合体,外层是一个双向链表(linkedlist),linkedlist中每个节点都是一个压缩列表(ziplist)。定义如下:head和tail指向双向列表的表头和表尾。

    typedef struct quicklist { 
         quicklistNode *head; /* 指向双向列表的表头 */ 
         quicklistNode *tail; /* 指向双向列表的表尾 */ 
         unsigned long count; /* 所有的 ziplist 中一共存了多少个元素 */ 
         unsigned long len; /* 双向链表的长度,node 的数量 */ 
         int fill : 16; /* fill factor for individual nodes */ 
         unsigned int compress : 16; /* 压缩深度,0:不压缩; */ 
    } quicklist;

      quicklistNode中的*zl指向一个ziplist,一个ziplist可以存放多个元素。

    typedef struct quicklistNode { 
         struct quicklistNode *prev; /* 前一个节点 */ 
         struct quicklistNode *next; /* 后一个节点 */ 
         unsigned char *zl; /* 指向实际的 ziplist */ 
         unsigned int sz; /* 当前 ziplist 占用多少字节 */ 
         unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */ 
         unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点,1:RAW 2:LZF */ 
         unsigned int container : 2; /* 2:ziplist,未来可能支持其他结构存储 */ 
         unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */ 
         unsigned int attempted_compress : 1; /* 测试用 */ 
         unsigned int extra : 10; /* 预留给未来使用 */ 
    } quicklistNode;

    4.4、Set集合

    (1)存储类型

      String类型的无序集合,最大存储数量2^32-1(40亿左右)。

    (2)实现原理

      redis用 intset(整数集合) 或 hashtable 存储set。如果元素都是整数类型,就用 intset存储。如果不都是整数类型,就用 hashtable(数组+链表)。如果元素数量超过512个,也会用 hashtable存储。

    *  KV如何存储set集合元素?

      key就是元素值,value为null。

    4.5、ZSet有序集合

    (1)存储类型

              

      sorted set,有序的set,每个元素有个score,score相同时,按照key的ASCII码排序。

    (2)实现原理

      ZSet使用 ziplist 或 skiplist(跳跃表)+dict存储。同时满足以下条件时才使用ziplist,否则使用 skiplist+dict:

        1)元素数量小于128个;

        2)所有元素的长度都小于64字节。

      在ziplist内部,按照score排序递增来存储,插入的时候要移动后面的元素。

      ZSet使用skiplist+dict存储的结构如下:  

      zset的定义如下:

    typedef struct zset{
         dict  *dict;  /* 字典结构 */
         zskiplist  *zsl; /*  跳跃表 */
    }zset;                  

      跳跃表 zskiplist 的定义:

    typedef struct zskiplist{ 
         struct zskiplistNode *header, *tail;/* 指向跳跃表的头结点和尾节点 */ 
         unsigned long length; /* 跳跃表的节点数 */ 
         int level;/* 最大的层数 */ 
    }zskiplist;

      跳跃表节点 zskiplistNode的定义:

    typedef struct zskiplistNode{ 
          sds ele;/*zset 的元素 */ 
          double score;/* 分值 */ 
          struct zskiplistNode*backward;/* 后退指针 */ 
          struct zskiplistLevel{ 
                 struct zskiplistNode*forward;/* 前进指针,对应 level 的下一个节点 */ 
                 unsigned long span;/* 从当前节点到下一个节点的跨度(跨越的节点数) */ 
          }level[];/* 层 */ 
    }zskiplistNode;

               

      1)跳跃表按score分值从小到大存储元素,可以通过跳跃表对有序集合进行范围型操作,如zrank、zrange就是基于跳跃表API实现的。

      2)zset结构中dict为有序集合创建了一个从成员到分值的映射,每个字典的键值对保存了一个元素,键就是元素的成员,值就是元素的分值。通过字典,可以用O(1)复杂度找到给定成员

    的分值,如zscore就是基于这个原理。

      3)有序集合元素的成员是一个字符串对象,元素的分值是一个double类型的浮点数。

      4)虽然zset同时使用skiplist和dict保存了集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以不会产生任何重复的成员或分值,也不会因此浪费额外的内存。

    *  skiplist 跳跃表

      先来看一下有序链表:

        

      在这样一个链表中,如果要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间

    复杂度为O(n)。同样,当要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。而二分查找法只适用于有序数组,不适用于链表。

      假如每相邻两个节点增加一个指针,让指针指向下下个节点,如下为两层的跳跃表结构:

        

      这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。在插入一个数据的时候,决定要放到那一层,取决于一个算法(在redis中

    t_zset.c 有一个zslRandomLevel这个方法)。

      现在当想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中的下一层进行查找。比如,想查找23,查找的路径是沿着下图中标红的

    指针所指向的方向进行的:

        

        1) 23首先和7比较,再和19比较,比它们都大,继续向后比较。

        2) 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。

        3) 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在

      在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。

      为什么不用AVL树或者红黑树?因为skiplist更加简单,但是效率是差不多的。

     4.6、其他数据结构

    (1)BitMap

      Bitmap是在字符串类型上面定义的位操作。一个字节由8个二进制位组成。GETBIT 命令用于返回位数组 bitarray 在 offset 偏移量上的二进制位的值:

                                                                        GETBIT key <offset>

      GETBIT 命令的执行过程如下:

        1)计算 byte = lfloor offset div 8 
floor , byte 值记录了 offset 偏移量指定的二进制位保存在位数组的哪个字节。

        2)计算 bit = (offset mod 8) + 1 , bit 值记录了 offset 偏移量指定的二进制位是 byte 字节的第几个二进制位。

        3)根据 byte 值和 bit 值, 在位数组 bitarray 中定位 offset 偏移量指定的二进制位, 并返回这个位的值。

      如下例子:GETBIT key 10 将执行以下操作:

        1)lfloor 10 div 8 
floor 的值为 1 

        2)(10 mod 8) + 1 的值为 3 。

        3)定位到 buf[1] 字节上面, 然后取出该字节上的第 3 个二进制位的值。

        4)向客户端返回二进制位的值 0 。

      命令的执行过程如图所示:

        

    (2)Hyperloglogs

      提供了一种不太准确的基数统计方法,比如统计网站的UV,存在一定的误差。

    (3)Streams

      5.0推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了kafka的设计。

  • 相关阅读:
    fastjson-alibaba
    ubuntu 入门
    资料文档
    asp.net mvc View视图相关
    视频
    js第三方
    工具类网址
    [转]初学者的编程自学指南
    seajs的使用--主要了解模块化
    其它
  • 原文地址:https://www.cnblogs.com/jing-yi/p/12894831.html
Copyright © 2011-2022 走看看