1 简介

MQ要能支持组件通信消息的快速读写,而Redis本身支持数据的高速访问,正好可以满足MQ读写性能需求。
但除了性能,MQ还有其他要求,Redis真的适合做消息队列吗?
问题的背后,其实包含如下核心问题:

  • MQ消息存取需求是什么
  • Redis如何实现消息队列的需求

MQ的特征和Redis提供的MQ方案。只有把这两方面的知识和实践经验串连起来,才能彻底理解基于Redis实现消息队列的技术实践。MQ选型时,就可以根据组件通信量和消息通信速度要求,选择出适合的Redis MQ方案。

2 MQ的消息存取需求

假设:

  • P要对采集到的数据求和,并写入DB。但消息到达的速度很快,P无法及时既采集,又计算,并写入DB。所以,可利用MQ通信,让P把数据x、y保存为JSON消息,再发到MQ,即可继续接收新数据。
  • C则异步读MQ,在C求积后,落库
    #yyds干货盘点# Redis真的可以做MQ消息队列吗?
    通用MQ架构模型:
    #yyds干货盘点# Redis真的可以做MQ消息队列吗?
    在使用MQ时,C可异步读取P的消息,然后再处理。
    这样即使P发送消息速度>>C处理消息速度,P已发送的消息也可缓存在MQ,避免阻塞P,因此MQ常用于分布式系统通信。

但MQ存取消息时,必须满足如下需求

2.1 消息保序

虽然C异步处理消息,但C仍需按P发送消息的顺序来处理消息,避免后发送的消息被先处理。
对消息保序场景,出现这种消息乱序处理,可能导致业务逻辑被错误执行,造成业务损失。

更新商品库存

假设P负责接收库存更新请求,C负责实际更新库存,现有库存=10。
P先后发送了消息1、2:

  • 1,把商品X的库存记录更新为5
  • 2,把商品X库存更新为3

若消息1、2在MQ中无法保序,出现消息2早于消息1被处理,则库存更新就错了。

你会怎么解决这个问题呢?

你可能想:不要把更新后的库存量作为P发送的消息,而是把库存扣除值作为消息的内容。这样消息1是扣减库存量5,消息2是扣减库存量2。若消息1、2间没有库存查询请求,即使消费者先处理消息2,再处理消息1,也能够保证库存最终是正确的3。

但别忘了:若C收到这样三条消息:

  • 消息1,扣减库存量5
  • 消息2,读取库存量
  • 消息3,扣减库存量2

若C先处理了消息3(-2),则库存量就变成8。然后,处理消息2,读到当前库存8,库存量查询错误!
业务应用层面看,消息1、2、3应顺序执行,所以,消息2查询到的应该是-5后的库存,而非-2后的库存。
所以,用库存扣除值作为消息,在消息中同时包含读写操作时,有数据读取错误问题。
而且,该方案还面临重复消息处理问题:

2.2 重复消息处理

C从MQ读消息时,有时因为网络堵塞而出现消息重传。此时,C可能收到多条重复消息。对于重复消息,C若多次处理,就可能造成同一业务逻辑被多次执行,若恰好都是修改数据的,则数据会被多次修改!

假设C收到一次消息1,要扣减库存量5,然后又收到一次消息1,则若C无法识别这两条消息其实是相同消息,则执行两次库存-5,那库存肯定就错了。这是绝对无法接受的。

2.3 消息可靠性保证

C在处理消息时,还可能出现因故障或宕机,导致消息没处理完成。
此时,MQ需能提供消息可靠性保证:当C重启后,可重新读取消息再次处理,否则,就会出现消息漏处理问题

Redis的List和Streams两种数据类型,即可满足MQ的这三需求

3 基于List的MQ实现

List本就是FIFO存取数据,所以,若使用List作为MQ保存消息,先天消息有序。

P可使用LPUSH把待发送消息依次写入List,C则可使用RPOP从List的另一端按照消息的写入顺序,依次读取消息并进行处理。

