Redis设计于实现(一) -数据类型

Redis知识图谱:
 
Redis底层数据类型:
    
    redis底层的数据类型有:SDS、hash(哈希)、dict(字典)、跳跃表,整数集合、压缩列表和对象。
 
SDS类型实现
 
    SDS(Simple Dynamic String)结构实现:
    struct sdshdr
    {
        //记录buf数组中已使用字节的数量
        // 等于SDS所保存字符串的长度
         int len;
        //记录buf数组未使用字节的数量
        int free;
        //字节数组,用于保存字符串
        char buf[];
    };
    SDS实现了空间预分配和惰性空间释放优化策略减少字符串内存重分配次数:
       空间预分配用于优化SDS的字符串增长的操作:当SDS的Api对一个SDS进行修改,并且需要扩展空间时,程序不仅会为SDS分配修改所需要的空间,还会为SDS分配额外的未使用空间。如果修改之后,SDS的长度小于1M,那么程序分配和SDS的len同样大的未使用空间。当len大于1M时,那么程序会分配1M未使用的空间。
        惰性空间释放用于优化SDS的字符串缩短的操作:当SDS的Api需要缩短SDS保存的字符串时,程序并不是立即使用内存重分配来回收缩短后多出来的字符,而是使用free属性将这些字节记录起来,并等待将来使用。 也提供api让我们可以手动释放多余空间。
 
Hash 类型实现:
    
    哈希是有Redis自定义的hash结构实现双端列表:
    typedef struct listNode {
    //前置节点
    struct listNode *prev;
    //后置节点
    struct listNode *next;
    //节点的值
    void *value;
    }listNode;
    typedef struct list {
    //表头节点
    listNode * head;
    //表尾节点
    listNode * tail;
    //链表所包含的节点数量
    unsigned long len;
    //节点值复制函数
    void *(*dup)( void *ptr);
    //节点值释放函数
    void (*free)( void *ptr);
    //节点值对比函数
    int (*match)( void *ptr, void *key);
    } list;
 
    双端链表:获取节点的前置节点和后置节点的复杂度都为O(1)
    无环:表头节点的pre和标为节点的next指针都为null,对链表的访问以null为终点。    
    带表头和表尾指针:通过表头和表尾指针,获取表头和表尾节点的复杂度为O(1)。
    链表长度计数器:是获取链表长度的复杂度为O(1)。
 
应用:
    链表被广泛应用于实现redis的各种功能,比如:列表键、发布与订阅、慢查询、监视器等。
 
字典类型的实现:
 
    在字典中就是一个键(key)和一个值(value)进行关联,这些关联的键和值就成为键值对。Redis的字典是使用哈希表作为底层实现的,一个哈希表里面可以有多个哈希表节点,每个节点就是保存了字典的一个键值对。
    接下来分别介绍Redis的哈希表、哈希节点和字典的实现。
 
    哈希表:
        typedef struct dictht {
            //哈希表数组
          dictEntry **table;
          //哈希表大小
          unsigned long size;
          //哈希表大小掩码,用于计算索引值
          //总是等于size- 1
          unsigned long sizemask;
          //该哈希表已有节点的数量
          unsigned long used; }dictht;
 
       table属性是一个数组,数组中的每个元素都是一个指向dict. h/ dictEntry结构的指针,每个table属性是一个数组,数组中的每个元素都是一个指向dict. h/ dictEntry结构的指针,每个dictEntry 结构保存着一个键值 对。
      size属性记录了哈希表的大小,也即是 table数组的大小,
      used属性则记录了哈希表目前已有节(键值 对的数量。
      sizemask属性的值总是等于 size- 1,这个属性和哈希值一起决 一个键应该被放到table 数组的哪个索引上面。
    
    哈希表节点dictEntry:
        typedef struct dictEntry {
            // 键
            void *key;
            // 值
            union{ void *val; uint64_ tu64; int64_ ts64; } v;
           //指向下个哈希表节点,形成链表
            struct dictEntry *next; } dictEntry;
 
    字典:
         typedef struct dict {
            // 类型 特定 函数
            dictType *type;
            // 私有 数据
            void *privdata;
            // 哈 希 表
            dictht ht[ 2];
             // rehash 索引
             // 当 rehash 不在 进行 时, 值 为- 1
             int  rehashidx; /* rehashing not in progress if rehashidx == -1 */
         } dict;
        ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
 
        字典结构
 
        
        使用链地址发解决冲突,next指向下一个节点,组成一个链表结构。和java 7中的HashMap解决key冲突方式一样。
        字典长度的扩充:ht[1]大小一般为ht[0]的两倍,当ht[0]中的空间使用完后,需要进行rehash,将ht[0]的元素rehash到ht[1]中。再将ht[0]回收,ht[0]指向ht[1],给ht[1]设置空白哈希表。至此是实现了哈希表的扩容。
        哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。·在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。在rehash期间,字典会同时使用ht[0]和ht[1]支持字典的增删改查操作。rehashidx记录了rehash的进度。
 
跳跃表的实现:
        
        跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为更为简单,所以有不少程序都使用跳跃表来代替平衡树。Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。
 
 跳跃表节点数据结构:
typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel {
         // 前进 指针
         struct zskiplistNode *forward;
         // 跨度
         unsigned int span;
         } level[];
    // 后退 指针
    struct zskiplistNode *backward;
    // 分值 
    double score;
    // 成员 对象 
    robj *obj;
} zskiplistNode;
 
