Redis(三):数据类型

前言

上一篇介绍了 Redis 定义的八大数据结构:SDS、双向链表、压缩列表、哈希表、整数集合、跳表、quicklist、listpack。这节将开始介绍在这些数据结构的基础上实现的九大数据类型:String、List、Hash、Set、Sorted Set、BitMap、HyperLogLog、GEO、Stream。

String

String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value 不仅可以是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M。

实现

String 类型的底层的数据结构实现主要是 int 和 SDS(Simple Dynamic String,简单动态字符串)。

字符串对象的内部编码(encoding)有 3 种 :int、raw、embstr

如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long),并将字符串对象的编码设置为 int

在这里插入图片描述

如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 32 字节,那么字符串对象将使用一个 SDS 来保存这个字符串,并将对象的编码设置为 embstrembstr 编码是专门用于保存短字符串的一种优化编码方式。

在这里插入图片描述

如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节,那么字符串对象将使用一个 SDS 来保存这个字符串,并将对象的编码设置为 raw。此时会给 SDS 分配一个独立的空间,而不是和 redisObject 布局在一起(像 embstr)。

在这里插入图片描述

embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的:redis 2.+ 是 32 字节;redis 3.0-4.0 是 39 字节;redis 5.0 是 44 字节。

可以看到 embstr 和 raw 编码都会使用 SDS 来保存值,但不同之处在于 embstr 会通过一次内存分配函数来分配一块连续的内存空间来保存 redisObject 和 SDS,而 raw 编码会通过调用两次内存分配函数来分别分配两块空间来保存 redisObject 和 SDS。这样做的好处:

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

embstr 的缺点:

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

另外,Redis 中对于浮点数类型是作为字符串保存的,在需要的时候再将其转换成浮点数类型。

问题

String 类型在记录小数据时,元数据的内存开销比较大,不太适合保存大量数据。

假设需要存储一个图片对象记录,每个记录的格式为「图片ID:存储对象ID」,如果用两个 8 字节的 long 类型表示这两个 ID,理论上每个记录占 16 个字节,但是实际上却会占 64 字节:每个 ID 使用 int 编码的 String 存储,每个 ID 占 16B(4+4+8),共 32B,dictEntry 结构中有三个 8B 的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24B。总共需要 56B 的空间。

再加上 Redis 使用的内存分配库 jemalloc 在分配内存时,会根据申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。

所以,在刚刚说的场景里,dictEntry 结构就占用了 32B,用 String 类型存储上述例子,总共需要 64B。

List

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。列表的最大长度为 2^32 - 1。

实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:

如果列表的元素个数小于 512 个(默认值,可修改),每个元素的值都小于 64 字节(默认值,可修改),会使用压缩列表作为 List 类型的底层数据结构;否则使用双向链表

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

应用场景
消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性

List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。

另外为了解决消费者不知道新消息的写入问题(轮询请求是否有新消息),Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销。

消费者要实现重复消息的判断,需要 2 个方面的要求:

  • 每个消息都有一个全局的 ID;
  • 消费者要记录已经处理过的消息的 ID;

但是 List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在把消息插入 List 时,需要在消息中包含这个全局唯一 ID。

当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。

为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存

缺陷:List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。

Hash

Hash 是一个键值对集合,其中 value 的形式如: value=[{field1,value1},…{fieldN,valueN}]。Hash 特别适合用于存储对象。

实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

如果哈希类型元素个数小于 512 个(默认值,可修改),所有值小于 64 字节(默认值,可修改)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;否则使用哈希表

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

Hash 和 String 类型都能够用于存储对象数据,一般来说,如果对象数据比较复杂,且需要进行频繁或复杂的查询和操作,使用 Hash 类型可能更加合适。

Set

Set 类型是一个无序且唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。

一个集合最多可以存储 2^32-1 个元素。Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。

Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,可能会导致 Redis 实例阻塞。

Set 类型和 List 类型的区别如下:

  • List 可以存储重复元素,Set 只能存储非重复元素;
  • List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。
实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

如果集合中的元素都是整数且元素个数小于 512 (默认值)个,会使用整数集合作为 Set 类型的底层数据结构;否则使用哈希表

应用场景

集合的主要几个特性,无序、不可重复、支持并交差等操作。

因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。

如点赞、共同关注、抽奖活动等场景。适合做聚合统计,如统计每天的新增用户数、第二天的留存用户数等。

Sorted Set

Sorted Set,有序集合。Sorted Set 类型相比于 Set 类型多了一个排序属性 score,对于有序集合来说,每个存储元素相当于有两个值组成的,一个是有序集合的元素值,一个是排序值。

