redis-消息队列

回顾消息队列

消息队列 是指利用 高效可靠消息传递机制 进行与平台无关的 数据交流,并基于数据通信来进行分布式系统的集成。通过提供 消息传递消息排队 模型,它可以在 分布式环境 下提供 应用解耦、弹性伸缩、冗余存储、流量削峰、异步通信、数据同步 等等功能。
消息队列常见的使用场景:

  • 比如电商里的下单后与会员积分、物流订单等异步解耦处理
  • 电商促销短信下发,使用MQ来削峰填谷

redis-消息队列_第1张图片
三个角色:生产者、消费者、消息处理中心
异步处理模式:生产者 将消息发送到一条 虚拟的通道(消息队列)上,而无须等待响应。消费者则订阅或是监听该通道,取出消息。两者互不干扰,甚至都不需要同时在线,也就是我们说的松耦合。
一般设计消息队列需要考虑三个需求,分别是

  • 消息保序:对应消息需要有序消费的场景;
  • 处理重复消息:如网络抖动引起的同一条消息多次被投递到队列的场景;
  • 保证消息可靠性:消息从队列取出,此时客户端宕机,消息未正常消费的场景;

市面上已经存在专业的 MQRocketMQKafka等,为什么还需要Redis来自定义实现消息队列?

  • 重!需要额外的成本负担,包括运维成本、学习成本等等;所以如果你的场景足够简单,redis 完全能满足需求,可以考虑使用 redis 做消息队列
  • redis 是一款轻量级内存组件,相信你一定也经常使用,使用成本低。

Redis 实现消息队列

当我们在使用一个消息队列时,希望它的功能如下:

  • 支持阻塞等待拉取消息;
  • 支持发布 / 订阅模式;
  • 消费失败,可重新消费,消息不丢失;
  • 实例宕机,消息不丢失,数据可持久化;
  • 消息可堆积;

List 实现消息队列

如果你的业务需求足够简单,想把Redis当作队列来使用,肯定最先想到的就是使用 List 这个数据类型。因为 List 底层的实现就是一个链表,在头部和尾部操作元素,时间复杂度都是 O(1),这意味着它非常符合消息队列的模型。

生产者 发布消息

127.0.0.1:6379> LPUSH queue msg1
(integer) 1
127.0.0.1:6379> LPUSH queue msg2
(integer) 2

消费者 拉取消息

127.0.0.1:6379> RPOP queue
"msg1"
127.0.0.1:6379> RPOP queue
"msg2"

redis-消息队列_第2张图片

list常用命令

指令 用法 描述
LPUSH LPUSH KEY VALUE … 将一个或者多个value插入表头
RPUSH RPUSH KEY VALUE 将一个或者多个value插入表尾
LPOP LPOP KEY 移除并返回表头元素
RPOP RPOP KEY 移除并返回表尾元素
BLPOP BLPOP KEY TIMEOUT 移除并返回表头元素,没有元素则阻塞列表直到超时或者发现列表可弹元素
BRPOP BRPOP KEY TIMEOUT 移除并返回表尾元素,没有元素则阻塞列表直到超时或者发现列表可弹元素
LLEN LLEN KEY 返回列表长度,列表不存在,则返回0,key不是列表类型,返回错误
LRANGE LRANGE KEY START STOP 返回KEY中指定区间的元素
RPOPLPUSH BRPOPLPUSH S D 在一个原子时间内,将S弹出的元素插入到另一个列表D并返回它,
BRPOPLPUSH BRPOPLPUSH S D TIMEOUT 在一个原子时间内,将S弹出的元素插入到另一个列表D并返回它,如果列表没有元素则阻塞列表直到超时或者发现列表可弹元素

组合
LPUSH、RPOP 左进右出
RPUSH、LPOP 右进左出

如果队列为空,那消费者依旧会频繁拉取消息,这会造成CPU 空转,不仅浪费 CPU 资源,还会对 Redis 造成压力。
解决:当队列为空时,我们可以休眠一会,再去尝试拉取消息。
但又带来另外一个问题:当消费者在休眠等待时,有新消息来了,那消费者处理新消息就会存在延迟
那如何做,既能及时处理新消息,还能避免 CPU 空转呢?
Redis 确实提供了阻塞式拉取消息的命令:BRPOP / BLPOP。客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。这种方式就节省了不必要的 CPU 开销。

  • LPUSH、BRPOP 左进右阻塞出
  • RPUSH、BLPOP 右进左阻塞出

使用 BRPOP 这种阻塞式方式拉取消息时,还支持传入一个超时时间,如果设置为 0,则表示不设置超时,直到有新消息才返回,否则会在指定的超时时间后返回 NULL
这个方案不错,既兼顾了效率,还避免了 CPU 空转问题,一举两得。

