Redis 深入解析

356 阅读10分钟

Redis作为目前互联网领域使用最广泛的存储中间件,Redis有着超高的性能,丰富的功能,其应用范围之广泛,几乎说道缓存,第一个选择便是RedisRedis所提供的的丰富的数据结构以及对数据结构的各种操作指令使得Redis不仅仅可以作为缓存缓存,其应用还有分布式锁,消息队列,布隆过滤器,延时队列,阻塞队列等等。

Redis总共提供了5种基本数据结构,每种数据结构都有Redis对其的优化,这也是Redis之所以能近乎单线程就能撑起大量的并发的原因之一。明白其优化原理,可以将其思想应用到其他系统中,也能帮助我们更好的使用Redis,下面简单介绍Redis提供的几种基本数据类型以及其对数据结构的优化。

以下总结来自于:《Redis 深度历险:核心原理与应用实践》


String

String是使用频率最多的数据类型,在Redis中,对String类型的数据的指令不仅仅是get,set。其中还包括:

  • append : 追加字符串

  • setrange: 修改范围的字符串

  • getlen: 获取字符串的长度

  • getrange:获取指定范围的字符串

  • setrange:设置范围内的字符串

  • incr: 给指定的value加一,原子操作

  • incrby: 给指定的value加上指定的值,原子操作

  • incrbyfolat: 给指定的value加上浮点数


    从上面可以看出来,Redis中的String类型其实在内部应该是包含两种,第一种是字符串,第二种是数字,对于字符串,可以获取或设置指定字符串的局部字符串,可以追加字符串内容,可以获取字符串长度,对于数字,可以增加或减少对应的值。

    Redis内部,对String类型优化共两部分:

    • String的数据类型被称为SDS(Simple Dynamic String),也就是动态字符串。

      此字符串中,可能包含3中数据类型:

      • int: 当存储的字符串全是数字的时候,此时使用int方式来存储

      • embstr: 当存储的内容小于44个字符的时候,使用embstr来存储

      • raw: 当存储的内容大于44个字符的时候,使用raw来存储

      对于rawembstr,两种的区别在于embstr的对象头和SDS是连在一起的,在分配的时候只用分配一次内存,但是对于raw类型,对象头和SDS在内存上是分开存储的,因此需要分配两次内存。

      之所以这么做的具体好处并没有得到权威的回答,但是从embstr调用一次append操作之后就会变成raw类型来看,embstr应该是分配快,但是不便于扩容和动态变化,raw分配慢,但是便于扩容,也就是说raw主要应用在append之后和大字符串中。

    • 针对于SDS结构来说,它类似于JavaArrayList,其包含3个元素:len,capacity,content。其中len为数组实际长度,capacity为分配的容量,和ArrayList一样,当len大小将要超过capacity的时候,SDS会进行扩容操作。

      当第一次创建字符串的时候,lencapacity一样长,当使用了append的时候,SDS将会进行扩容,字符串长度小于1M的时候,扩容采用加倍扩容,当长度超过1M,则每次增加1M.

List

Redis设计的List是用来对应链表,链表有个很重要的特点就是和插入的元素有关,一般对链表的操作有:对链表头元素操作,对链表尾元素操作,获取某个位置的元素,遍历元素,获取链表的大小等,在Redis中命令如下:

  • 从左边加入数据: lpush
  • 从右边加入数据:rpush
  • 移除左边的数据:lpop
  • 移除右边的数据:rpop
  • 删除元素: lrem
  • 在元素后面插入元素: linsert
  • 裁剪列表: ltrime
  • 遍历元素:lrange
  • 获取列表某个位置的元素:lindex
  • 获取列表的元素数量:llen

  • 阻塞的弹出列表左边的元素:blpop
  • 阻塞的弹出列表右边的元素:brpop

  • 列表间的操作:将列表的右边的元素弹出并加入另外列表的左边:rpoplpush
  • 阻塞的将列表的右边的元素弹出并加入另外列表的左边:brpoplpush

可以看出来,链表中最大的特点便是“有序”。链表所提供的操作,大多数都是操作头元素或尾元素以及链表间的操作命令,通过头元素和尾元素的随意组合,可以将链表当做栈或队列进行使用。虽然链表也支持操作中间元素,但是不建议使用,因为时间复杂度是O(n)。

对于链表,还有一个最大的功能便是可以阻塞,这用来实现生产者,消费者中的的Channel容器,在一些特殊的场景下可以应用。

Redis中,由于每个链表节点都需要两个链接前后节点的指针,对于64位系统来说,一个指针占用8个字节,每个节点都会多出16个字节的空间浪费(一般一个Int数据才占4个字节)。同时由于链表的内存都是以节点为单位进行分为,这样会导致内存碎片化,影响内存管理效率。

因此Redis使用quickList来实现链表。quickList结构如下:

image-20210129145357981