如下,P先用LPUSH写入两条库存消息:把库存更新为5和3
C则用RPOP依次读取两条消息并处理。
#yyds干货盘点# Redis真的可以做MQ消息队列吗?

性能分析

P往List中写数据时,List并不会通知C有新消息,若C想及时处理消息,就得不停调用RPOP命令(如用个while(true)循环)。若有新消息写入,RPOP就会返回结果,否则,RPOP返回空值,继续循环。

所以,即使无新消息写入List,C也得不停调用RPOP,导致C程序的CPU一直消耗在执行RPOP,带来不必要性能消耗。

怎么解决呢?

Redis提供了BRPOP,阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新数据,再开始读新数据。和C自己不停地调用RPOP比,节省了CPU。

继续解决重复消息处理,这其实有个隐含要求:C自己能判断重复消息

  • MQ要能给每个消息提供全局唯一ID
  • C要记录已处理消息ID

收到一条消息后,C即可对比收到的消息ID和记录的已处理消息ID,判断当前所收到消息是否经过处理:

  • 若已经处理,则C不再处理。

这种处理也叫幂等性:对于同一条消息,C收到一次的处理结果和收到多次的处理结果一致。

不过,List本身是不会为每个消息生成ID号的,所以,消息的全局唯一ID号就需要生产者程序在发送消息前自行生成。生成之后,我们在用LPUSH命令把消息插入List时,需要在消息中包含这个全局唯一ID。

如执行以下命令,把一条全局ID=101030001、库存量为5的消息插入MQ:

LPUSH mq "101030001:stock:5"
(integer) 1

消息可靠性

当C从List中读取一条消息后,List就不会再留存该消息。
所以,若C处理消息过程出现故障或宕机,就会导致消息没处理完成,那C程序重启后,就没法再从List读取消息了。

为留存消息,List提供了BRPOPLPUSH:让C从List读取消息,同时,Redis会把该消息再插入到另一个备份List留存。
这么一来,若C读了消息但未正常处理,等它重启后,即可从备份List中重新读取消息。

图解

使用BRPOPLPUSH留存消息,C再读取消息:
#yyds干货盘点# Redis真的可以做MQ消息队列吗?

  • P先LPUSH,把消息“5”、“3”插入MQ。C使用BRPOPLPUSH读消息“5”,同时,消息“5”还会被Redis插入mqback队列
  • 若C处理消息“5”时宕机,重启后,可从mqback再读取消息“5”并处理

所以基于List,即可满足分布式系统对MQ三大需求。
但用List做MQ时,还可能:P消息发送很快,但C处理消息速度较慢,导致List中消息积压太多,给Redis内存带来压力。

这时,期望启动多个C组成一个消费组,分担处理List中消息。可惜List不支持消费组。

还能有啥办法吗?

于是Redis 5.0提供Streams数据类型。
比起List,Streams同样满足MQ三大需求,还支持消费组形式的消息读取。

基于Streams的MQ

Redis专为MQ设计的数据类型

存取消息

使用XADD命令可往MQ插入新消息,消息格式是KV对。对每条消息,Streams会自动为其生成全局唯一ID。

如执行以下命令,即可往名称为mqstream的MQ插入一条消息:

  • K=repo
  • V=5

MQ名称后面的*,表示让Redis为插入的数据自动生成全局唯一ID,如“1599203861727-0”。
也可以不用*,直接在MQ名称后自行设定一个ID号,只要保证该ID号全局唯一即可。相比自行设定ID号,使用*更方便高效。
#yyds干货盘点# Redis真的可以做MQ消息队列吗?

消息全局唯一ID由两部分组成:

  • 第一部分“1635682096857”,数据插入时,ms单位计算的当前服务器时间
  • 第二部分,插入消息在当前ms内的消息序号,从0开始编号