注意:如果设置的超时时间太长,这个连接太久没有活跃过,可能会被 Redis Server 判定为无效连接,之后 Redis Server会强制把这个客户端踢下线。所以,采用这种方案,客户端要有重连机制。

解决了消息处理不及时的问题,这种队列模型,有什么缺点?

  • 不支持重复消费:消费者拉取消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费;
  • 消息丢失:消费者拉取到消息后,如果发生异常宕机,那这条消息就丢失了,缺少消息确认机制;
  • 不能满足多组生产者和消费者的业务场景

list是否满足以下功能

功能 是否满足
支持阻塞等待拉取消息
支持发布 / 订阅模式
消费失败,可重新消费,消息不丢失
实例宕机,消息不丢失,数据可持久化
消息可堆积

发布/订阅模型:Pub/Sub

解决重复消费

它正好可以解决前面提到的第一个问题:重复消费
redis-消息队列_第3张图片
"发布/订阅"模式包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或者多个频道(channel),而发布者可以向指定的频道(channel)发送消息,所有订阅此频道的订阅者都会收到此消息。
如上图 3个消费者,使用 SUBSCRIBE 命令,启动 3 个消费者,并订阅同一个队列。
Redis 通过PUBLISH 、 SUBSCRIBE 等命令实现了订阅与发布模式, 这个功能提供两种信息机制, 分别是订阅/发布到频道订阅/发布到模式

模式

订阅/发布到频道

// 3个消费者 都订阅一个队列
127.0.0.1:6379> SUBSCRIBE queue
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "queue"
3) (integer) 1

之后,再启动一个生产者,发布一条消息。

127.0.0.1:6379> PUBLISH queue msg1
(integer) 1

这时,2 个消费者就会解除阻塞,收到生产者发来的新消息。

127.0.0.1:6379> SUBSCRIBE queue
// 收到新消息
1) "message"   //消息的种类
2) "queue"  //始发频道的名称
3) "msg1"  //实际的消息

订阅/发布到模式

Pub/Sub 还提供了匹配订阅模式,允许消费者根据一定规则,订阅多个自己感兴趣的队列。
// 订阅符合规则的队列。
redis-消息队列_第4张图片

127.0.0.1:6379> PSUBSCRIBE queue.*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "queue.*"
3) (integer) 1

这里的消费者,订阅了 queue.* 相关的队列消息。
之后,生产者分别向 queue.p1 queue.p2 发布消息。

127.0.0.1:6379> PUBLISH queue.p1 msg1
(integer) 1
127.0.0.1:6379> PUBLISH queue.p2 msg2
(integer) 1

这时再看消费者,它就可以接收到这 2 个生产者的消息了。

127.0.0.1:6379> PSUBSCRIBE queue.*
Reading messages... (press Ctrl-C to quit)
...
// 来自queue.p1的消息
1) "pmessage"
2) "queue.*"
3) "queue.p1"
4) "msg1"
 
// 来自queue.p2的消息
1) "pmessage"
2) "queue.*"
3) "queue.p2"
4) "msg2"

Pub/Sub 最大的优势就是,支持多组生产者、消费者处理消息
讲完了它的优点,那它有什么缺点呢?

Pub/Sub 最大问题是:丢数据

其实,Pub/Sub 最大问题是:丢数据。
如果发生以下场景,就有可能导致数据丢失:

  • 消费者下线
  • Redis 宕机
  • 消息堆积
Redis 宕机

Pub/Sub 在实现时非常简单,它没有基于任何数据类型,也没有做任何的数据存储,也不具备数据持久化的能力。Pub/Sub 的相关操作,不会写入到 RDBAOF 中,当 Redis 宕机重启,Pub/Sub 的数据也会全部丢失,它只是单纯地为生产者、消费者建立数据转发通道,把符合规则的数据,从一端转发到另一端。整个过程中,没有任何的数据存储,一切都是实时转发的。当你在使用 Pub/Sub 时,一定要注意:消费者必须先订阅队列,生产者才能发布消息,否则消息会丢失。

消息堆积

我们来看 Pub/Sub 在处理消息积压时,为什么也会丢数据?