跳跃表数据结构和算法:
 
如果有n个元素,因为是2分,所以层数就应该是log n层 (本文所有log都是以2为底),再加上自身的1层。以上图为例,如果是4个元素,那么分层为L3和L4,再加上本身的L2,一共3层;如果是8个元素,那么就是3+1层。最耗时间的查询自然是访问所有层数,耗时logn+logn,即2logn。为什么是2倍的logn呢?我们以上图中的46为例,查询到46要访问所有的分层,每个分层都要访问2个元素,中间元素和最后一个元素。所以时间复杂度为O(logn)
数据的插入问题:
我们先看理想的跳跃表结构,L2层的元素个数是L1层元素个数的1/2,L3层的元素个数是L2层的元素个数的1/2,以此类推。从这里,我们可以想到,只要在插入时尽量保证上一层的元素个数是下一层元素的1/2,我们的跳跃表就能成为理想的跳跃表。那么怎么样才能在插入时保证上一层元素个数是下一层元素个数的1/2呢?很简单,抛硬币就能解决了!假设元素X要插入跳跃表,很显然,L1层肯定要插入X。那么L2层要不要插入X呢?我们希望上层元素个数是下层元素个数的1/2,所以我们有1/2的概率希望X插入L2层,那么抛一下硬币吧,正面就插入,反面就不插入。那么L3到底要不要插入X呢?相对于L2层,我们还是希望1/2的概率插入,那么继续抛硬币吧!以此类推,元素X插入第n层的概率是(1/2)的n次。这样,我们能在跳跃表中插入一个元素了
 
整数集合:
    整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_ t、int32_ t或者int64_ t的整数值,并且保证集合中不会出现重复元素。
    结构图:
    
    contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
    length属性记录了整数集合包含的元素数量,也即是contents数组的长度。虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。
   
    元素升级: 如果数组中存储的元素item都是int_16类型,新加的元素为int_32类型,程序会现将数组已有的元素升级到int_32类型,再添加新元素。
 
   压缩列表 
        
        压缩列表是列表键和哈希键的底层实现之一,当 一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
 
 
Redis数剧对象:
    
    在前面的数个章节里,我们陆续介绍了Redis用到的所有主要数据结构,比如简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等等。Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面所介绍的数据结构。Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。举个例子,以下SET命令在数据库中创建了一个新的键值对,其中键值对的键是一个包含了字符串值"msg"的对象,而键值对的值则是一个包含了字符串值"helloworld"的对象:redis> SET msg "hello world" OK
    Redis中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性:
        typedef struct redisObject{
            //类型
            unsigned type:4;
            //编码
            unsigned encoding:4;
            //指向底层实现数据结构的指针
            void*ptr;
            //...
        }robj
    其中type属性记录了对象类型,这个属性是下图中列出常量的其中一个。
    
    对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。因此:·当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象”;·当我们称呼一个键为“列表键”时,我们指的是“这个数据库键所对应的值为列表对象”。TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应值对象的类型,而不是键对象的类型。
 
    
 
    对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。encoding属性记录了对象所使用的编码,也即这个对象使用了什么数据结构作为底层实现。对想编码列表如下图:
 