当C需读取消息时,可直接使用XREAD从MQ读取,XREAD读取消息时,可指定消息ID,并从该消息ID的下一条消息开始进行读取。
如执行下面命令,从ID=1635682096857-0 消息开始,读取后续所有消息:
#yyds干货盘点# Redis真的可以做MQ消息队列吗?
C也可在调用XRAED时设定block配置项,实现类似BRPOP的阻塞读取。
当MQ没消息时,设置block配置项,XREAD就会阻塞,阻塞时长可以在block配置项中设置:

  • $,读取最新消息
  • block 5000,单位ms,XREAD在读取最新消息时,若没有消息到来,XREAD将阻塞5000ms(5s),然后再返回

XREAD执行后,MQ mqstream中一直没有消息,所以,XREAD在5s后返回空:
#yyds干货盘点# Redis真的可以做MQ消息队列吗?

Streams特色功能

可使用XGROUP创建消费组
#yyds干货盘点# Redis真的可以做MQ消息队列吗?
创建消费组之后,Streams可使用XREADGROUP让消费组内的消费者读取消息。

如执行下面命令,创建名为group1的消费组,该消费组消费的MQ是mqstream:
#yyds干货盘点# Redis真的可以做MQ消息队列吗?
然后,执行一段命令,让group1消费组里的消费者consumer1从mqstream读取所有消息:

  • >,表示从第一条尚未被消费的消息开始读取
    因为在consumer1读取消息前,group1中没有其他消费者读取过消息。所以,consumer1就得到mqstream中的所有消息
    #yyds干货盘点# Redis真的可以做MQ消息队列吗?

MQ中的消息一旦被消费组里的一个消费者读取,就不能再被该消费组内其他消费者读取了。使用Stream一个消费组内,一条消息只能有一个消费者读取,有多个消费组,是可以重复读取消费这条消息的。

建议也看下 kafka rocketmq 等如何处理不同消费组消费消息的。一般分为客户端记录offset跟 服务器端记录。

比如执行完刚才的XREADGROUP,再执行下面命令,让group1内的consumer2读取消息时,consumer2读到的就是空值,因为消息已经被consumer1读完:
#yyds干货盘点# Redis真的可以做MQ消息队列吗?

使用消费组的目的

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

如执行下列命令,让group2中的consumer1、2、3各读取一条消息:

XREADGROUP group group2 consumer1 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599203861727-0"
         2) 1) "repo"
            2) "5"

XREADGROUP group group2 consumer2 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"

XREADGROUP group group2 consumer3 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"

为保证消费者在故障或宕机重启后,仍可读取未处理完的消息,Streams会自动使用内部队列(PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用XACK命令通知Streams“消息已处理完成”。
若消费者没有成功处理消息,它就不会给Streams发送XACK命令,消息仍会留存。此时,消费者可在重启后,用XPENDING查看已读取、但尚未确认处理完成的消息。

若C从stream中读一条消息后,但未回复ack,然后C挂了,则当C重启后,C是从已读取未确认队列中取消息。
若该消费者一直没起来,没消费者消费,消息会一直保留,若设置了TTL,则过期自动删除。

如查看group2中各个消费者已读取、但尚未确认的消息个数。其中,XPENDING返回结果的第二、三行分别表示group2中所有消费者读取的消息最小ID和最大ID。

XPENDING mqstream group2
1) (integer) 3
2) "1599203861727-0"
3) "1599274925823-0"
4) 1) 1) "consumer1"
      2) "1"
   2) 1) "consumer2"
      2) "1"
   3) 1) "consumer3"
      2) "1"

如还需查看某个消费者具体读取了哪些数据,执行:

XPENDING mqstream group2 - + 10 consumer2
1) 1) "1599274912765-0"
   2) "consumer2"
   3) (integer) 513336
   4) (integer) 1

可见,consumer2已读取的消息的ID是1599274912765-0。

一旦消息1599274912765-0被consumer2处理了,consumer2就可以使用XACK命令通知Streams,然后这条消息就会被删除。当我们再使用XPENDING命令查看时,就可以看到,consumer2已经没有已读取、但尚未确认处理的消息了。

 XACK mqstream group2 1599274912765-0
