Redis基础——剖析基础数据结构及其用法

这是一个系列的文章,打算把Redis的 基础数据结构高级数据结构持久化的方式以及 高可用的方式都讲一遍,公众号会比其余的平台提早更新,感兴趣的能够提早关注,「 SH的全栈笔记」,下面开始正文。

若是你是一个有经验的后端或者服务器开发,那么必定据说过Redis,其全称叫Remote Dictionary Server。是由C语言编写的基于Key-Value的存储系统。说直白点就是一个内存数据库,既然是内存数据库就会遇到若是服务器意外宕机形成的数据不一致的问题。redis

这跟不少游戏服务器也是同样的,感兴趣的能够参考我以前的文章游戏服务器和Web服务器的区别。其数据首先会流向内存,基于快速的内存读写来实现高性能,而后按期将内存的数据中的数据落地。Redis其实也是这么个流程,基于快速的内存读写操做,单机的Redis甚至可以扛住10万的QPS。数据库

Redis除了高性能以外,还拥有丰富的数据结构,支持大多数的业务场景。这也是其为何如此受欢迎的缘由之一,下面咱们就来看一看Redis有哪些基础数据类型,以及他们底层都是怎么实现的。后端

1. 数据类型

其基础数据类型有StringListHashSetSorted Set,这些都是经常使用的基础数据类型,能够看到很是丰富,几乎可以知足大部分的需求了。其实还有一些高级数据结构,咱们在这章里暂时先不提,只聊基础的数据结构。数组

2. String

String能够说是最基础的数据结构了, 用法上能够直接和Java中的String挂钩,你能够把String类型用于存储某个标志位,某个计数器,甚至狠一点,序列化以后的JSON字符串都行,其单个key限制为512M。其常见的命令为getsetincrdecrmget安全

2.1 使用

  • get 获取某个key,若是key不存在会返回空指针
  • set 给key赋值,将key设置为指定的值,若是该key以前已经有值了,那么将被新的值给覆盖
  • incr 给当前的key的值+1,若是key不存在则会先给key调用set赋值为0,再调用incr。固然若是该key的类型不能作加法运算,例如字符串,就会抛出错误
  • decr 给当前key的值-1,其他的同上
  • mget 同get,只是一次性返回多条数据,不存在的key将会返回空指针

string相关命令

可能大多数的人只是到用一用的地步,这也无可厚非,可是若是是做为一个对技术有追求的开发,或者说你有想近大厂的想法,必定要有刨根问底的精神。只有当你真正知道一个东西的底层原理时,你遇到问题时才能提供给你更多的思路去解决问题。接下来咱们就来聊一下Redis中String底层是如何实现的。bash

2.2 原理

2.2.1 结构

咱们知道Redis是用C语言写的,可是Redis却没有直接使用,而是本身实现了一个叫SDS(Simple Dynamic String)的结构来实现字符串,结构以下。服务器

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

2.2.2 优势

为何Redis要本身实现SDS而不是直接用C的字符串呢?主要是由于如下几点。微信

  • 减小获取字符串长度开销 C语言中获取字符串的长度须要遍历整个字符串,直到遇到结束标志位\0,时间复杂度为O(n),而SDS直接维护了长度的变量,取长度的时间复杂度为O(1)
  • 避免缓冲区溢出 C语言中若是往一个字节数组中塞入超过其容量的字节,那么就会形成缓冲区溢出,而SDS经过维护free变量解决了这个问题。向buf数组中写入数据时,会先判断剩余的空间是否足够塞入新数据,若是不够,SDS就会从新分配缓冲区,加大以前的缓冲区。且加大的长度等于新增的数据的长度
  • 空间预分配&空间惰性释放 C语言中,每次修改字符串都会从新分配内存空间,若是对字符串修改了n次,那么必然会出现n次内存从新分配。而SDS因为冗余了一部分空间,优化了这个问题,将必然从新分配n次变为最多分配n次,而数据从buf中移除的时候,空闲出来的内存也不会立刻被回收,防止新写入数据而形成内存从新分配
  • 保证二进制安全 C语言中,字符串遇到\0会被截断,而SDS不会由于数据中出现了\0而截断字符串,换句话说,不会由于一些特殊的字符影响实际的运算结果

能够结合下面的图来理解SDS。网络

图片来源于网络,侵删

总结一下就是上面列表的四个小标题,为了减小获取字符串长度开销、避免缓冲区溢出、空间预分配&空间惰性释放和保证二进制安全。数据结构

