Redis5种数据结构的运用及实现

1.数据结构

1.1字符串

       字符串类型的值实际可以是字符串、数字(整数,浮点数),甚至是二进制(图片、视频),但是值不能超过512MB。
1)常用命令

  • 设置值set(O(1)):set key value [ex seconds] [px milliseconds] [nx|xx]
       set命令有几个选项:
           [ex seconds]:为键设置秒级过期时间;
           [px milliseconds]:为键设置毫秒级过期时间;
           [nx]:键必须不存在才可以设置成功;
           [xx]:键必须存在才可以设置成功;
    还有setex和setnx分别对应ex和nx作用;
  • 获取值get(O(1)):get key若键不存在,返回nil空;
  • 批量设置值mset(O(k),k为键的个数):mset key value [key value ..]
  • 批量获取值mget(O(k),k为键的个数):mget key [key ..]
  • 计数(O(1)) incr decr incrby decrby incrbyfloat
      (incr key)用于对值做自增操作,返回结果分三种:值不是整数,返回错误(error);值是整数,返回自增后的结果;键不存在,按照值为0自增,返回1,并创建此键值对;
      (decr)自减;(incrby)自增指定数字;(decrby)自减指定数字;(incrbyfloat)自增浮点数。

2)不常用命令:

  • 追加值(O(1)) append key value 向字符串尾部追加值;
  • 字符串长度(O(1)) strlen key 返回字符串长度;
  • 设置并返回原值(O(1)) getset key value 设置值并返回原值;
  • 设置指定位置的字符(O(1)) setrange key offset value
  • 获取部分字符(O(n),n为字符串长度) getrange key start end,start和end分别为开始和结束偏移量,从0开始计算

3)内部编码:int 8字节长整形;embstr (基于SDS实现)小于等于39个字节的字符串;raw(基于SDS实现) 大于39个字节的字符串

4)典型使用场景:缓存数据,计数,共享Session,限速;

1.2哈希

键指向一个映射关系field-value;
1)命令

  • 设置值O(1) hset key field value,成功返回1,反之返回0。此外提供hsetnx(对应setnx);
  • 获取值O(1) hget key field 不存在返回nil;
  • 删除O(k) field hdel key field [field…] 返回成功删除field的个数;
  • 计算field个数O(1) hlen key
  • 批量设置或获取O(k)
    hmget key field [field…]
    hmset key field value [field value…]
  • 判断field是否存在O(1) hexists key field
  • 获取所有field/value/field-value O(n) hkeys key/hvals key/hgetall key。在使用hgetall时,如果哈希元素个数比较多,会存在阻塞Redis的可能。
  • 计数O(1) hincrby key field value hincrbyfloat效果等同于incrby/incrbyfloat
  • 计算value的字符串长度O(1) hstrlen key field
  1. 内部编码
      Ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512)、同时所有值都小于hash-max-ziplist-value配置(默认64字节)时,使用ziplist作为哈希的内部实现,以紧凑结构实现多个元素的连续存储,所有在节省内存方面比hashtable好。
      Hashtable(哈希表):当哈希类型无法满足ziplist条件时,Redis会使用hashtable作为哈希的内部实现,因为ziplist此时读写效率会下降,而hashtable读写效率时O(1)的,但会消耗更多内存。

1.3列表

列表中的元素是有序的,可以通过索引下标获取某个元素或者某个范围内的元素列表;列表中的元素可以是重复的;最多可以存储232-1个元素;可以对列表两端插入和弹出。
1)命令

a)添加操作

  • 从右边/左边插入元素O(k) rpush/lpush key value [value …]
  • 向某个元素前/后插入元素O(n) linsert key before|after pivot value。命令会找到等于pivot的元素并在其前或者后插入新元素value;
    (返回值均为操作后列表的长度)

b)查找

  • 获取指定范围内的元素列表 lrange key start end。索引下标从左到右分别是0到N-1;end选项包含了自身
  • 获取列表指定索引下标的元素O(n) lindex key index
  • 获取列表长度O(1) llen key