当消费者的速度,跟不上生产者时,就会导致数据积压的情况发生。
如果采用 List 当作队列,消息积压时,会导致这个链表很长,最直接的影响就是,Redis 内存会持续增长,直到消费者把所有数据都从链表中取出。但 Pub/Sub 的处理方式却不一样,当消息积压时,有可能会导致消费失败消息丢失
每个消费者订阅一个队列时,Redis 都会在 Server 上给这个消费者在分配一个缓冲区,这个缓冲区其实就是一块内存。当生产者发布消息时,Redis 先把消息写到对应消费者的缓冲区中。之后,消费者不断地从缓冲区读取消息,处理消息。因为这个缓冲区其实是有上限的,如果消费者拉取消息很慢,就会造成生产者发布到缓冲区的消息开始积压,缓冲区内存持续增长。如果超过了缓冲区配置的上限,此时,Redis 就会强制把这个消费者踢下线。这时消费者就会消费失败,也会丢失数据
从这里你应该可以看出,List 其实是属于拉模型,而 Pub/Sub 其实属于推模型

总结一下 Pub/Sub 的优缺点:

优点

  1. 支持发布 / 订阅,支持多组生产者、消费者处理消息 ;

缺点

  1. 消费者下线,数据会丢失 ;
  2. 不支持数据持久化,Redis 宕机,数据也会丢失;
  3. 消息堆积,缓冲区溢出,消费者会被强制踢下线,数据也会丢失;
  4. Pub/Sub 从缓冲区取走数据之后,数据就从 Redis 缓冲区删除了,消费者发生异常,自然也无法再次重新消费。
Pub/Sub 是否满足以下功能
功能 是否满足
支持阻塞等待拉取消息
支持发布 / 订阅模式
消费失败,可重新消费,消息不丢失
实例宕机,消息不丢失,数据可持久化
消息可堆积

趋于成熟的队列:Stream

基本指令:

  • xadd:追加消息
  • xdel:删除信息,这里的删除是设置标志位,不影响消息总长度
  • xrange 获取stream的消息列表(会过滤已经删除的信息)
  • xlen:获取信息长度
  • del:删除整个stream消息列表的种的所有信息(不会删除信息,只是给消息做个标记位)
  • xread: 可以将stream当作队列来使用,xread可以从队列中获取消息

生产者发布 2 条消息:

// *表示让Redis自动生成消息ID 这个消息 ID 的格式是「时间戳-自增序号」。
127.0.0.1:6379> XADD queue * name zhangsan
"1618469123380-0"
127.0.0.1:6379> XADD queue * name lisi
"1618469127777-0"

消费者拉取消息:

// 从开头读取5条消息,0-0表示从开头读取
127.0.0.1:6379> XREAD COUNT 5 STREAMS queue 0-0
1) 1) "queue"
   2) 1) 1) "1618469123380-0"
         2) 1) "name"
            2) "zhangsan"
      2) 1) "1618469127777-0"
         2) 1) "name"
            2) "lisi"

如果想继续拉取消息,需要传入上一条消息的 ID:

127.0.0.1:6379> XREAD COUNT 5 STREAMS queue 1618469127777-0
(nil)

Stream 是否支持「阻塞式」拉取消息?

可以的,在读取消息时,只需要增加 BLOCK 参数即可。

// BLOCK 0 表示阻塞等待,不设置超时时间
127.0.0.1:6379> XREAD COUNT 5 BLOCK 0 STREAMS queue 1618469127777-0

Stream 是否支持发布 / 订阅模式?

  • XGROUP:创建消费者组
  • XREADGROUP:在指定消费组下,开启消费者拉取消息

首先,生产者依旧发布 2 条消息:

127.0.0.1:6379> XADD queue * name zhangsan
"1618470740565-0"
127.0.0.1:6379> XADD queue * name lisi
"1618470743793-0"

之后,我们想要开启 2 组消费者处理同一批数据,就需要创建 2 个消费者组:

// 创建消费者组10-0表示从头拉取消息
127.0.0.1:6379> XGROUP CREATE queue group1 0-0
OK
// 创建消费者组20-0表示从头拉取消息
127.0.0.1:6379> XGROUP CREATE queue group2 0-0
OK

消费者组创建好之后,我们可以给每个消费者组下面挂一个消费者,让它们分别处理同一批数据。
第一个消费组开始消费:

// group1的consumer开始消费,`>`表示拉取最新数据
127.0.0.1:6379> XREADGROUP GROUP group1 consumer COUNT 5 STREAMS queue >
1) 1) "queue"
   2) 1) 1) "1618470740565-0"
         2) 1) "name"
            2) "zhangsan"
      2) 1) "1618470743793-0"
         2) 1) "name"
            2) "lisi"

同样地,第二个消费组开始消费:

// group2的consumer开始消费,>表示拉取最新数据
127.0.0.1:6379> XREADGROUP GROUP group2 consumer COUNT 5 STREAMS queue >
1) 1) "queue"
   2) 1) 1) "1618470740565-0"
         2) 1) "name"
            2) "zhangsan"
      2) 1) "1618470743793-0"
         2) 1) "name"
            2) "lisi"