每种类型的对象都至少使用了两种不同的编码,下图为每种类型的对象可以使用的编码:
 
使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:
 
 
    
   
    通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。举个例子,在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:·因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;·随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;其他类型的对象也会通过使用多种不同的编码来进行类似的优化。在接下来的内容中,我们将分别介绍Redis中的五种不同类型的对象,说明这些对象底层所使用的编码方式,列出对象从一种编码转换成另一种编码所需的条件,以及同一个命令在多种不同编码上的实现方法。
    
字符串对象:
    
    字符串对象的编码可以是int、raw或者embstr。如果一个字符串对象保存的是整数值,并且这个值可以用long来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将编码设置为int。注:int为编码不是数据类型。当字符串对象保存的一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式保存这个字符串值。当字符串值的长度大于32字节时,字符串对象将使用raw编码的方式保存字符串值。embstr编码是专门用来保存断字符串的一种优化编码方式,和raw一样都是用redisObject和sdshdr结构来表示字符串对象。raw是通过两次分配内存空间创建redisObject和sdshdr两个结构,而embstr调用一次内存分配函数来分配一块连续的空间。释放时,raw需要释放两次内存,而embstr只需要释放一次内存空间。int编码和embstr编码都可以转为raw编码,当整数10086被转为字符串对象时,编码就会从int-->raw。当使用append命令该字符串追加时,编码就会从embstr-->raw。
        
    
 
列表对象:
 
    列表对象的编码可以是ziplist或者linkedlist。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。举个例子,如果我们执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:   
         redis>RPUSH numbers 1 "three" 5
        (integer) 3
       如果numbers键的值对象使用的是ziplist编码,这个值对象将会下图的样子:
    
    另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。举个例子,如果前面所说的numbers键创建的列表列表对象使用的不是ziplist编码,而是linkedlist编码,那么numbers键的值对象将是下图的样子。
    
 
    注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。
    
编码转换:当列表对象保存的所有字符串元素的长度都小于64字节或者列表对象保存的元素数量小于512时,编码格式使用ziplist,当不满足这两个条件时,ziplist编码会转变为linkedlist编码。配置文件中可通过这两个参数修改:list- max- ziplist- value,list- max- ziplist- entries条件的值。
 
哈希对象:
 
    哈希对象的编码可以是ziplist或者hashtable。ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:·保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
 
       
        另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:·字典的每个键都是一个字符串对象,对象中保存了键值对的键;·字典的每个值都是一个字符串对象,对象中保存了键值对的值。举个例子,如果前面profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,那么这个哈希对象应该会是下图所示的样子。
     
 
    编码转换:当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:·哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;·哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈希对象需要使用hashtable编码。注意这两个条件的上限值是可以修改的,具体请看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist-entries选项的说明。
 
集合对象:
 
    集合对象的编码可以是intset或者hashtable。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。另一方面,hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。    
     
    编码转换:当集合对象可以同时满足以下两个条件时,对象使用intset编码:1.集合对象保存的所有元素都是整数值;2.集合对象保存的元素数量不超过512个。不能满足这两个条件的集合对象需要使用hashtable编码。两个条件的值都可以修改,具体见配置文件中set-max-inset-entries选项的说明。
 
有序集合对象:
 
    有序集合的编码可以是ziplist或者skiplist。ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而较大的元素靠近表尾方向。
 
 
 
    typedef struct zset {
         zskiplist *zsl;
        dict *dict; 
    } zset;
    skiplist编码的有序集合使用zset结构作为底层实现。包含一个跳跃表和字典结构。
    zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。
 
编码转换:当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:1.·有序集合保存的元素数量小于128个;2.有序集合保存的所有元素成员的长度都小于64字节。以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值