3. List

List也是一个使用频率很高的数据结构,其设计到的命令太多了,就不像String那样一个一个演示了,感兴趣的你们能够去搜一搜。命令有lpush、lpushx、rpush、rpushx、lpop、rpop、lindex、linsert、lrange、llen、lrem、lset、ltrim、rpoplpush、brpoplpush、blpop、brpop,其都是对数组中的元素的操做。

3.1 使用

List的用途我认为主要集中在如下两个方面。

  1. 看成普通列表存储数据(相似于Java的ArrayList)
  2. 用作异步队列

普通列表这个天然没必要多说,其中存放的必然业务中须要的数据,下面来着重聊一下异步队列

啥玩意,List还能当成队列来玩?

List除了能被用作队列,还能看成栈来使用。在上面介绍了不少操做List命令,当咱们用rpush/lpop组合命令的时候,实际上就是在使用一个队列,而当咱们用rpush/rpop命令组合的时候,就是在使用一个栈。lpush/rpop和lpush/lpop是同理的。

假设咱们用的是rpush来生产消息,当咱们的程序须要消费消息的时候,就使用lpop异步队列中消费消息。可是若是采用这种方式,当队列为空时,你可能须要不停的去询问队列中是否有数据,这样会形成机器的CPU资源的浪费。

因此你能够采起让当前线程Sleep一段时间,这样的确能够节省一部分CPU资源。可是你可能就须要去考虑Sleep的时间,间隔过短,CPU上下文切换可能也是一笔不小的开销,间隔太长,那么可能形成这条消息被延迟消费(不过都用异步队列了,应该能够忽略这个问题)。

除了Sleep,还有没有其余的方式?

有,答案是blpop。当咱们使用blpop去消费时,若是当前队列是空的,那么此时线程会阻塞住,直到下面两种condition。

  1. 达到设置的timeout时间
  2. 队列中有消息能够被消费

比起Sleep一段时间,实时性会好一点;比起轮询,对CPU资源更加友好。

3.2 原理

在Redis3.2以前,Redis采用的是ZipList(压缩列表)或者LinkedList(链表)。当List中的元素同时知足每一个元素的小于64字节List元素个数小于512个时,存储的方式为ZipList。但凡是有一个条件没知足就会转换为LinkedList

而在3.2以后,其实现变成了QuickList(快速列表)。LinkedList因为是较为基础的东西,此处就不赘述了。

3.2.1 ZipList

ZipList采用连续的内存紧凑存储,不像链表那样须要有额外的空间来存储前驱节点和后续节点的指针。按照其存储的区域划分,大体能够分为三个部分,每一个部分也有本身的划分,其详细的结构以下。

  • header ziplist的头部信息

    • zlbytes 标识ziplist所占用的内存字节数
    • zltail 到ziplist尾节点的偏移量
    • zllen ziplist中的存储的节点数量
  • entries 存储实际节点的信息

    • pre_entry_length 记录了前一个节点的长度,经过这个值能够快速的跳转到上一个节点
    • encoding 顾名思义,存储量元素的编码格式
    • length 所存储数据的长度
    • content 保存节点的内容
  • end 标识ziplist的末尾

若是采用链表的存储方式,链表中的元素由指针相连,这样的方式可能会形成必定的内存碎片。而指针也会占用额外的存储空间。而ZipList不会存在这些状况,ZipList占用的是一段连续的内存空间。

可是相应地,ZipList的修改操做效率较为低下,插入和删除的操做会设计到频繁的内存空间申请和释放(有点相似于ArrayList从新扩容),且查询效率一样会受影响,本质上ZipList查询元素就是遍历链表。

3.2.2 QuickList

在3.2版本以后,list的实现就换成了QuickList。QuickList将list分红了多个节点,每个节点采用ZipList存储数据。

4. Hash

其用法就跟Java中的HashMap中同样,都是往map中去丢键值对。

4.1 使用

基础的命令以下:

  • hset 在hash中设置键值对
  • hget 获hash中的某个key值
  • hdel 删除hash中某个键
  • hlen 统计hash中元素的个数
  • hmget 批量的获取hash中的键的值
  • hmset 批量的设置hash中的键和值
  • hexists 判断hash中某个key是否存在
  • hkeys 返回hash中的全部键(不包含值)
  • hvals 返回hash中的全部值(不包含键)
  • hgetall 获取全部的键值对,包含了键和值

