Redis数据类型

Redis提供了丰富的数据类型,常见的有五种:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)。

随着Redis版本的更新,后面又支持了四种数据类型:BitMap(2.2版新增)、HyperLogLog(2.8版新增)、GEO(3.2版新增)、Stream(5.0版新增)。

一、Redis核心对象

Redis中有一个核心的对象叫做redisObject,是用来表示所有的keyvalue的,用redisObject结构体来表示StringHashListSetZSet五种数据类型。

redisObject的源代码在redis.h中,使用c语言写的,表示redisObject的结构如下所示:

redisObjecttype表示所属数据类型,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类型

StringRedis最基本的数据类型,也是最简单的类型,就是普通的setget,做简单的K-V缓存。一个键最大能存储512M

2.1 存储方式

String类型的底层的数据结构实现主要是intSDS(Simple Dynamic String,简单动态字符串),内部编码(encoding)有三种intrawembstr

2.1.1 int - 整型

Redis中规定假如存储的是整数型值,比如set num 123这样的类型,就会使用int的存储方式进行存储,在redisObject「ptr属性」中就会保存该值。

2.1.2 SDS - 简单动态字符串

存储的字符串对象是一个字符串值就会使用SDS方式进行存储。SDS类似于Java中的ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。

SDS称为简单动态字符串,对于SDS中的定义在Redis的源码中有的三个属性int lenint freechar buf[]

  • len保存了字符串的长度;
  • free表示buf数组中未使用的字节数量;
  • buf数组则是保存字符串的每一个字符元素。

如果字符串长度小于等于32个字节,对象编码设置为embstr

如果存储的字符串值长度大于32个字节,对象编码设置为raw。(注意:如果空间不够,就会进行相应的空间扩展)

raw和embstr区别

  • embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObjectSDS
  • raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObjectSDS

embstr优缺点

优点

  1. embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次;
  2. 释放embstr编码的字符串对象同样只需要调用一次内存释放函数;
  3. 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用CPU缓存提升性能。

缺点

  1. 如果字符串的长度增加需要重新分配内存时,整个redisObjectsds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。

SDS与c语言字符串对比

Redis使用SDS作为存储字符串的类型肯定是有自己的优势,SDSc语言的字符串相比,SDSc语言的字符串做了自己的设计和优化,具体优势有以下几点:

  1. c语言中的字符串并不会记录自己的长度,因此每次获取字符串的长度都会遍历得到,时间的复杂度是\(O(n)\),而Redis中获取字符串只要读取len的值就可,时间复杂度变为\(O(1)\)
  2. c语言中两个字符串拼接,若是没有分配足够长度的内存空间就会出现缓冲区溢出的情况;而SDS会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以不会出现缓冲区溢出的情况
  3. SDS还提供空间预分配惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。具体的空间预分配原则是:当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MBfree分配的空间大小就为1MB
  4. SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。

为了方便易懂,做了一个c语言的字符串和SDS进行对比的表格,如下所示:

c语言字符串 SDS
获取长度的时间复杂度为O(n) 获取长度的时间复杂度为O(1)
不是二进制安全的 是二进制安全的
只能保存字符串 还可以保存二进制数据
n次增长字符串必然会带来n次的内存分配 n次增长字符串内存分配的次数<=n

2.2 应用场景

2.2.1 存储图片案例

  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);
}
  1. 第二步就是把处理后的图片字符串格式存储进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}]。

HashString对象的区别如下图所示:

3.1 底层实现

Hash对象的实现方式有两种分别是压缩列表(ziplist)和哈希表(hashtable),其中hashtable的存储方式keyString类型的,value也是以键值对(key-value)的形式进行存储。

  • 如果哈希类型元素个数小于512个(默认值,可由hash-max-ziplist-entries配置),所有值小于64字节(默认值,可由hash-max-ziplist-value配置)的话,Redis会使用ziplist作为Hash类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis会使用hashtable作为Hash类型的底层数据结构。