相比于 Set 类型,Sorted Set 类型不支持差集运算。

实现

Sorted Set 类型的底层数据结构是由压缩列表或跳表实现的:

如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Sorted Set 类型的底层数据结构;否则使用跳表

Sorted Set 在使用跳表作为数据结构的时候,是使用由哈希表+跳表组成的结构,哈希表用于以常数复杂度获取元素权重。这样既能进行高效的范围查询,也能进行高效的单点查询。

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

应用场景

Sorted Set 类型可以根据元素的权重来排序,可以自己来决定每个元素的权重值。应用场景有展示最新列表、排行榜等。

BitMap

Bitmap,即位图,是一串连续的二进制数组,可以通过偏移量(offset)定位元素。BitMap 通过最小的单位 bit 来进行 0|1 的设置,表示某个元素的值或者状态,时间复杂度为 O(1)。

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

实现

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

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

应用场景

Bitmap 类型非常适合二值状态统计的场景。如签到打卡、判断用户登录态等。

HyperLogLog

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

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

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

应用场景

HyperLogLog 提供的是不精确的去重计数。可以应用于对精度要求不是特别严格的统计场景,比如百万级网页 UV(Unique visitor)计数。

GEO

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

实现

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

GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。

应用场景

位置信息服务(Location-Based Service,LBS)的应用,查询相邻的经纬度范围。

Stream

Stream 是 Redis 5.0 版本新增加的数据类型,是专门为消息队列设计的数据类型。

消息队列需要满足三个需求:消息保序、处理重复消息、保证消息可靠性。

  • 消息保序:消费者需要按照生产者发送消息的顺序来处理信息;
  • 处理重复消息:如果因为网络堵塞而出现消息重传,对于重复的消息只能处理一次;
  • 保证消息可靠性:如果因为故障导致消息没有处理完成,需要保证消息可靠性执行;

原先消息队列的实现方式都有着各自的缺陷:

  • 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
  • List 实现消息队列的方式不能重复消费(对于多个消费者而言),一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID;不支持消费组;如果生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给内存带来很大压力。

Stream 支持消息的持久化、自动生成全局唯一 ID、ack 确认消息的模式、消费组模式等,让消息队列更加的稳定和可靠。

应用场景
消息队列

生产者通过 XADD 命令插入一条消息:

# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
# 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"

插入成功后会返回全局唯一的 ID:“1654254953808-0”。消息的全局唯一 ID 由两部分组成:

  • 第一部分“1654254953808”是数据插入时,以毫秒为单位计算的当前服务器时间,int64;
  • 第二部分表示插入消息在当前毫秒内的消息序号,int64,从 0 开始编号。例如,“1654254953808-0”就表示在“1654254953808”毫秒内的第 1 条消息。

时间回拨问题:

为了避免服务器时间错误而带来的问题(服务器时间延后等),每个 Stream 类型数据都维护一个 latest_generated_id 属性,用于记录最后一个消息的 ID。若发现当前时间戳退后(小于 latest_generated_id),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用 int64 的原因,保证有足够多的的序号),从而保证 ID 的单调递增性质。

消费者通过 XREAD 命令从消息队列中读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取。

# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。
> XREAD STREAMS mymq 1654254953807-0
1) 1) "mymq"
   2) 1) 1) "1654254953808-0"
         2) 1) "name"
            2) "xiaolin"

如果想要实现阻塞读(当没有数据时,阻塞住),可以调用 XRAED 时设定 BLOCK 配置项

比如,下面这命令,设置了 BLOCK 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。

# 命令最后的“$”符号表示读取最新的消息
> XREAD BLOCK 10000 STREAMS mymq $
(nil)
(10.00s)

Stream 可以以使用 XGROUP 创建消费组,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。

创建两个消费组,这两个消费组消费的消息队列是 mymq,都指定从第一条消息开始读取:

# 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。
> XGROUP CREATE mymq group1 0-0
OK
# 创建一个名为 group2 的消费组,0-0 表示从第一条消息开始读取。
> XGROUP CREATE mymq group2 0-0
OK

消费组 group1 内的消费者 consumer1 从 mymq 消息队列中读取所有消息的命令如下:

# 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
> XREADGROUP GROUP group1 consumer1 STREAMS mymq >
1) 1) "mymq"
   2) 1) 1) "1654254953808-0"
         2) 1) "name"
            2) "xiaolin"

消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息

但是,不同消费组的消费者可以消费同一条消息(但是有前提条件,创建消息组的时候,不同消费组指定了相同位置开始读取消息)

使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。

Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams “消息已经处理完成”。再次重启后仍然可以读取未处理完的消息。

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 XACK 命令确认消息已经被消费完成,如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息