c)删除

  • 从列表左侧弹出元素O(1) lpop key
  • 从列表右侧弹出元素O(1) rpop key
  • 删除指定元素O(n) lrem key count value;从列表中找到等于value的元素进行删除,根据count不同分为三种情况:
    ①count>0,从左到右,删除最多count个元素;
    ②count<0,从右到左,删除最多count绝对值个元素;
    ③count=0,删除所有;
  • 按照索引范围修剪列表O(n) ltrim key start end;只保留从start到end的元素;

d)修改

  • 修改指定索引下标的元素O(n) lset key index newValue

e)阻塞操作O(1)
  blpop key [key …] timeout
  brpop key [key …] timeout
  blpop/brpop是lpop和rpop的阻塞版本,其中key [key …]代表多个列表的键;timeout代表阻塞时间。若列表为空,timeout=0则一直阻塞或者等待timeout时间后返回;若列表不为空,客户端立即返回,
  需要注意的是:1.如果是多个键,brpop会从左至右依次遍历键,一旦有一个键能弹出元素,客户端立即返回;2.如果多个客户端对用一个键指向brpop,那么最先执行的客户端可以获取到弹出的值;

2)内部编码
  ziplist,同哈希,元素个数小于list-max-ziplist-entries配置,且每个元素值小于list-max-ziplist-value配置,采用压缩列表作为内部实现来减少内存使用;
  linkedlist(链表),无法满足ziplist条件时,采用linkedlist;

1.4集合

集合用于保存多个字符串元素,但集合不允许有重复元素,且元素无序,不能通过下标获取元素;Redis除了支持集合内部从增删改查,还支持多个集合的交、并、差。
1)命令

a)集合内操作

  • 添加元素O(k)sadd key element [element …],返回成功添加元素的个数;
  • 删除元素O(k) srem key element [element …],返回成功删除元素的个数;
  • 计算元素个数O(1) scard key
  • 判断元素是否在集合中O(1) sismember key element,给定元素在集合中返回1,反之0;
  • 随机从集合中返回指定个数元素O(count) srandmember key [count],count默认为0;
  • 从集合中随机弹出元素O(1) spop key [count]
  • 获取所有元素O(n) smembers key,重操作,可能会阻塞其他操作;

b)集合间操作

  • 求集合的交集 sinter key [key …]
  • 求集合的并集 sunion key [key …]
  • 求多个集合的差集 sdiff key [key …]
  • 将交集、并集、差集的结果保存
    sinterstore destination key [key …]
    sunionstore destination key [key …]
    sdiffstore destination key [key …]
    集合间的运算在元素较多的情况下比较耗时,可以通过上述命令保存结果;

2)内部编码
  intset(整数集合),当集合中元素都是整数且元素个数小于set-max-inset-entries配置时采用,可以减少内存使用;
  hashtable(哈希表),无法满足intset条件时采用

1.5 有序集合

有序集保留了集合不能有重复成员的特性,同时对有序集合中的元素进行了排序。
1)命令

