Redis数据类型
Redis
提供了丰富的数据类型,常见的有五种:String
(字符串)、Hash
(哈希)、List
(列表)、Set
(集合)、Zset
(有序集合)。
随着Redis
版本的更新,后面又支持了四种数据类型:BitMap
(2.2
版新增)、HyperLogLog
(2.8
版新增)、GEO
(3.2
版新增)、Stream
(5.0
版新增)。
一、Redis核心对象
在Redis
中有一个核心的对象叫做redisObject
,是用来表示所有的key
和value
的,用redisObject
结构体来表示String
、Hash
、List
、Set
、ZSet
五种数据类型。
redisObject
的源代码在redis.h
中,使用c
语言写的,表示redisObject
的结构如下所示:
在redisObject
中type
表示所属数据类型,encoding
表示该对象编码,也就是底层实现该数据类型的存储方式。那么encoding
中的存储类型又分别表示什么意思呢?具体数据类型所表示的含义,如下所示:
类型常量(type) | 编码常量(encoding) | 底层结构 | Object命令的输出 |
---|---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 | int |
REDIS_ENCODING_EMBSTR | 使用embstr编码的sds实现的字符串对象 | embstr | |
REDIS_ENCODING_RAW | 使用sds实现的字符串对象 | raw | |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 | ziplist |
REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 | linkedlist | |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希对象 | ziplist |
REDIS_ENCODING_HT | 使用字典实现的哈希对象 | hashtable | |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 | intset |
REDIS_ENCODING_HT | 使用字典实现的集合对象 | hashtable | |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 | ziplist |
REDIS_ENCODING_SKIPTLIST | 使用跳跃表和字典实现的有序集合对象 | skiplist |
上面只是让你找到每种数据结构对应的储存类型有哪些,举一个简单的例子,在Redis
中设置一个字符串key 234
,然后查看这个字符串的存储类型就会看到为int
类型,非整数型的使用的是embstr
储存类型,具体操作如下所示:
127.0.0.1:6379> set key 234
OK
127.0.0.1:6379> object encoding key
"int"
127.0.0.1:6379> set k2 3.200
OK
127.0.0.1:6379> object encoding k2
"embstr"
二、String类型
String
是Redis
最基本的数据类型,也是最简单的类型,就是普通的set
和get
,做简单的K-V
缓存。一个键最大能存储512M
。
2.1 存储方式
String
类型的底层的数据结构实现主要是int
和SDS
(Simple Dynamic String
,简单动态字符串),内部编码(encoding
)有三种int
、raw
、embstr
。
2.1.1 int - 整型
Redis
中规定假如存储的是整数型值,比如set num 123
这样的类型,就会使用int
的存储方式进行存储,在redisObject
的「ptr属性」中就会保存该值。
2.1.2 SDS - 简单动态字符串
存储的字符串对象是一个字符串值就会使用SDS
方式进行存储。SDS
类似于Java
中的ArrayList
,可以通过预分配冗余空间的方式来减少内存的频繁分配。
SDS
称为简单动态字符串,对于SDS
中的定义在Redis
的源码中有的三个属性int len
、int free
、char buf[]
。
len
保存了字符串的长度;free
表示buf
数组中未使用的字节数量;buf
数组则是保存字符串的每一个字符元素。
如果字符串长度小于等于32
个字节,对象编码设置为embstr
。
如果存储的字符串值长度大于32
个字节,对象编码设置为raw
。(注意:如果空间不够,就会进行相应的空间扩展)
raw和embstr区别
embstr
会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject
和SDS
;raw
编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject
和SDS
。
embstr优缺点
优点:
embstr
编码将创建字符串对象所需的内存分配次数从raw
编码的两次降低为一次;- 释放
embstr
编码的字符串对象同样只需要调用一次内存释放函数; - 因为
embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用CPU
缓存提升性能。
缺点:
- 如果字符串的长度增加需要重新分配内存时,整个
redisObject
和sds
都需要重新分配空间,所以embstr
编码的字符串对象实际上是只读的,redis
没有为embstr
编码的字符串对象编写任何相应的修改程序。当我们对embstr
编码的字符串对象执行任何修改命令(例如append
)时,程序会先将对象的编码从embstr
转换成raw
,然后再执行修改命令。
SDS与c语言字符串对比
Redis
使用SDS
作为存储字符串的类型肯定是有自己的优势,SDS
与c
语言的字符串相比,SDS
对c
语言的字符串做了自己的设计和优化,具体优势有以下几点:
c
语言中的字符串并不会记录自己的长度,因此每次获取字符串的长度都会遍历得到,时间的复杂度是\(O(n)\),而Redis
中获取字符串只要读取len
的值就可,时间复杂度变为\(O(1)\)。c
语言中两个字符串拼接,若是没有分配足够长度的内存空间就会出现缓冲区溢出的情况;而SDS
会先根据len
属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以不会出现缓冲区溢出的情况。SDS
还提供空间预分配和惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。当字符串被缩短的时候,SDS
也不会立即回收不适用的空间,而是通过free
属性将不使用的空间记录下来,等后面使用的时候再释放。具体的空间预分配原则是:当修改字符串后的长度len
小于1MB
,就会预分配和len
一样长度的空间,即len=free;
若是len
大于1MB
,free
分配的空间大小就为1MB
。SDS
是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c
语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。
为了方便易懂,做了一个c
语言的字符串和SDS
进行对比的表格,如下所示:
c语言字符串 | SDS |
---|---|
获取长度的时间复杂度为O(n) | 获取长度的时间复杂度为O(1) |
不是二进制安全的 | 是二进制安全的 |
只能保存字符串 | 还可以保存二进制数据 |
n次增长字符串必然会带来n次的内存分配 | n次增长字符串内存分配的次数<=n |
2.2 应用场景
2.2.1 存储图片案例
- 首先要把上传得图片进行编码,这里写了一个工具类把图片处理成了
Base64
得编码形式,具体得实现代码如下:
/**
* 将图片内容处理成Base64编码格式
* @param file
* @return
*/
public static String encodeImg(MultipartFile file) {
byte[] imgBytes = null;
try {
imgBytes = file.getBytes();
} catch (IOException e) {
e.printStackTrace();
}
BASE64Encoder encoder = new BASE64Encoder();
return imgBytes == null ? null : encoder.encode(imgBytes);
}
- 第二步就是把处理后的图片字符串格式存储进
Redis
中,实现的代码如下所示:
/**
* Redis存储图片
* @param file
* @return
*/
public void uploadImageServiceImpl(MultipartFile image) {
String imgId = UUID.randomUUID().toString();
String imgStr= ImageUtils.encodeImg(image);
redisUtils.set(imgId , imgStr);
// 后续操作可以把imgId存进数据库对应的字段
// 如果需要从redis中取出,只要获取到这个字段后从redis中取出即可。
}
2.2.2 其他应用场景
- 缓存功能:
String
字符串是最常用的数据类型,不仅仅是Redis
,各个语言都是最基本类型,因此利用Redis
作为缓存,配合其它数据库作为存储层,利用Redis
支持高并发的特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。 - 计数器:许多系统都会使用
Redis
作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。 - 共享用户
Session
:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存Cookie
,但是可以利用Redis
将用户的Session
集中管理,在这种模式只需要保证Redis
的高可用,每次用户Session
的更新和获取都可以快速完成。大大提高效率。 - 分布式锁:
SET
命令有个NX
参数可以实现「key
不存在才插入」,可以用它来实现分布式锁。
三、Hash类型
Hash
是一个键值对(key-value
)集合,特别适合用于存储对象,其中value
的形式如:value=[{field1,value1},...,{fieldN,valueN}]。
Hash
与String
对象的区别如下图所示:
3.1 底层实现
Hash
对象的实现方式有两种分别是压缩列表(ziplist
)和哈希表(hashtable
),其中hashtable
的存储方式key
是String
类型的,value
也是以键值对(key-value
)的形式进行存储。
- 如果哈希类型元素个数小于
512
个(默认值,可由hash-max-ziplist-entries
配置),所有值小于64
字节(默认值,可由hash-max-ziplist-value
配置)的话,Redis
会使用ziplist
作为Hash
类型的底层数据结构; - 如果哈希类型元素不满足上面条件,
Redis
会使用hashtable
作为Hash
类型的底层数据结构。
3.1.1 ziplist - 压缩列表
ziplist
(压缩列表)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:
压缩列表中每一个节点表示的含义如下所示:
zlbytes
:4
个字节的大小,记录压缩列表占用内存的字节数。zltail
:4
个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。zllen
:2
个字节的大小,记录压缩列表中的节点数。entry
:表示列表中的每一个节点。zlend
:表示压缩列表的特殊结束符号'0xFF'
。
在压缩列表中每一个entry
节点又有三部分组成,包括previous_entry_ength
、encoding
、content
。
prelen
:表示前一个节点entry
的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。encoding
:这里保存的是content
的内容类型和长度。entry-data
:保存的是每一个节点的内容。
在
Redis 7.0
中,压缩列表数据结构已经废弃了,交由listpack
数据结构来实现了。具体请参考listpack
连锁更新是ziplist
一个比较大的缺点,这也是在7.0
被listpack
所替代的一个重要原因。
ziplist
在更新或者新增时候,如空间不够则需要对整个列表进行重新分配。当新插入的元素较大时,可能会导致后续元素的prevlen
占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
ziplist
节点的prevlen
属性会根据前一个节点的长度进行不同的空间大小分配:
- 如果前一个节点的长度小于
254
字节,那么prevlen
属性需要用1
字节的空间来保存这个长度值。 - 如果前一个节点的长度大于等于
254
字节,那么prevlen
属性需要用5
字节的空间来保存这个长度值。
假设有这样的一个ziplist
,每个节点都是等于253
字节的。新增了一个大于等于254
字节的新节点,由于之前的节点prevlen
长度是1
个字节。
为了要记录新增节点的长度所以需要对节点1
进行扩展,由于节点1
本身就是253
字节,再加上扩展为5
字节的pervlen
则长度超过了254
字节,这时候下一个节点又要进行扩展了。噩梦就开始了。
3.1.2 listpack - 紧凑列表
Redis
在后续的版本中也采用了quicklist
(快速链表),通过控制quicklistNode
结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题,因为quicklistNode
还是用了压缩列表来保存元素。
所以在Redis5.0
出现了listpack
,目的是替代压缩列表,其最大特点是listpack
中每个节点不再包含前一个节点的长度,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。
listpack
的内存结构图
encoding
:定义该元素的编码类型,会对不同长度的整数和字符串进行编码。data
:实际存放的数据。len
:encoding + data
的总长度,len
代表当前节点的回朔起始地址长度的偏移量。
从上图不难得到这样的结论:listpack
没有记录前一个节点长度,只记录当前节点的长度,从而避免了压缩列表的连锁更新问题。
ziplist
和listpack
区别:
listpack
中每个节点不再包含前一个节点的长度,避免连锁更新的隐患发生。listpack
相对于ziplist
,没有了指向末尾节点地址的偏移量,解决ziplist
内存长度限制的问题。但一个listpack
最大内存使用不能超过1GB
。
3.1.3 hashtable - 哈希表
字典类型(dict
)的底层就是hashtable
实现的,明白了字典的底层实现原理也就是明白了hashtable
的实现原理,hashtable
的实现原理可以与HashMap
的是底层原理相似。
两者在新增时都会通过key
计算出数组下标,不同的是计算法方式不同,HashMap
中是以hash
函数的方式,而hashtable
中计算出hash
值后,还要通过sizemask
属性和哈希值再次得到数组下标。
我们知道hash
表最大的问题就是hash
冲突,为了解决hash
冲突,假如hashtable
中不同的key
通过计算得到同一个index
,就会形成单向链表(链地址法),如下图所示:
rehash
在字典的底层实现中,value
对象以每一个dictEntry
的对象进行存储,当hash
表中的存放的键值对不断的增加或者减少时,需要对hash
表进行一个扩展或者收缩。
这里就会和HashMap
一样也会就进行rehash
操作,进行重新散列排布。从上图中可以看到有ht[0]
和ht[1]
两个对象,先来看看对象中的属性是干嘛用的。
在hash
表结构定义中有四个属性,代表的含义如下:
dictEntry table
:哈希表数组;unsigned long size
:hash
表大小;unsigned long sizemask
:用于计算索引值,总是等于size-1
;unsigned long used
:hash
表中已有的节点数。
ht[0]
是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]
的大小就决定了ht[1]
的大小,ht[0]
中的所有的键值对就会重新散列到ht[1]
中。
- 扩展操作:
ht[1]
扩展的大小是比当前ht[0].used
值的二倍大的第一个2
的整数幂; - 收缩操作:
ht[0].used
的第一个大于等于的2
的整数幂。
当ht[0]
上的所有的键值对都rehash
到ht[1]
中,会重新计算所有的数组下标值,当数据迁移完后ht[0]
就会被释放,然后将ht[1]
改为ht[0]
,并新创建ht[1]
,为下一次的扩展和收缩做准备。
渐进式rehash
假如在rehash
的过程中数据量非常大,Redis
不是一次性把全部数据rehash
成功,这样会导致Redis
对外服务停止,Redis
内部为了处理这种情况采用渐进式的rehash
。
Redis
将所有的rehash
的操作分成多步进行,直到都rehash
完成,具体的实现与对象中的rehashindex
属性相关,若是rehashindex
表示为-1
表示没有rehash
操作。当rehash
操作开始时会将该值改成0
,在渐进式rehash
的过程更新、删除、查询会在ht[0]
和ht[1]
中都进行,比如更新一个值先更新ht[0]
,然后再更新ht[1]
。而新增操作直接就新增到ht[1]
表中,ht[0]
不会新增任何的数据,这样保证ht[0]
只减不增,直到最后的某一个时刻变成空表,这样rehash
操作完成。
上面就是字典的底层hashtable
的实现原理。
3.2 应用场景
哈希表相对于String
类型存储信息更加直观,存储更加方便,经常会用来做用户数据的管理,存储用户的信息。hash
也可以用作高并发场景下使用Redis
生成唯一的id
。下面我们就以这两种场景用作案例编码实现。
3.2.1 存储用户数据
第一个场景比如我们要储存用户信息,一般使用用户的ID
作为key
值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value
值存储。
若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。
但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。
若是使用Redis
的hash
来存储用户数据,就会将原来的value
值又看成了一个kv
形式的存储容器,这样就不会带来序列化的性能开销的问题。
3.2.2 分布式生成唯一ID
第二个场景就是生成分布式的唯一ID
,这个场景下就是把redis
封装成了一个工具类进行实现,实现的代码如下:
// offset表示的是id的递增梯度值
public Long getId(String key, String hashKey, Long offset) throws BusinessException {
try {
if (null == offset) {
offset=1L;
}
// 生成唯一id
return redisUtil.increment(key, hashKey, offset);
} catch (Exception e) {
// 若是出现异常就是用uuid来生成唯一的id值
int randNo = UUID.randomUUID().toString().hashCode();
if (randNo < 0) {
randNo=-randNo;
}
return Long.valueOf(String.format("%16d", randNo));
}
}
3.2.3 购物车
以用户id
为key
,商品id
为field
,商品数量为value
,恰好构成了购物车的3
个要素。
四、List类型
List
列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List
列表添加元素。
列表的最大长度为\(2^32 - 1\),也即每个列表支持超过40
亿个元素。
4.1 底层实现
Redis
中的列表在3.2
之前的版本是使用ziplist
(压缩列表)和linkedlist
(双向链表)进行实现的。
- 如果列表的元素个数小于
512
个(默认值,可由list-max-ziplist-entries
配置),列表每个元素的值都小于64
字节(默认值,可由list-max-ziplist-value
配置),Redis
会使用ziplist
作为List
类型的底层数据结构; - 如果列表的元素不满足上面的条件,
Redis
会使用linkedlist
作为List
类型的底层数据结构;
4.1.1 linkedlist - 双向链表
linkedlist
是一个双向链表,普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度为\(O(1)\),但是查询的时间复杂度却是\(O(n)\)。
linkedlist
的底层实现是采用链表进行实现,在c
语言中并没有内置的链表这种数据结构,Redis
实现了自己的链表结构。
各属性含义如下:
head
指向链表的头节点;tail
指向链表的尾节点;len
表示这个链表共有多少个节点,这样就可以在\(O(1)\)的时间复杂度内获得链表的长度;dup
函数,用于链表转移复制时对节点value
拷贝的一个实现,一般情况下使用等号足以,但在某些特殊情况下可能会用到节点转移函数,默认可以给这个函数赋值NULL
,即表示使用等号进行节点转移;free
函数,用于释放一个节点所占用的内存空间,默认赋值NULL
的话,可使用Redis
自带的zfree()
函数进行内存空间释放;match
函数,用来比较两个链表节点的value
值是否相等,相等返回1
,不等返回0
。
Redis
中链表的特性:
- 每一个节点都有指向前一个节点和后一个节点的指针。
- 头节点和尾节点的
prev
和next
指针指向为null
,所以链表是无环的。 - 链表有自己长度的信息,获取长度的时间复杂度为\(O(1)\)。
4.1.2 quicklist - 双向链表
在3.2
之后的版本就是引入了quicklist
(双向链表)。
ziplist
会引入频繁的内存申请和释放,而linkedlist
由于指针也会造成内存的浪费,而且每个节点是单独存在的,会造成很多内存碎片,所以结合两个结构的特点,设计了quickList
。
quickList
是一个ziplist
组成的双向链表。每个节点使用ziplist
来保存数据。本质上来说,quicklist
里面保存着一个一个小的ziplist
。
quicklist
和linkedlist
底层实现相同都是链表。
quickList
中每个ziplist
节点可以存储多少个元素?
# Lists are also encoded in a special way to save a lot of space.
# The number of entries allowed per internal list node can be specified
# as a fixed maximum size or a maximum number of elements.
# For a fixed maximum size, use -5 through -1, meaning:
# -5: max size: 64 Kb <-- not recommended for normal workloads
# -4: max size: 32 Kb <-- not recommended
# -3: max size: 16 Kb <-- probably not recommended
# -2: max size: 8 Kb <-- good
# -1: max size: 4 Kb <-- good
# Positive numbers mean store up to _exactly_ that number of elements
# per list node.
# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),
# but if your use case is unique, adjust the settings as necessary.
list-max-ziplist-size -2
quicklist
内部默认单个ziplist
长度为8k
字节,即list-max-ziplist-size
为-2
,超出了这个阈值,就会重新生成一个ziplist
来存储数据。根据注释可知,性能最好的时候就是就是list-max-ziplist-size
为-1
和-2
,即分别是4kb
和8kb
的时候,当然,这个值也可以被设置为正数,当list-max-ziplist-szie
为正数n
时,表示每个quickList
节点上的zipList
最多包含n
个数据项。
压缩深度
quicklist
中可以使用压缩算法对ziplist
进行进一步的压缩,这个算法就是LZF算法,这是一种无损压缩算法,具体可以参考上面的链接。使用压缩算法对ziplist
进行压缩后,此时quicklist
的示意图如下所示:
当然,在redis.conf
文件中的DVANCED CONFIG
下面也可以对压缩深度进行配置。
# Lists may also be compressed.
# Compress depth is the number of quicklist ziplist nodes from *each* side of
# the list to *exclude* from compression. The head and tail of the list
# are always uncompressed for fast push/pop operations. Settings are:
# 0: disable all list compression
# 1: depth 1 means "don't start compressing until after 1 node into the list,
# going from either the head or tail"
# So: [head]->node->node->...->node->[tail]
# [head], [tail] will always be uncompressed; inner nodes will compress.
# 2: [head]->[next]->node->node->...->node->[prev]->[tail]
# 2 here means: don't compress head or head->next or tail->prev or tail,
# but compress all nodes between them.
# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]
# etc.
list-compress-depth 0
list-compress-depth
这个参数表示一个quicklist
两端不被压缩的节点个数。
list-compress-depth
为0
,表示不进行压缩,此为quicklist
的默认值;list-compress-depth
为1
,表示quicklist
的两端各有1
个节点不进行压缩,中间结点开始进行压缩;list-compress-depth
为2
,表示quicklist
的首尾2
个节点不进行压缩,中间结点开始进行压缩;- 以此类推。
从上面可以看出,对于quicklist
来说,其首尾两个节点永远不会被压缩。
4.2 应用场景
4.2.1 消息队列
Redis
的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过Lpush命令从左边插入数据,多个数据消费者,可以使用BRpop命令阻塞的“抢”列表尾部的数据。
4.2.2 文章列表或者数据分页展示的应用
比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用Redis
的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能,大大提高查询效率。
五、Set类型
Redis
中列表和集合都可以用来存储字符串,但是Set
是不可重复的集合,而List
列表可以存储相同的字符串,Set
集合是无序的这个和后面讲的ZSet
有序集合相对。
Set
类型和List
类型的区别如下:
List
可以存储重复元素,Set
只能存储非重复元素;List
是按照元素的先后顺序存储元素的,而Set
则是无序方式存储元素的。
5.1 底层实现
Set
的底层实现是hashtable
(哈希表)和intset
(整数集合)。
- 如果集合中的元素都是整数且元素个数小于
512
(默认值,set-maxintset-entries
配置)个,Redis
会使用整数集合作为Set
类型的底层数据结构; - 如果集合中的元素不满足上面条件,则
Redis
使用哈希表作为Set
类型的底层数据结构。
5.1.1 intset - 整数集合
intset
也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_t
、int32_t
或者int64_t
的整数值。
在整数集合中,有三个属性值
encoding
:编码方式;length
:整数集合的长度,length
就是记录contents
里面的大小;contents[]
:元素内容。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
- 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
- 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
- 整数集合升级后就不会再降级,编码会一直保持升级后的状态。
5.2 应用场景
Set
集合的应用场景可以用来去重、点赞、抽奖、共同好友、二度好友等业务类型。接下来模拟一个添加好友的案例实现:
@RequestMapping(value = "/addFriend", method = RequestMethod.POST)
public Long addFriend(User user, String friend) {
String currentKey = null;
// 判断是否是当前用户的好友
if (AppContext.getCurrentUser().getId().equals(user.getId)) {
currentKey = user.getId.toString();
}
// 若是返回0则表示不是该用户好友
return currentKey == null ? 0l : setOperations.add(currentKey, friend);
}
假如两个用户A
和B
都是用上面的这个接口添加了很多的自己的好友,那么有一个需求就是要实现获取A
和B
的共同好友,那么可以进行如下操作:
public Set intersectFriend(User userA, User userB) {
return setOperations.intersect(userA.getId.toString(), userB.getId.toString());
}
举一反三,还可以实现A
用户自己的好友,或者B
用户自己的好友等,都可以进行实现。
六、ZSet类型
Zset
(Sorted set
)是有序集合类型,相比于Set
类型多了一个排序属性score
(分值),对于有序集合ZSet
来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
6.1 底层实现
Zset
的底层实现是ziplist
(压缩列表)和skiplist
(跳表)实现的。
- 如果有序集合的元素个数小于
128
个,并且每个元素的值小于64
字节时,Redis
会使用压缩列表作为Zset
类型的底层数据结构; - 如果有序集合的元素不满足上面的条件,
Redis
会使用跳表作为Zset
类型的底层数据结构;
6.1.1 skiplist - 跳表
skiplist
(跳跃表)是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist
有如下几个特点:
- 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
- 每一层都是一个有序链表,至少包含两个节点,头节点和尾节点。
- 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
- 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
具体实现的结构图如下所示
在跳跃表的结构中有head
和tail
表示指向头节点和尾节点的指针,能快速的实现定位。level
表示层数,len
表示跳跃表的长度,BW
表示后退指针,在从尾向前遍历的时候使用。BW
下面还有两个值分别表示分值(score
)和成员对象(各个节点保存的成员对象)。跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。
6.2 应用场景
zset
的使用场景与set
类似,但是set
集合不是自动有序的,而zset
可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择zset
数据结构作为选择方案。
-
排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
-
用ZSets来做带权重的队列,比如普通消息的
score
为1
,重要消息的score
为2
,然后工作线程可以选择按score
的倒序来获取工作任务。让重要的任务优先执行。 -
微博热搜榜,就是有个后面的热度值,前面就是名称。因为
zset
是有序的集合,因此zset
在实现排序类型的业务是比较常见的,比如在首页推荐10
个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。下面就选用获取排行榜前前10
名的选手作为案例实现,实现的代码如下所示:
/**
* 获取前10排名
* @return
*/
public static List<levelVO > getZset(String key, long baseNum, LevelService levelService) {
ZSetOperations<Serializable, Object> operations = redisTemplate.opsForZSet();
// 根据score分数值获取前10名的数据
Set<ZSetOperations.TypedTuple<Object>> set = operations.reverseRangeWithScores(key, 0, 9);
List<LevelVO> list = new ArrayList<LevelVO>();
int i = 1;
for (ZSetOperations.TypedTuple<Object> o : set) {
int uid = (int) o.getValue();
LevelCache levelCache = levelService.getLevelCache(uid);
LevelVO levelVO = levelCache.getLevelVO();
long score = (o.getScore().longValue() - baseNum + levelVO.getCtime()) / CommonUtil.multiplier;
levelVO .setScore(score);
levelVO .setRank(i);
list.add(levelVO);
i++;
}
return list;
}
以上的代码实现大致逻辑就是根据score
分数值获取前10
名的数据,然后封装成lawyerVO
对象的列表进行返回。
七、Bitmap类型
Bitmap
,即位图,是一串连续的二进制数组(0
和1
),可以通过偏移量(offset
)定位元素。BitMap
通过最小的单位bit
来进行0|1
的设置,表示某个元素的值或者状态,时间复杂度为\(O(1)\)。
由于bit
是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
7.1 底层实现
Bitmap
本身是用String
类型作为底层数据结构实现的一种统计二值状态的数据类型。
String
类型是会保存为二进制的字节数组,所以,Redis
就把字节数组的每个bit
位利用起来,用来表示一个元素的二值状态,你可以把Bitmap
看作是一个bit
数组。
7.2 应用场景
Bitmap
类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有0
和1
两种,在记录海量数据时,Bitmap
能够有效地节省内存空间。
7.2.1 签到统计
在签到打卡的场景中,我们只用记录签到(1)
或未签到(0)
,所以它就是非常典型的二值状态。
签到统计时,每个用户一天的签到用1
个bit
位就能表示,一个月(假设是31
天)的签到情况用31
个bit
位就可以,而一年的签到也只需要用365
个bit
位,根本不用太复杂的集合类型。
7.2.2 判断用户登陆态
Bitmap
提供了GETBIT
、SETBIT
操作,通过一个偏移值offset
对bit
数组的offset
位置的bit
位进行读写操作,需要注意的是offset
从0
开始。
只需要一个key=login_status
表示存储用户登陆状态集合数据,将用户ID
作为offset
,在线就设置为1
,下线设置0
。通过GETBIT
判断对应的用户是否在线。50000
万用户只需要6MB
的空间。
7.2.3 连续签到用户总数
把每天的日期作为Bitmap
的key
,userId
作为offset
,若是打卡则将offset
位置的bit
设置成1
。
key
对应的集合的每个bit
位的数据则是一个用户在该日期的打卡记录。
一共有7
个这样的Bitmap
,如果我们能对这7
个Bitmap
的对应的bit
位做『与』运算。同样的UserID
offset
都是一样的,当一个userID
在7
个Bitmap
对应对应的offset
位置的bit=1
就说明该用户7
天连续打卡。
结果保存到一个新Bitmap
中,我们再通过BITCOUNT
统计bit=1
的个数便得到了连续打卡3
天的用户总数了。
八、HyperLogLog类型
HyperLogLog
是Redis 2.8.9
版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog
是统计规则是基于概率完成的,不是非常准确,标准误算率是0.81%
。
所以,简单来说HyperLogLog
提供不精确的去重计数。
HyperLogLog
的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在Redis
里面,每个HyperLogLog
键只需要花费12KB
内存,就可以计算接近2^64
个不同元素的基数,和元素越多就越耗费内存的Set
和Hash
类型相比,HyperLogLog
就非常节省空间。
这什么概念?举个例子给大家对比一下。
用Java
语言来说,一般long
类型占用8
字节,而1
字节有8
位,即:\(1byte = 8bit\),即long
数据类型最大可以表示的数是:\(2^{63}-1\)。对应上面的\(2^{64}\)个数,假设此时有\(2^{63}-1\)这么多个数,从\(0 \sim 2^{63}-1\),按照long
以及1k=1024
字节的规则来计算内存总数,就是:\(((2^{63}-1) * 8/1024)K\),这是很庞大的一个数,存储空间远远超过12K
,而HyperLogLog
却可以用12K
就能统计完。
8.1 应用场景
百万级网页UV计数:HyperLogLog
优势在于只需要花费12KB
内存,就可以计算接近\(2^{64}\)个元素的基数,和元素越多就越耗费内存的Set
和Hash
类型相比,HyperLogLog
就非常节省空间。
所以,非常适合统计百万级以上的网页UV
的场景。
九、Geo类型
GEO
是Redis 3.2
版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service
,LBS
)的应用。LBS
应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO
就非常适合应用在LBS
服务的场景中。
9.1 底层实现
GEO
本身并没有设计新的底层数据结构,而是直接使用了zset
集合类型。
GEO
类型使用GeoHash
编码方法实现了经纬度到zset
中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为zset
元素的权重分数。
这样一来,我们就可以把经纬度保存到zset
中,利用zset
提供的“按权重进行有序范围查找”的特性,实现LBS
服务中频繁使用的“搜索附近”的需求。
9.2 应用场景
这里以滴滴叫车的场景为例,介绍下具体如何使用GEO
命令:GEOADD
和GEORADIUS
这两个命令。
假设车辆ID
是33
,经纬度位置是(116.034579
,39.030452
),我们可以用一个GEO
集合保存所有车辆的经纬度,集合key
是cars:locations
。
当用户想要寻找自己附近的网约车时,LBS
应用就可以使用GEORADIUS
命令。
例如,LBS
应用执行下面的命令时,Redis
会根据输入的用户的经纬度信息(116.054579
,39.030452
),查找以这个经纬度为中心的5
公里内的车辆信息,并返回给LBS
应用。
可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用Redis
来实现附近的人?或者计算最优地图路径?这三个其实也可以算作一种数据结构,不知道还有多少朋友记得,我在梦开始的地方,Redis
基础中提到过,你如果只知道五种基础类型那只能拿60
分,如果你能讲出高级用法,那就觉得你有点东西。
十、Stream类型
Stream
是Redis 5.0
版本新增加的数据类型,Redis
专门为消息队列设计的数据类型。
在Redis 5.0 Stream
没出来之前,消息队列的实现方式都有着各自的缺陷,例如:
- 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
List
实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一ID
。
基于以上问题,Redis 5.0
便推出了Stream
类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一ID
、支持ack
确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。
10.1 应用场景
10.1.1 消息队列
生产者通过XADD
命令插入一条消息,消费者通过XREAD
命令从消息队列中读取消息时,可以指定一个消息ID
,并从这个消息ID
的下一条消息开始进行读取
十一、总结
Redis
常见的五种数据类型:String
(字符串),Hash
(哈希),List
(列表),Set
(集合)及Zset
(sorted set
:有序集合)。
这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。
Redis
五种数据类型的应用场景:
String
类型:缓存对象、常规计数、分布式锁、共享session
信息等。List
类型:消息队列(有两个问题:1.生产者需要自行实现全局唯一ID;2.不能以消费组形式消费数据)等。Hash
类型:缓存对象、购物车等。Set
类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。Zset
类型:排序场景,比如排行榜、电话和姓名排序等。
Redis
后续版本又支持四种数据类型,它们的应用场景如下:
BitMap
(2.2
版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;HyperLogLog
(2.8
版新增):海量数据基数统计的场景,比如百万级网页UV
计数等;GEO
(3.2
版新增):存储地理位置信息的场景,比如滴滴叫车;Stream
(5.0
版新增):消息队列,相比于基于List
类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID
,支持以消费组形式消费数据。