(integer) 1
XPENDING mqstream group2 - + 10 consumer2
(empty list or set)
  • 若是Stream数据类型来消费数据,在只有一个消费组的情况下,多个消费者之间在业务层面是如果保证消息是被顺序消费的呢?
    无法保证,业务使用MQ不能强依赖顺序性。所以建议使用专业的mq,针对顺序消费 你只需要保证客户端实现分区即可。

    总结

    分布式系统MQ的三大需求:消息保序、重复消息处理和消息可靠性保证

可转换为MQ的三大要求:

  • 消息数据有序存取
  • 消息数据具有全局唯一编号
  • 消息数据在消费完成后被删除

List V.S Streams MQ
#yyds干货盘点# Redis真的可以做MQ消息队列吗?
关于Redis是否适合做MQ,业界一直有争论。很多人认为,要使用MQ,就应该用Kafka、RabbitMQ这些专门面向MQ场景的软件,Redis更该做缓存。

Redis是非常轻量级的KV DB,部署一个Redis实例就是启动一个进程,部署Redis集群,也就是部署多个Redis实例。
而Kafka、RabbitMQ部署时,涉及额外组件,如Kafka需再部署ZooKeeper。相比Redis,Kafka和RabbitMQ一般被认为是重量级MQ。

所以,关于是否用Redis做MQ,不能一概而论,需考虑业务层面数据体量及对性能、可靠性、可扩展性需求。
若分布式系统中组件消息通信量不大,则Redis只需使用有限内存空间就能满足消息存储的需求,而且,Redis的高性能特性能支持快速的消息读写,不失为MQ的一个好的解决方案。

若P发送给MQ的消息,需被多个消费者读取处理,你会使用Redis的什么数据类型?

这时只能使用Streams。使用Streams数据类型,创建多个消费者组,就可以实现同时消费生产者的数据。每个消费者组内可以再挂多个消费者分担读取消息进行消费,消费完成后,各自向Redis发送XACK,标记自己的消费组已经消费到了哪个位置,而且消费组之间互不影响。

使用List用作队列时,为了保证消息可靠性,使用BRPOPLPUSH命令把消息取出的同时,还把消息插入到备份队列中,从而防止消费者故障导致消息丢失。
还需维护这个备份队列:每次执行BRPOPLPUSH命令后,因为都会把消息插入备份Q,所以当消费者成功消费取出消息后,最好把备份Q中的消息删除,防止备份Q存储过多无用数据。

在使用MQ时,重点需关注如何保证不丢消息?

生产者在发布消息时异常:

a) 网络故障或其他问题导致发布失败(直接返回错误,消息根本没发出去)
b) 网络抖动导致发布超时(可能发送数据包成功,但读取响应结果超时了,不知道结果如何)

情况a还好,消息根本没发出去,那么重新发一次就好了。但是情况b没办法知道到底有没有发布成功,所以也只能再发一次。所以这两种情况,生产者都需要重新发布消息,直到成功为止(一般设定一个最大重试次数,超过最大次数依旧失败的需要报警处理)。这就会导致消费者可能会收到重复消息的问题,所以消费者需要保证在收到重复消息时,依旧能保证业务的正确性(设计幂等逻辑),一般需要根据具体业务来做,例如使用消息的唯一ID,或者版本号配合业务逻辑来处理。

对于“网络抖动导致发布超时(可能发送数据包成功,但读取响应结果超时了,不知道结果如何)”。如果数据包的发送使用的是tcp协议,那么tcp的重传机制不是会保证数据包一定会到达redis吗?
这个超时是怎么定义的?指的是不是应用程序层面上认为的超时?如果是的话,那么导致这个超时的可能是不是如下两种:1.网络拥塞,tcp还在尝试重传。2.tcp连接断开。

TCP重传只是尽可能地让数据传输成功,如果网络本身出现问题,还是会失败的。
超时是应用层设置的,超时发生时是不知道结果如何的,可能数据已经发过去了,只是没读到对方返回的结果,也可能根本没发出去,这两个阶段都有可能存在网络问题。