a)集合内

  • 添加成员O(log(n)) zadd key score member [score member];返回结果代表成功添加成员的个数;同时zadd有4个选项:
    ①nx:member必须不存在;
    ②xx:member必须存在;
    ③ch:返回此次操作后,有序集合元素和分数发生变化的个数;
    ④incr:对score做增加,相当于zincrby;

  • 计算成员个数O(1) zcard key

  • 计算某个成员的分数 zscore key member,成员不存在返回nil;

  • 计算成员排名 zrank key member/zrerank key member;zrank从低到高返回排名,zrerank反之;

  • 删除成员 zrem key member [member …]

  • 增加成员分数 zincrby key increment member

  • 返回指定排名范围的成员 zrange/zrevrange key start end [withscores],zrange从低到高,zrevrange反之,withscores同时返回分数;

  • 返回指定分数范围的成员
    zrangebyscore key min max [withscores] [limit offset count]
    zrevrangebyscore key max min [withscores] [limit offset count]
      [limit offset count]可以限制输出的起始位置和个数;min和max还支持开区间(括号)和闭区间(中括号),-inf和+inf分别代表无限小和无限大;

  • 返回指定分数范围成员个数 zcount key min max

  • 删除指定排名内的升序元素 zremrangebyrank key start end

  • 删除指定分数范围的成员 zremrangebyscore key min max
    b)集合间操作

  • 交集,zinterstore destination numkeys key [key …] [weights weight [weight …]] [aggregate sum|min|max]
    -destination:交集计算结果保存到的键名;
    -numkeys:需要做交集计算键的个数;
    -key [key …]:需要做交集计算的键;
    -weights weight [weight …]:每个键的权重,做交集运算时,每个键中的分数自己乘以这个权重,默认是1;
    -aggregate sum|min|max:计算成员交集后,分值可以按照和、最小值、最大值做汇总,默认是sum;

  • 并集,zunionstore destination numkeys key [key …] [weights weight [weight …]] [aggregate sum|min|max]
    参数与zinterstore是一致的;

2)内部编码
  ziplist(压缩列表):当有序集合的元素个数小于zset-max-ziplist-entries配置(默认128),同时每个元素的值都小于zset-max-ziplist-value配置,会采用ziplist来减少内存使用;
  skiplist(跳跃表):当ziplist条件不满足时采用;

1.6 键管理

1)单个键管理

  • 键重命名 rename/renamenx key newKey,使用renamenx防止重命名时覆盖已有的键,若重命名时新名的键已存在,则renamenx返回0;
    在重命名键期间会执行del命令删除旧的键,如果对应键的值比较大,会存在阻塞Redis的可能;key和newKey相同,Redis3.2之前会返回错误,Redis3.2会返回OK;

  • 随机返回一个键 randomkey

  • 键过期
    expire key seconds:键在seconds秒后过期;
    expireat key timestamp:建在秒级时间戳timestamp后过期;
    如果expire key的键不存在,返回0;
    如果过期时间为负值,键会立即被删除,犹如使用del命令一样;
    persist命令可以将键的过期时间清除;
    对于字符串类型键,执行set命令会去掉过期时间;
    Redis不支持二级数据结构内部元素的过期功能;

  • 迁移键
    move key db,用于在Redis内部进行数据迁移,由于Redis内部可以有多个数据库,彼此在数据上相互隔离;
    dump key + restore key ttl value,在源Redis上,dump命令会将键值序列化,格式采用RDB;在目标Redis上,restore将序列化后的键值反序列化复原,ttl参数表示过期时间,ttl=0表示没有过期时间。整个迁移过程是非原子性的且迁移过程是开启了两个客户端连接。

  • migrate host port key|"" destination-db timeout [copy] [replace] [keys key [key …]],实际上migrate是将dump、restore、del三个命令进行组合,从而简化了操作流程。Migrate具有原子性,不需要开启多个Redis实例,只需要在源Redis上执行命令,数据的传输直接在源Redis和目标Redis上完成的;目标Redis完成restore后会发送OK给源Redis,源Redis接到后会根据migrate对应的选项来决定是否删除源Redis上对应的键。
    -host:目标Redis的IP地址;
    -port:目标Redis的端口;
    -key|””:在Redis3.0.6之前migrate只支持一个键,所有此处为待迁移的键,在3.0.6之后支持多个键,此处为””空字符串;
    -destination-db:目标Redis的数据库索引;
    -timeout:迁移的超时时间,单位为毫秒;
    -[copy]:添加此选项,迁移后并不删除源键;
    -[replace]:添加此选项,不管目标Redis是否存在该键都回正常迁移并进行数据覆盖;
    -[keys key[key …]]:迁移多个键