3.1.1 ziplist - 压缩列表

ziplist(压缩列表)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:

压缩列表中每一个节点表示的含义如下所示:

  1. zlbytes4个字节的大小,记录压缩列表占用内存的字节数。
  2. zltail4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
  3. zllen2个字节的大小,记录压缩列表中的节点数。
  4. entry:表示列表中的每一个节点。
  5. zlend:表示压缩列表的特殊结束符号'0xFF'

在压缩列表中每一个entry节点又有三部分组成,包括previous_entry_engthencodingcontent

  1. prelen:表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
  2. encoding:这里保存的是content的内容类型和长度。
  3. entry-data:保存的是每一个节点的内容。

Redis 7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。具体请参考listpack

连锁更新是ziplist一个比较大的缺点,这也是在7.0listpack所替代的一个重要原因。

ziplist在更新或者新增时候,如空间不够则需要对整个列表进行重新分配。当新插入的元素较大时,可能会导致后续元素的prevlen占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。

ziplist节点的prevlen属性会根据前一个节点的长度进行不同的空间大小分配:

  1. 如果前一个节点的长度小于254字节,那么prevlen属性需要用1字节的空间来保存这个长度值。
  2. 如果前一个节点的长度大于等于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:实际存放的数据。
  • lenencoding + data的总长度,len代表当前节点的回朔起始地址长度的偏移量。

从上图不难得到这样的结论:listpack没有记录前一个节点长度,只记录当前节点的长度,从而避免了压缩列表的连锁更新问题。

ziplistlistpack区别:

  1. listpack中每个节点不再包含前一个节点的长度,避免连锁更新的隐患发生。
  2. 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 sizehash表大小;
  • unsigned long sizemask:用于计算索引值,总是等于size-1
  • unsigned long usedhash表中已有的节点数。

ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。

  • 扩展操作:ht[1]扩展的大小是比当前ht[0].used值的二倍大的第一个2的整数幂;
  • 收缩操作:ht[0].used的第一个大于等于的2的整数幂。

ht[0]上的所有的键值对都rehashht[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值存储。

若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。

但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。

若是使用Redishash来存储用户数据,就会将原来的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 购物车

以用户idkey,商品idfield,商品数量为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中链表的特性:

  1. 每一个节点都有指向前一个节点和后一个节点的指针。
  2. 头节点和尾节点的prevnext指针指向为null,所以链表是无环的。
  3. 链表有自己长度的信息,获取长度的时间复杂度为\(O(1)\)

4.1.2 quicklist - 双向链表

3.2之后的版本就是引入了quicklist(双向链表)。

ziplist会引入频繁的内存申请和释放,而linkedlist由于指针也会造成内存的浪费,而且每个节点是单独存在的,会造成很多内存碎片,所以结合两个结构的特点,设计了quickList

quickList是一个ziplist组成的双向链表。每个节点使用ziplist来保存数据。本质上来说,quicklist里面保存着一个一个小的ziplist

quicklistlinkedlist底层实现相同都是链表。

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,即分别是4kb8kb的时候,当然,这个值也可以被设置为正数,当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-depth0,表示不进行压缩,此为quicklist的默认值;
  • list-compress-depth1,表示quicklist的两端各有1个节点不进行压缩,中间结点开始进行压缩;
  • list-compress-depth2,表示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_tint32_t或者int64_t的整数值。

在整数集合中,有三个属性值

  • encoding:编码方式;
  • length:整数集合的长度,length就是记录contents里面的大小;
  • contents[]:元素内容。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  1. 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  2. 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  3. 整数集合升级后就不会再降级,编码会一直保持升级后的状态。

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);
}

假如两个用户AB都是用上面的这个接口添加了很多的自己的好友,那么有一个需求就是要实现获取AB的共同好友,那么可以进行如下操作:

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有如下几个特点:

  1. 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
  2. 每一层都是一个有序链表,至少包含两个节点,头节点和尾节点。
  3. 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
  4. 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。