其实大多数状况下的使用跟HashMap是差很少的,没有什么较为特殊的地方。

4.2 原理

hash的底层实现也是有两种,ZipList和HashTable。但具体采用哪种与Redis的版本无关,而与当前hash中所存的元素有关。首先当咱们建立一个hash的时候,采用的ZipList进行存储。随着hash中的元素增多,达到了Redis设定的阈值,就会转换为HashTable。

其设定的阈值以下:

  • 存储的某个键或者值长度大于默认值(64)
  • ZipList中存储的元素数量大于默认值(512)

ZipList上面咱们专门简单分析了一下,理解这个设定应该也比较容易。当ZipList中的元素过多的时候,其查询的效率就会变得低下。而HashTable的底层设计其实和Java中的HashMap差很少,都是经过拉链法解决哈希冲突。具体的能够参考从基础的使用来深挖HashMap这篇文章。

5. Set

Set的概念能够与Java中的Set划等号,用于存储一个不包含重复元素的集合。

5.1 使用

其主要的命令以下,key表明redis中的Set,member表明集合中的元素。

  • sadd sadd key member [...] 将一个或者多个元素加入到集合中,若是有已经存在的元素会忽略掉。
  • srem srem key member [...]将一个或者多个元素从集合中移除,不存在的元素会被忽略掉
  • smembers smembers key返回集合中的全部成员
  • sismember dismember key member判断member在key中是否存在,若是存在则返回1,若是不存在则返回0
  • scard scard key返回集合key中的元素的数量
  • smove move source destination member将元素从source集合移动到destination集合。若是source中不包含member,则不会执行任何操做,当且仅当存在才会从集合中移出。若是destination已经存在元素则不会对destination作任何操做。该命令是原子操做。
  • spop spop key随机删除并返回集合中的一个元素
  • srandmember srandmember key与spop同样,只不过不会将元素删除,能够理解为从集合中随机出一个元素来。
  • sinter 求一个或者多个集合的交集
  • sinterstore sinterstore destination key [...]与sinter相似,可是会将得出的结果存到destination中。
  • sunion 求一个或者多个集合的并集
  • sunionstore sunionstore destination key [...]
  • sdiff 求一个或者多个集合的差集
  • sdiffstore sdiffstore destination key [...]与sdiff相似,可是会将得出的结果存到destination中。

5.2 原理

咱们知道Java中的Set有多种实现。在Redis中也是,有IntSetHashTable两种实现,首先初始化的时候使用的是IntSet,而知足以下的条件时,就会转换成HashTable

  • 当集合中保存的全部元素都是整数时
  • 集合对象保存的元素数量不超过512

上面已经简单的介绍了HashTable了,因此这里只聊聊IntSet。

5.2.1 IntSet

intset底层是一个数组,既然数据结构是数组,那么存储数据就能够是有序的,这也使得intset的底层查询是经过二分查找来实现。其结构以下。

struct intset {
  // 编码方式
  uint32_t encoding;
  // 集合包含元素的数量
  uint32_t length;
  // 存储元素的数组
  int8_t contents[];
}

与ZipList相似,IntSet也是使用的一连串的内存空间,可是不一样的是ZipList能够存储二进制的内容,而IntSet只能存储整数;且ZipList存储是无序的,IntSet则是有序的,这样一来,元素个数相同的前提下,IntSet的查询效率会更高。

6. Sorted Set

其与Set的功能大体相似,只不过在此基础上,能够给每个元素赋予一个权重。你能够理解为Java的TreeSet。与List、Hash、Set同样,其底层的实现也有两种,分别是ZipListSkipList(跳表)。

初始化Sorted Set的时候,会采用ZipList做为其实现,其实很好理解,这个时候元素的数量不多,采用ZipList进行紧凑的存储会更加的节省空间。当期达到以下的条件时,就会转换为SkipList:

  • 其保存的元素数量的个数小于128个
  • 其保存的全部元素长度小于64字节

6.1 使用