我们可以看到,这 2 组消费者,都可以获取同一批数据进行处理了。
这样一来,就达到了多组消费者订阅消费的目的。

消息处理时异常,Stream 能否保证消息不丢失,重新消费?

除了上面拉取消息时用到了消息 ID,这里为了保证重新消费,也要用到这个消息 ID
当一组消费者处理完消息后,需要执行 XACK 命令告知 Redis,这时 Redis 就会把这条消息标记为处理完成。

// group1下的 1618472043089-0 消息已处理完成
127.0.0.1:6379> XACK queue group1 1618472043089-0

如果消费者异常宕机,肯定不会发送 XACK,那么Redis就会依旧保留这条消息。
待这组消费者重新上线后,Redis 就会把之前没有处理成功的数据,重新发给这个消费者。这样一来,即使消费者异常,也不会丢失数据了。

Stream 数据会写入到 RDB 和 AOF 做持久化吗?

Stream 是新增加的数据类型,它与其它数据类型一样,每个写操作,也都会写入到RDBAOF 中。
我们只需要配置好持久化策略,这样的话,就算 Redis 宕机重启,Stream 中的数据也可以从 RDB AOF 中恢复回来。

消息堆积时,Stream 是怎么处理的?

其实,当消息队列发生消息堆积时,一般只有 2 个解决方案:

  • 生产者限流:避免消费者处理不及时,导致持续积压
  • 丢弃消息:中间件丢弃旧消息,只保留固定长度的新消息
    而 Redis 在实现 Stream 时,采用了第 2 个方案。在发布消息时,你可以指定队列的最大长度,防止队列积压导致内存爆炸。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的

Stream 是否满足以下功能

功能 是否满足
支持阻塞等待拉取消息
支持发布 / 订阅模式
消费失败,可重新消费,消息不丢失
实例宕机,消息不丢失,数据可持久化
消息可堆积

与专业的消息队列对比

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

  • 消息不丢
  • 消息可堆积

消息是否会发生丢失,其重点也就在于以下 3 个环节:

  • 生产者会不会丢消息?
  • 消费者会不会丢消息?
  • 队列中间件会不会丢消息?

生产者会不会丢消息?

  • 消息没发出去:网络故障或其它问题导致发布失败,中间件直接返回失败
  • 不确定是否发布成功:网络问题导致发布超时,可能数据已发送成功,但读取响应结果超时了

如果是情况 1,消息根本没发出去,那么重新发一次就好了。
如果是情况 2,生产者没办法知道消息到底有没有发成功?所以,为了避免消息丢失,它也只能继续重试,直到发布成功为止。

生产者一般会设定一个最大重试次数,超过上限依旧失败,需要记录日志报警处理。

也就是说,生产者为了避免消息丢失,只能采用失败重试的方式来处理。从这点看,生产者不丢消息与整个中间件无关,完全是业务实现的问题,是否考虑了以上的异常情况。

消费者会不会丢消息?

这种情况就是我们前面提到的,消费者拿到消息后,还没处理完成,就异常宕机了,那消费者还能否重新消费失败的消息?
要解决这个问题,消费者在处理完消息后,必须告知队列中间件,队列中间件才会把标记已处理,否则仍旧把这些数据发给消费者。
这种方案需要消费者和中间件互相配合,才能保证消费者这一侧的消息不丢。
无论是 RedisStream,还是专业的队列中间件,例如 RabbitMQ、Kafka,其实都是这么做的。
所以,从这个角度来看,Redis 也是合格的。

中间丢失消息的情况

这其实就是中间件的实现方式了,redis存在两个风险点:

  • aof周期性刷盘,这个过程是异步的有丢失的风险;
  • 主从复制也是异步的,主从切换时,也存在丢失数据的可能。

基于以上原因我们可以看到,Redis 本身的无法保证严格的数据完整性。
kafka、rabbitMQ则是通过一次写入,多个节点同时ack,才认为写入成功,进一步加强了消息的可靠性。

消息积压怎么办?

因为 Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致Redis的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。
所以,RedisStream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。
Kafka、RabbitMQ 这类消息队列就不一样了,它们的数据都会存储在磁盘上,磁盘的成本要比内存小得多,当消息积压时,无非就是多占用一些磁盘空间,相比于内存,在面对积压时也会更加坦然。
综上,我们可以看到,把 Redis 当作队列来使用时,始终面临的 2 个问题:

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

总结

redis-消息队列_第5张图片

你可能感兴趣的:(redis,redis)