具体实现的结构图如下所示


在跳跃表的结构中有headtail表示指向头节点和尾节点的指针,能快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。

6.2 应用场景

zset的使用场景与set类似,但是set集合不是自动有序的,而zset可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择zset数据结构作为选择方案。

  • 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。

  • ZSets来做带权重的队列,比如普通消息的score1,重要消息的score2,然后工作线程可以选择按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,即位图,是一串连续的二进制数组(01),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为\(O(1)\)

由于bit是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。

7.1 底层实现

Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。

String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态,你可以把Bitmap看作是一个bit数组。

7.2 应用场景

Bitmap类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有01两种,在记录海量数据时,Bitmap能够有效地节省内存空间。

7.2.1 签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

签到统计时,每个用户一天的签到用1bit位就能表示,一个月(假设是31天)的签到情况用31bit位就可以,而一年的签到也只需要用365bit位,根本不用太复杂的集合类型。

7.2.2 判断用户登陆态

Bitmap提供了GETBITSETBIT操作,通过一个偏移值offsetbit数组的offset位置的bit位进行读写操作,需要注意的是offset0开始。

只需要一个key=login_status表示存储用户登陆状态集合数据,将用户ID作为offset,在线就设置为1,下线设置0。通过GETBIT判断对应的用户是否在线。50000万用户只需要6MB的空间。

7.2.3 连续签到用户总数

把每天的日期作为BitmapkeyuserId作为offset,若是打卡则将offset位置的bit设置成1

key对应的集合的每个bit位的数据则是一个用户在该日期的打卡记录。

一共有7个这样的Bitmap,如果我们能对这7Bitmap的对应的bit位做『与』运算。同样的UserID offset都是一样的,当一个userID7Bitmap对应对应的offset位置的bit=1就说明该用户7天连续打卡。

结果保存到一个新Bitmap中,我们再通过BITCOUNT统计bit=1的个数便得到了连续打卡3天的用户总数了。

八、HyperLogLog类型

HyperLogLogRedis 2.8.9版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog是统计规则是基于概率完成的,不是非常准确,标准误算率是0.81%

所以,简单来说HyperLogLog提供不精确的去重计数。

HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。

Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同元素的基数,和元素越多就越耗费内存的SetHash类型相比,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}\)个元素的基数,和元素越多就越耗费内存的SetHash类型相比,HyperLogLog就非常节省空间。

所以,非常适合统计百万级以上的网页UV的场景。

九、Geo类型

GEORedis 3.2版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based ServiceLBS)的应用。LBS应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO就非常适合应用在LBS服务的场景中。

9.1 底层实现

GEO本身并没有设计新的底层数据结构,而是直接使用了zset集合类型。

GEO类型使用GeoHash编码方法实现了经纬度到zset中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为zset元素的权重分数。

这样一来,我们就可以把经纬度保存到zset中,利用zset提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。

9.2 应用场景

这里以滴滴叫车的场景为例,介绍下具体如何使用GEO命令:GEOADDGEORADIUS这两个命令。

假设车辆ID33,经纬度位置是(116.03457939.030452),我们可以用一个GEO集合保存所有车辆的经纬度,集合keycars:locations

当用户想要寻找自己附近的网约车时,LBS应用就可以使用GEORADIUS命令。

例如,LBS应用执行下面的命令时,Redis会根据输入的用户的经纬度信息(116.05457939.030452),查找以这个经纬度为中心的5公里内的车辆信息,并返回给LBS应用。

可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用Redis来实现附近的人?或者计算最优地图路径?这三个其实也可以算作一种数据结构,不知道还有多少朋友记得,我在梦开始的地方,Redis基础中提到过,你如果只知道五种基础类型那只能拿60分,如果你能讲出高级用法,那就觉得你有点东西

十、Stream类型

StreamRedis 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,支持以消费组形式消费数据。

参考文章

posted @ 2022-07-10 21:50  夏尔_717  阅读(482)  评论(2编辑  收藏  举报