Redis
作为目前互联网领域使用最广泛的存储中间件,Redis
有着超高的性能,丰富的功能,其应用范围之广泛,几乎说道缓存,第一个选择便是Redis
。Redis
所提供的的丰富的数据结构以及对数据结构的各种操作指令使得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
来存储
对于
raw
和embstr
,两种的区别在于embstr
的对象头和SDS
是连在一起的,在分配的时候只用分配一次内存,但是对于raw
类型,对象头和SDS
在内存上是分开存储的,因此需要分配两次内存。之所以这么做的具体好处并没有得到权威的回答,但是从
embstr
调用一次append
操作之后就会变成raw
类型来看,embstr
应该是分配快,但是不便于扩容和动态变化,raw
分配慢,但是便于扩容,也就是说raw
主要应用在append
之后和大字符串中。 -
-
针对于
SDS
结构来说,它类似于Java
的ArrayList
,其包含3个元素:len
,capacity
,content
。其中len
为数组实际长度,capacity
为分配的容量,和ArrayList
一样,当len
大小将要超过capacity
的时候,SDS
会进行扩容操作。当第一次创建字符串的时候,
len
和capacity
一样长,当使用了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
结构如下:
ziplist
是一个压缩列表,可以将其理解为数组,每个ziplist
中存储了当前ziplist
的各种信息,比如尾元素偏移量,元素数量等,便于快速索引和倒叙索引。由于是数组,因此当需要插入或删除元素的时候,就需要对其重新分配内存,因此ziplist
的数据量不能太大(list-max-ziplist-size
: 默认8k)。
Hash
Redis
中的hash
和Java
中的HashMap
完全相同,实现方式也是通过数组+链表来完成的。
当Hash
中的value
类型的时候,可以使用HINCBY
和HINCBYFLOAT
,Hash
支持以下命令:
- 添加
Key
,Value
:hset name key value
- 获取key对应的value:
hget
- 删除
Key
:hdel
- 获取元素数量:
hlen
- 获取
field
的长度:hstrlen
- 检查元素是否存在某个值中:
hexists
- 获取
hash
中所有的keys
:hkeys
- 获取
hash
中所有的value
:hvals
对于Redis
来说,Hash
的实现和Java
中的实现方式基本类似,但是依然存在一些细节上的优化。
-
当元素个数小于512个,并且所有的
value
大小都小于64个字节的时候,hash
内部直接采用ziplist
来实现,ziplist
(压缩列表)是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。使用ziplist
通过时间换空间,能节约大部分内存。 -
对于
hash
来说,redis
的rehash
是一个时间复杂度为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
zset
是sortedSet
和HashMap
的结合体,它通过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
确是使用的 [跳跃列表] 来实现,跳跃列表的理解可以通过行政区的划分来理解,比如我说我是四川省成都市郫县人,则首先通过世界地图找到中国的位置,然后在中国找到四川的位置,再通过四川找到成都的位置,最后通过成都确认郫县的位置。
跳表也是通过这样一步一步缩小范围,最后找到指定的位置。借用了二分的思想,但又不是二分。
跳表的具体实现这里不详细介绍,感兴趣的可以去了解以下概念然后去LeetCode
上自己实现一遍,能够加深理解。
Redis
之所以使用跳表而不使用红黑树原因如下:
- 实现简单,相对于红黑树来说,实现更加的简单,不容易出错,代码更加容易维护和调试。
- 跳表的底层节点有都是通过双向指针相互链接,这和B+树一样,对于范围查找会更加的方便。
- 跳表的效率和红黑树一样,查找单个Key时间复杂度都是
O(logn)
- 跳表更加灵活,可以通过改变索引构建策略,有效的平衡执行效率和内存消耗。
Redis 在编写跳表的时候,通过优化指针,维护了每个指针指向的跨度
span
,这样,在计算排名(Rank)
的时候,只用将经过的所有的指针的Rank
都相加即可得到该节点的排名,
总结
Redis
提供的五种数据结构,虽然每种数据结构我们在开发中都觉得耳熟能详,并且都在数据结构的书中进行过相关的学习,但是Redis
本着精益求精的精神,给每种数据结构进行了极限的优化,这也是为什么Reids
仅仅使用单线程就能支持如此高的并发的原因之一。