例如,我们来查看一下 group2 中各个消费者已读取、但尚未确认的消息个数,命令如下:

127.0.0.1:6379> XPENDING mymq group2
1) (integer) 3
2) "1654254953808-0"  # 表示 group2 中所有消费者读取的消息最小 ID
3) "1654256271337-0"  # 表示 group2 中所有消费者读取的消息最大 ID
4) 1) 1) "consumer1"
      2) "1"
   2) 1) "consumer2"
      2) "1"
   3) 1) "consumer3"
      2) "1"

如果想查看某个消费者具体读取了哪些数据,可以执行下面的命令:

# 查看 group2 里 consumer2 已从 mymq 消息队列中读取了哪些消息
> XPENDING mymq group2 - + 10 consumer2
1) 1) "1654256265584-0"
   2) "consumer2"
   3) (integer) 410700
   4) (integer) 1

可以看到,consumer2 已读取的消息的 ID 是 1654256265584-0。

一旦消息 1654256265584-0 被 consumer2 处理了,consumer2 就可以使用 XACK 命令通知 Streams,然后这条消息就会被删除

> XACK mymq group2 1654256265584-0
(integer) 1

当我们再使用 XPENDING 命令查看时,就可以看到,consumer2 已经没有已读取、但尚未确认处理的消息了。

> XPENDING mymq group2 - + 10 consumer2
(empty array)

若某个消费者宕机之后,没有办法再上线了,那么就需要将该消费者 Pending 的消息,转移给其他的消费者处理。消息转移的操作时将某个消息转移到自己的 Pending 列表中。使用语法 XCLAIM 来实现,需要设置组、转移的目标消费者和消息 ID,同时需要提供 IDLE(已被读取时长),只有超过这个时长,才能被转移。

# 当前属于消费者A的消息1553585533795-1,已经15907,787ms未处理了
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1553585533795-1"
   2) "consumerA"
   3) (integer) 15907787
   4) (integer) 4

# 转移超过3600s的消息1553585533795-1到消费者B的Pending列表
127.0.0.1:6379> XCLAIM mq mqGroup consumerB 3600000 1553585533795-1
1) 1) "1553585533795-1"
   2) 1) "msg"
      2) "2"

# 消息1553585533795-1已经转移到消费者B的Pending中。
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1553585533795-1"
   2) "consumerB"
   3) (integer) 84404 # 注意IDLE,被重置了
   4) (integer) 5 # 注意,读取次数也累加了1次

如果某个消息,不能被消费者处理,也就是不能被 XACK,这是要长时间处于 Pending 列表中,即使被反复的转移给各个消费者也是如此。此时该消息的 delivery counter 就会累加,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用 XDEL 语法:

# 删除队列中的消息
127.0.0.1:6379> XDEL mq 1553585533795-1
(integer) 1
# 查看队列中再无此消息
127.0.0.1:6379> XRANGE mq - +
1) 1) "1553585533795-0"
   2) 1) "msg"
      2) "1"
2) 1) "1553585533795-2"
   2) 1) "msg"
      2) "3"

Redis 基于 Stream 的消息队列与专业的消息队列有哪些差距?

一个专业的消息队列,必须要做到两大块:

  • 消息不丢。
  • 消息可堆积。

1、Redis Stream 消息会丢失吗?

使用一个消息队列,其实就分为三大块:生产者、队列中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。

Redis Stream 消息队列能不能保证三个环节都不丢失数据?

  • 生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。 从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 ( MQ 中间件) 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。

  • Redis 消费者不会丢消息,因为 Stream ( MQ 中间件)会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,但是未被确认的消息。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。等到消费者执行完业务逻辑后,再发送消费确认 XACK 命令,也能保证消息的不丢失。

  • Redis 消息中间件会丢消息,Redis 在以下 2 个场景下,都会导致数据丢失:

    • AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能
  • 主从复制也是异步的,主从切换时,也存在丢失数据的可能 (opens new window)。

可以看到,Redis 在队列中间件环节无法保证消息不丢。像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。

2、Redis Stream 消息可堆积吗?

Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。

所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。

当指定队列最大长度时,队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。

但 Kafka、RabbitMQ 专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。

因此,把 Redis 当作队列来使用时,会面临的 2 个问题:

  • Redis 本身可能会丢数据;
  • 面对消息挤压,内存资源会紧张;

所以,能不能将 Redis 作为消息队列来使用,关键看你的业务场景:

  • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
  • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。

最后

本文介绍了 Redis 的九大数据类型,至此,Redis 的数据结构方面就差不多介绍完了。下一节将介绍 Redis 的线程模型。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值