可是只要tcp连接还没断开,应用交给tcp传输的数据就一定会到达吧?
那在tcp没有断开的情况下,tcp尝试重传的时间超过了用户层定义的超时时间(tcp层还可继续尝试重传,可能下一次就发送成功),那么生产者用户层在这种情况下再向tcp交付一次数据,那这不就是应用层自己导致的数据多次发送了吗?
是的,会出现这种情况的。应用层超时了,无法知道结果,可能成功也可能失败。
应用层此时有2个选择,一是应用层主动向目标查询校验,二是重新发送一次消息。在消息队列的场景中,一般选择后者,并让消费者在消费时实现幂等逻辑,保证重复消费业务依旧正常。

“可是只要tcp连接还没断开,应用交给tcp传输的数据就一定会到达吧?”不一定能到达。看你这里的到达是指到达目的主机还是目的应用程序,如果是前者,包可能会被中间路由器丢弃;如果是后者也有可能出现这种场景:数据到达目的主机,但是在将包从socket的缓冲区传递给应用程序之前 应用程序崩溃 ,应用层也没法得到数据。

消费者在处理消息时异常:

也就是消费者把消息拿出来了,但是还没处理完,消费者就挂了。这种情况,需要消费者恢复时,依旧能处理之前没有消费成功的消息。使用List当作队列时,也就是利用老师文章所讲的备份队列来保证,代价是增加了维护这个备份队列的成本。而Streams则是采用ack的方式,消费成功后告知中间件,这种方式处理起来更优雅,成熟的队列中间件例如RabbitMQ、Kafka都是采用这种方式来保证消费者不丢消息的。

消息队列中间件丢失消息

上面2个层面都比较好处理,只要客户端和服务端配合好,就能保证生产者和消费者都不丢消息。但是,如果消息队列中间件本身就不可靠,也有可能会丢失消息,毕竟生产者和消费这都依赖它,如果它不可靠,那么生产者和消费者无论怎么做,都无法保证数据不丢失。

a) 在用Redis当作队列或存储数据时,是有可能丢失数据的:一个场景是,如果打开AOF并且是每秒写盘,因为这个写盘过程是异步的,Redis宕机时会丢失1秒的数据。而如果AOF改为同步写盘,那么写入性能会下降。另一个场景是,如果采用主从集群,如果写入量比较大,从库同步存在延迟,此时进行主从切换,也存在丢失数据的可能(从库还未同步完成主库发来的数据就被提成主库)。总的来说,Redis不保证严格的数据完整性和主从切换时的一致性。我们在使用Redis时需要注意。

b) 而采用RabbitMQ和Kafka这些专业的队列中间件时,就没有这个问题了。这些组件一般是部署一个集群,生产者在发布消息时,队列中间件一般会采用写多个节点+预写磁盘的方式保证消息的完整性,即便其中一个节点挂了,也能保证集群的数据不丢失。当然,为了做到这些,方案肯定比Redis设计的要复杂。

一般对于消息中间件来说 都有很复杂的策略来保证生产消息时的可靠性,以kafka为例,生产者生产消息时 可以指定acks参数 ,acks的值有0 1 all三种。 只有设置为all(并且ISR>1)的时候才能保证不丢消息,但是这个性能就会有一些影响,还是靠业务根据数据重要程度抉择平衡。

综上,Redis可以用作队列,而且性能很高,部署维护也很轻量,但缺点是无法严格保数据的完整性(个人认为这就是业界有争议要不要使用Redis当作队列的地方)。而使用专业的队列中间件,可以严格保证数据的完整性,但缺点是,部署维护成本高,用起来比较重。

所以我们需要根据具体情况进行选择,如果对于丢数据不敏感的业务,例如发短信、发通知的场景,可以采用Redis作队列。如果是金融相关的业务场景,例如交易、支付这类,建议还是使用专业的队列中间件。