ziplist是一个压缩列表,可以将其理解为数组,每个ziplist中存储了当前ziplist的各种信息,比如尾元素偏移量,元素数量等,便于快速索引和倒叙索引。由于是数组,因此当需要插入或删除元素的时候,就需要对其重新分配内存,因此ziplist的数据量不能太大(list-max-ziplist-size: 默认8k)。

Hash

Redis中的hashJava中的HashMap完全相同,实现方式也是通过数组+链表来完成的。

Hash中的value类型的时候,可以使用HINCBYHINCBYFLOAT,Hash支持以下命令:

  • 添加Key,Value : hset name key value
  • 获取key对应的value: hget
  • 删除Key: hdel
  • 获取元素数量: hlen
  • 获取field的长度: hstrlen
  • 检查元素是否存在某个值中: hexists
  • 获取hash中所有的keyshkeys
  • 获取hash中所有的value: hvals

对于Redis来说,Hash的实现和Java中的实现方式基本类似,但是依然存在一些细节上的优化。

  • 当元素个数小于512个,并且所有的value大小都小于64个字节的时候,hash内部直接采用ziplist来实现,ziplist(压缩列表)是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。使用ziplist通过时间换空间,能节约大部分内存。

  • 对于hash来说,redisrehash是一个时间复杂度为O(n)的操作,因此Redis 通过渐进式hash来进行扩容。

    Rehash通过同时保留的旧的hashtable和新的hashtable,使得扩容操作可以逐渐的操作。在每次hset/hdel的时候,redis都会同时执行rehash,同时redis也启动了定时任务进行扩容。

Set

set的实现和hash完全一样,不同的是字典中所有的value都是NULL,因此特性也和Hash相同

set可以被看作是一个集合,因此支持的指令还包括集合间的操作。

  • 添加元素:sadd

  • 移除任意一个元素:spop

  • 移除一个或多个元素:srem

  • 判断元素是否是key的成员:sismember

  • 随机返回count个元素: srandmemeber

  • 返回集合中元素的数量: scard

  • 获取集合中所有的元素: smembers


  • 将一个元素从一个集合移动到另外一个集合: smove

  • 将多个集合的交集保存在另外一个集合中:sinterstore

  • 将多个集合的并集保存在另外一个集合中: sunionstore

  • 将多个集合的差集保存在另外一个集合中: sdiffstore

  • 获取多个元素的交集: sinter

  • 获取多个元素的并集: sunion

  • 获取多个元素的差集: sdiff


Set的实现和Hash一样,因此这里不再赘述优化。

ZSet

zsetsortedSetHashMap的结合体,它通过set保证了内部的唯一性,同时它可以给每个value赋予一个score,通过这个score进行排序,Zset支持以下指令:

Zset有两个概念:分值(score),也是权重, 排名(Rank),

Zset也是集合的一种,因此也支持集合间的操作

  • 添加元素:zadd

  • 修改元素的score: zincrby

  • 通过区间排名执行需要移除的成员:zremrangebyranK

  • 通过分数移除指定区间的成员:zremrangebyscore

  • 当成员的分数全都相同的时候,通过字典序排序,然后移除指定成员:zremrangbylex


  • 获取元素对应的分数: zscore

  • 获取key对应的元素数量: zcard

  • 获取元素对应分数区间的数量: zcount

  • 获取元素对应排名区间的所有元素和分数: zrange / zrevrange

  • 获取元素对应分数区间的所有元素和分数:zrangebyscore

  • 获取元素对应的排名:zrank/zrevrank

说到排序,大家可能首先的反应就是红黑树/B+树等,但是Redis确是使用的 [跳跃列表] 来实现,跳跃列表的理解可以通过行政区的划分来理解,比如我说我是四川省成都市郫县人,则首先通过世界地图找到中国的位置,然后在中国找到四川的位置,再通过四川找到成都的位置,最后通过成都确认郫县的位置。

跳表也是通过这样一步一步缩小范围,最后找到指定的位置。借用了二分的思想,但又不是二分。

image-20210125232939283

跳表的具体实现这里不详细介绍,感兴趣的可以去了解以下概念然后去LeetCode上自己实现一遍,能够加深理解。

Redis之所以使用跳表而不使用红黑树原因如下:

  • 实现简单,相对于红黑树来说,实现更加的简单,不容易出错,代码更加容易维护和调试。
  • 跳表的底层节点有都是通过双向指针相互链接,这和B+树一样,对于范围查找会更加的方便。
  • 跳表的效率和红黑树一样,查找单个Key时间复杂度都是O(logn)
  • 跳表更加灵活,可以通过改变索引构建策略,有效的平衡执行效率和内存消耗。

Redis 在编写跳表的时候,通过优化指针,维护了每个指针指向的跨度span,这样,在计算排名(Rank)的时候,只用将经过的所有的指针的Rank都相加即可得到该节点的排名,

总结

Redis提供的五种数据结构,虽然每种数据结构我们在开发中都觉得耳熟能详,并且都在数据结构的书中进行过相关的学习,但是Redis本着精益求精的精神,给每种数据结构进行了极限的优化,这也是为什么Reids仅仅使用单线程就能支持如此高的并发的原因之一。