2)遍历键
全量遍历键 keys pattern
渐进式遍历 scan cursor [match pattern] [count number]
为解决keys命令存在的问题,采用渐进式遍历的方式来解决keys命令可能带来的阻塞问题,每次scan命令的时间复杂度为O(1)
cursor 是必须参数,是一个游标,每次遍历完会返回当前游标的值,从0开始,直到游标值为0遍历结束
match 是可选参数,作用是进行模式匹配
count number 是可选参数,作用是表明每次要遍历的键的个数,默认值为10,可适当增大;
即执行一次scan默认返回10个键和下一次scan需要的cursor,当返回的cursor=0,则所有的键均已经被遍历.

3)数据库管理

  • 切换数据库 select dbIndex
    通过数字来区分不同数据库,默认配置16个数据库(0-15)。分布式中只能使用0号数据库,因为Redis是单线程的,多个数据库仍要共享一个CPU,且不同业务使用不同数据库,当某一业务出现慢查询会影响全局难以排查。可以用不同端口多个Redis实例来代替。
  • 清除数据库 flushdb/flushall
    flushdb只清除当前数据库,flushall清除所有数据库。如果键值比较多,可能存在阻塞Redis的可能。

2.数据结构实现

2.1 简单字符串(simple dynamic string,SDS)

       Redis没有直接采用C语言传统的字符串表示,而是自己构造了简单动态字符串(SDS)的抽象类型作为默认字符串表示,embstr和raw都是基于此实现。每个sds.h/sdshdr结构表示一个SDS值:

 struct sdshde {
     //记录buf数组中已使用字节的数量
     //等于SDS所保存字符串的长度
    int len;
     //记录buf数组中未使用字节的数量
    int free;
     //字符串数组,用于保存字符串数组
    char buf[];
};

       SDS遵循C字符串以空字符'\0'结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性内,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等都是SDS函数自动完成的。
SDS与C字符串的区别

  • O(1)获取字符串长度:C字符串并不记录自身长度信息,获取长度需遍历字符串,对每个字符进行计算直到遇到空字符结尾,复杂度是O(n),而SDS自身记录了长度属性len,复杂度是O(1);
  • 杜绝缓冲区溢出:C字符不记录自身长度带来的另一个问题就是容易造成缓冲区溢出,在对字符串进行扩展时由于空间分配已经完成,所以扩展会对额外空间进行占用,会有修改已存在变量值的可能性;而SDS进行修改时会先检查空间是否满足修改所需的要求,不满足会先自动扩展空间,然后才执行修改,也就不存在缓存区溢出的问题;
  • 减少修改字符串时带来的内存重分配次数:C字符串的长度和底层数组的长度之间存在关联,每次对字符串的修改(增长/缩短)都需要内存重分配,不然会造成缓冲区溢出/内存泄露; SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联,SDS通过未使用空间实现了空间预分配和惰性空间释放;
    空间预分配
    用于优化SDS的字符串增长操作:对一个SDS的字符串进行扩展(如拼接等操作),且需要空间扩展,程序不仅会为SDS分配修改所必须要的空间,还会额外分配未使用空间,当修改后SDS的len<1Mb,则free=len,当修改后SDS的len>=1Mb,则len=1Mb,如果未使用空间足够支持扩展则直接扩展字符串而无需进行空间扩展;
    惰性空间释放
    用于优化SDS的字符串缩短操作:对一个SDS的字符串进行缩短时,程序并不立即使用内存重分配来回收多余字节,而是使free属性记录起来,并等待将来使用;
  • 二进制安全:C字符串的字符必须符合某种编码,并且除末尾外不能包含空字符,因此只能存储文本数据,不能存储图片、音频、视频、压缩文件等二进制数据;而SDS是二进制安全的,会以处理二进制的方式来处理SDS存放在buf数组里面的数据,不会对数据进行任何限制、过滤,Redis在buf数组中保存的是二进制数据,并通过len属性来判断是否结束;
  • 兼容部分C字符串函数:SDS一样遵循C字符串以空字符结尾的惯例,在buf分配空间时多分配一个字节来容纳空字符,也就可以重用一部分库定义的函数,如strcasecmp进行字符串比较;