下面的命令中,key表明zset的名字;4表明score,也就是权重;而member就是zset中的key的名称。

  • zadd zadd key 4 member用于增长元素
  • zcard zcard key用于获取zset中的元素的数量
  • zrem zrem key member [...]删除zset中一个或者多个key
  • zincrby zincrby key 1 member给key的权重值加上score的值(也就是1)
  • zscore zscore key member用于获取指定key的权重值
  • zrange zrange key 0 -1获取zset中全部的元素,zrange key 0 -1 withscores获取全部元素和权重,withscores参数的做用是决定是否将权重值也一块儿返回。其返回的元素按照从小到大排序,若是元素具备相同的权重,则会按照字典序排序。
  • zrevrange zrevrange key 0 -1 withscores,其与zrange相似,只不过zrevrange按照从大到小排序。
  • zrangebyscore zrangebyscore key 1 5,返回key中权重在区间(1, 5]范围内元素。固然也可使用withscores来将权重值一并返回。其元素按照从小到大排序。1表明min,5表明max,他们也能够分别是-infinf,当你不知道key中的score区间时,就可使用这个。还有一个相似于SQL中的limit的可选参数,在此就不赘述。

除了可以对其中的元素添加权重以外,使用ZSet还能够实现延迟队列

延迟队列用于存放延迟任务,那什么是延迟队列呢?

举个很简单的例子, 你在某个电商APP中下订单,可是没有付款,此时它会提醒你,「订单若是超过1个小时没有支付,将会自动关闭」;再好比在某个活动结束前1个小时给用户推送消息;再好比订单完成后多少天自动确认收货等等。

用人话解释一遍,那就是过会才要干的事情。

那ZSet怎么实现这个功能?

其实很简单,就是将任务的执行时间设置为ZSet中的元素权重,而后经过一个后台线程定时的从ZSet中查询出权重最小的元素,而后经过与当前时间戳判断,若是大于当前时间戳(也就是该执行了)就将其从ZSet中取出。

那,怎么取?

其实我看不少讲Redis实现延迟队列的博客都没有把这个怎么取讲清楚,到底该用什么命令,传什么参数。咱们使用zrangebyscore命令来实现,还记得-inf和inf吗,其全称是infinity,分别表示无限小和无限大。

因为咱们并不知道延迟队列当中的score(也就是任务执行时间)的范围,因此咱们能够直接使用-inf和inf,完整命令以下。

zrangescore key -inf inf limt 0 1 withscores
仍是有点用,那ZSet底层是咋实现的呢?

上面已经讲过了ZipList,就不赘述,下面聊聊SkipList。

6.2 原理

6.2.1 SkipList

SkipList存在于zset(Sorted Set)的结构中,以下:

struct zset {
  // 字典
  dict *dict;
  // 跳表
  zskiplist *zsl;
}

而SkipList的结构以下:

struct zskiplist {
  // 表头节点和表尾节点
  struct zskiplistNode *header, *tail;
  // 表中节点的数量
  unsigned long length;
  // 表中层数最大的节点的层数
  int level;
}

不知道你们是否有想过,为何Redis要使用SkipList来实现ZSet,而不用数组呢?

首先ZSet若是数组存储的话,因为ZSet中存储的元素是有序的,存入的时候须要将元素放入数组中对应的位置。这样就会对数组进行频繁的增删,而频繁的增删在数组中效率并不高,由于涉及到数组元素的移动,若是元素插入的位置是首位,那么后面的全部元素都要被移动。

因此为了应付频繁增删的场景,咱们须要使用到链表。可是随着链表的元素增多,一样的会出现问题,虽然增删的效率提高了,可是查询的效率变低了,由于查询元素会从头至尾的遍历链表。全部若是有什么方法可以提高链表的查询效率就行了。

因而跳表就诞生了。基于单链表,从第一个节点开始,每隔一个节点,创建索引。其实也是单链表。只不是中间省略了节点。

例如存在个单链表 1 3 4 5 7 8 9 10 13 16 17 18

抽象以后的索引为 1 4 7 9 13 17

若是要查询16只须要在索引层遍历到13,而后根据13存储的下层节点(真实链表节点的地址),此时只须要再遍历两个节点就能够找到值为16的节点。

因此能够从新给跳表下一个定义,链表加多级索引的结构,就是跳表

在跳表中,查询任意数据的时间复杂度是O(logn)。时间复杂度跟二分查找是同样的。能够换句话说,用单链表实现了二分查找。但这也是一种用空间换时间的思路,并非免费的。

End

关于Redis的基础数据结构和其底层的原理就简单的聊到这里,以后的几篇应该会聊聊Redis的高可用和其对应的解决方案,感兴趣的能够持续关注,公众号会比其余的平台都先更新。

往期文章:

若是你以为这篇文章对你有帮助,还麻烦点个赞关个注分个享留个言

也能够微信搜索公众号【SH的全栈笔记】,固然也能够直接扫描二维码关注

相关文章
相关标签/搜索