2.2 链表

       当一个列表键包含过多元素/包含的元素较长时,Redis采用链表作为列表键的底层实现。 除了链表键外,发布与订阅、慢查询、监视器等也用到了链表,服务器本身还使用链表来保存多个客户端的状态信息。

typedef struct listnode{
       //前置节点
       struct listNode *prev;
       //后置节点
       struct listNode *next;
       //节点的值
       void *value;
};

       虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表更方便:

typedef struct list{
       //表头
       listNode *head;
       //表尾
       listNode *tail;
       //链表所包含的节点数
        unsigned long len;
       //节点复制函数
       void *(*dup)(void *ptr)
       //节点值释放函数
       void (*free)(void *ptr);
       //节点值对比函数
       void (*match) (void *ptr,void *key);
   }

Redis的链表实现特性:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置和后置节点都是O(1);
  • 无环:表头节点的prev指针和表尾的next指针都指向NULL,对链表的访问以NULL为终点;
  • 带表头表尾指针:获取表头表尾节点的复杂度为O(1);
  • 带链表长度计数器:使用len属性来对list持有的链表节点进行计数,获取节点数量的复杂度为O(1);
  • 多态:链表节点使用void*指针来保存节点值,可通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

2.3 字典

       字典用于保存键值对的抽象数据结构,同时字典也是哈希键的底层实现之一。Redis的字典采用哈希表作为底层实现,一个哈希表里面包含多个哈希表节点,一个哈希表节点代表一个键值对。
哈希表

typedef struct dictht {
    //哈希表数组,数组中元素指向一个dictEntry结构的指针,每个dictEntry结构保存一个键值对  
    dictEntry **table;
    //哈希表大小,即table数组的大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值 sizemask = size - 1;
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
}

哈希表节点

typedef struct dictEntry{
    // 键
    void *key;
    // 值,可以使一个指针、uint64_t整数或者int64_t整数
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表,将多个哈希值相同的键值对链接在一起,解决键冲突
    struct disEntry *next;
} dictEntry;

字典

typedef struct dict{
    //类型特定函数,指向dictType的指针,dictType结构保存了一簇用于操作特定类型键值对的函数  
    dictType *type;  
    //私有数据,保存了需要传给特定函数的可选参数  
    void *privdate;
    //哈希表,ht[0]即为哈希表,ht[1]只在rehash时使用
    dictht ht [2];
    //rehash索引,记录rehash目前的进度,当rehash不在进行时,值为-1;
    int rehashidx;
} dict;

typedef struct dictType{
    //计算哈希值
    unsigned int (*hashFunction) (const void *key);
    //复制键函数
    void *(*keyDup) (void *privdate, const void *key);
    //复制值的函数
    void *(*valDup) (void *privdate,const void *obj);
    //对比键的函数
   int (*keyCompare) (void *privdate,const void *key1, const void *key2);
    //销毁键的函数  
    void (*keyDestructor) (void *privdate,void *key);
    //销毁值的函数
    void (*valDestructor) (void *privdate,void *obj);
}

哈希算法
       根据键计算出哈希值和索引值,再根据索引值将包含键值对的哈希表节点放置到哈希表数组的指定索引上。
使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);
使用哈希表的sizemask属性和哈希值,计算出索引值

index = hash & dict->ht[x].sizemask  

键冲突
       当有两个或以上数量的键被分配到哈希数组同一个索引上面时,键发生冲突;Redis哈希表采用拉链法解决键冲突。
渐进式rehash
       随着键值对的增多和减少,为维持负载因子(已保存节点数/哈希表大小),需要对哈希表大小进行相应的扩展和收缩,如果直接将ht[0]的键值对rehash到ht[1]由于数据量过大可能对服务器性能造成影响。所以通过渐进式rehash分多次,将rehash键值对所需的工作平摊到字典的每个增删改查操作上,避免了集中式rehash带来的庞大计算量,步骤如下:

  • 为ht[1]分配空间,如是扩展,则大小为大于等于h[0].used*2的2次幂;如是收缩,则大小为大于等于h[0].used的2次幂;
  • 字典同时维持2个哈希表ht[0]和ht[1],并维持一个索引计数器rehashidx,设置为0,表示rehash工作正式开始;
  • 在rehash期间,每次对字典执行增删改查时,程序除执行指定操作外,会将ht[0]中所有键值对重新计算哈希值和索引值,放置到ht[1]指定位置上,然后rehashidx加1;
  • 随着字典操作不断执行,最终ht[0]所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]创建空白哈希表,rehashidx置为-1;

2.4 跳跃表

       跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。支持平均O(logN),最坏O(N)的查找。跳跃表只在有序集合和集群节点内部数据结构中被用到, 通由跳跃表节点zskiplistNode和保存跳跃表节点信息的zskiplist两个结构定义。

zskiplist结构包含以下属性:

  • header:指向跳跃表表头节点;
  • tail:指向跳跃表表尾节点;
  • level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点层数不计算在内);
  • length:记录跳跃表的长度,即目前包含的节点数(表头节点层数不计算在内);

zskiplistNode结构包含以下属性:

  • 层(level):Lx代表第x层(1-32,创建节点时随机生成),每层都包含指向其他节点的指针,层数量越多,访问其他节点的速度越快,还包含2个属性:前进指针和跨度,前进指针用于访问位于表尾方向的其他节点,跨度记录了前进指针所指节点与当前节点的距离;
  • 后退指针:节点中用BW标记节点的后退指针,指向位于当前节点的前一个节点;
  • 分值:一个double类型的浮点数,跳跃表中分值按各自所保存的分值从小到大排列;
  • 成员对象:指向一个字符串对象,存着SDS值,每个节点保存的节点对象必须唯一;

2.5 整数集合

       整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,保存类型为int16_t、int32_t和int64_t的整数值。

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素个数
    uint32_t length;
    //保存元素的数组,类型由encoding属性的值确定,元素从小到大排列,无重复元素
    int8_t contents[];
}

升级
       当添加新元素,且新元素类型比现有元素类型都长时,集合需要先进行升级:

  • 根据新元素的类型,扩展整数集合底层数组空间的大小,为新元素分配空间;
  • 将现有元素转换为新元素相同的类型,并将转换后的元素放到正确位置,保持底层数组有序性不变;
  • 添加新元素到底层数组;

       所以向整数集合添加新元素的时间复杂度为O(N),同时采用升级还能提升整数集合的灵活性,可以随意添加不同类型的整数而不用担心出现类型错误,也尽可能的节约了内存,升级操作只会在需要的时候进行,而不是一开始就预先分配大量的空间。
降级
       整数集合不支持降级,一旦对数组进行了升级,编码会一直保持升级后的状态。

2.6 压缩列表

       压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数:内存重分配和计算zlend位置是使用
zltail uint32_t 4字节 记录压缩列表表尾阶段距离压缩列表起始地址有多少个字节
zllen uint16-t 2字节 记录压缩列表包含的节点数:如果大于65535则需要遍历计算
entryX 列表节点 不定 压缩列表包含的各个节点,节点长度由节点保存的内容决定
zlend uint8_t 1字节 特殊值0xFF,用于标记压缩列表的末端

压缩列表节点由previous_entry_length、encoding、content三部分组成:

  • previous_entry_length:以字节为单位,记录了压缩列表前一个节点的长度,可以是1字节和5字节;
  • encoding:记录节点的content属性所保存数据的类型以及长度;
  • content:属性负责保存节点的值,可以是一个字节数组或者整数;

连锁更新
       由于previous_entry_length属性都记录了前一个节点的长度,长度小于254字节用1字节空间记录否则用5字节空间,那么当一个压缩列表中有多个连续且长度介于250-253字节的节点,添加一个大于等于254字节的节点到头节点时,导致后续节点都需要空间重分配,扩展出4个字节构成5字节来记录前一节点的长度,进而自身超出254字节,引起了后续节点的连锁更新;在删除节点时也可能会导致连锁更新。

你可能感兴趣的:(Redis5种数据结构的运用及实现)