什么是消息队列
我们可以把消息队列比作是一个存放消息的容器,当我们需要使用消息的时候可以取出消息供自己使用。消息队列是分布式系统中重要的组件,使用消息队列主要是为了通过异步处理提高系统性能和可以对系统进行解耦,流量削峰。
消息队列考虑什么
- 消息的发送
- 消息暂存
- 消息的异步消费
- 消息的顺序消息
- 投递可靠性保证(ACK)
- 消息持久化
- 支持不同消息模型
- 多实例集群功能
- 分布式环境下的负载均衡
下面就谈谈如何用redis实现一个消息队列
所幸的是,Redis提供的list数据结构非常适合做消息队列。
但是如何实现即时消费?如何实现ack机制?这些是实现的关键所在。
Redis的数据结构 List
list命令
LPUSH:向指定List的左侧(即头部)插入1个或多个元素,返回插入后的List长度。时间复杂度O(N),N为插入元素的数量
RPUSH:同LPUSH,向指定List的右侧(即尾部)插入1或多个元素
LPOP:从指定List的左侧(即头部)移除一个元素并返回,时间复杂度O(1)
RPOP:同LPOP,从指定List的右侧(即尾部)移除1个元素并返回
LPUSHX/RPUSHX:与LPUSH/RPUSH类似,区别在于,LPUSHX/RPUSHX操作的List如果不存在(长度为0),则不会进行任何操作
LLEN:返回指定List的长度,时间复杂度O(1)
LRANGE:返回指定List中指定范围的元素(双端包含,即LRANGE key 0 10会返回11个元素),时间复杂度O(N)。
BLPOP或BRPOP,和LPUSH ,RPUSH 指令相同, 即列表的阻塞式(blocking)弹出
由于Redis的List是链表结构的,上述命令的算法效率较低,需要对List进行遍历,命令的耗时无法预估,在List长度大的情况下耗时会明显增加,应谨慎使用。
换句话说,Redis的List实际是设计来用于实现队列,而不是用于实现类似ArrayList这样的列表的。如果不是想要实现一个双端出入的队列,那么请尽量不要使用Redis的List数据结构。
Redis的数据结构:Sorted Set
Redis Sorted Set是有序的、不可重复的String集合。
Sorted Set中的每个元素都需要指派一个分数(score),Sorted Set会根据score对元素进行升序排序。
如果多个元素拥有相同的score,则以字典序进行升序排序。
Sorted Set非常适合用于记录排名、热点话题等场景。
Sorted Set的主要操作命令
ZADD:向指定Sorted Set中添加1个或多个member,时间复杂度O(Mlog(N)),M为添加的member数量,N为Sorted Set中的member数量
ZREM:从指定Sorted Set中删除1个或多个member,时间复杂度O(Mlog(N)),M为删除的member数量,N为Sorted Set中的member数量
ZCOUNT:返回指定Sorted Set中指定score范围内的member数量,时间复杂度:O(log(N))
ZCARD:返回指定Sorted Set中的member数量,时间复杂度O(1)
ZSCORE:返回指定Sorted Set中指定member的score,时间复杂度O(1)
ZRANK/ZREVRANK:返回指定member在Sorted Set中的排名,ZRANK返回按升序排序的排名,ZREVRANK则返回按降序排序的排名。时间复杂度O(log(N))
ZINCRBY:同INCRBY,对指定Sorted Set中的指定member的score进行自增,时间复杂度O(log(N))
Sorted Set的注意事项
时间复杂度较高的以下命令都应谨慎使用
ZRANGE/ZREVRANGE:返回指定Sorted Set中指定排名范围内的所有member,ZRANGE为按score升序排序,ZREVRANGE为按score降序排序,时间复杂度O(log(N)+M),M为本次返回的member数(下同)
ZRANGEBYSCORE/ZREVRANGEBYSCORE:返回指定Sorted Set中指定score范围内的所有member,返回结果以升序/降序排序,min和max可以指定为-inf和+inf,代表返回所有的member。时间复杂度O(log(N)+M)
ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除Sorted Set中指定排名范围/指定score范围内的所有member。时间复杂度O(log(N)+M)
上述命令中应避免传递[0 -1]或[-inf +inf]这样的参数,来对Sorted Set做一次性的完整遍历,特别是在Sorted Set的尺寸不可预知的情况下。
可以通过ZSCAN命令来进行游标式的遍历
ZSCAN key cursor [MATCH pattern] [COUNT count]
或通过LIMIT参数来限制返回member的数量(适用于ZRANGEBYSCORE和ZREVRANGEBYSCORE命令),以实现游标式的遍历。
下就从生产者消费模式和发布订阅者模式来谈谈
生产者消费模式
生产者消费模式会让一个或者多个客户端监听消息队列,一旦消息到达,消费者马上消费,谁先抢到算谁的,如果队列里没有消息,则消费者继续监听。 其实在生产者消费模式中生产者是一堆线程,消费者是另一堆线程,内存缓冲区可以使用List数组队列,数据类型只需要定义一个简单的类就好。关键是如何处理多线程之间的协作。
基础方法
既然List这种数据结构就是被设计用来实现队列的,那么就可以直接使用List来实现消息队列。
生产者使用RPUSH不停地向队列右端添加信息;消费者使用LPOP不停地从队列左端读取消息,读取不到消息时,使用SLEEP命令定期重读即可。
改进版
不使用SLEEP命令,直接将LPOP命令替换为BLPOP即可,这个B代表Block,是一个阻塞式命令。List为空时,阻塞连接,直到List中有对象可获取时再返回。
这相当于Java中的BlockingQueue数据类型。
另外,BLPOP的命令参数中可以设定超时时间,timeout之后如果队列中仍然没有对象,则直接返回。
网上所流传的方法是使用Redis中list的操作BLPOP或BRPOP,即列表的阻塞式(blocking)弹出。
让我们来看看阻塞式弹出的使用方式:
BRPOP key [key ...] timeout此命令的说明是:
1、当给定列表内没有任何元素可供弹出的时候,连接将被 BRPOP 命令阻塞,直到等待超时或发现可弹出元素为止。
2、当给定多个key参数时,按参数 key 的先后顺序依次检查各个列表,弹出第一个非空列表的尾部元素。
另外,BRPOP 除了弹出元素的位置和 BLPOP 不同之外,其他表现一致。
以此来看,列表的阻塞式弹出有两个特点:
1、如果list中没有任务的时候,该连接将会被阻塞
2、连接的阻塞有一个超时时间,当超时时间设置为0时,即可无限等待,直到弹出消息
由此看来,此方式是可行的,但此为传统的观察者模式,业务简单则可使用,如A的任务只由B去执行。
但如果A和Z的任务,B和C都能执行,那使用这种方式就相形见肘。这个时候就应该使用订阅/发布模式,使业务系统更加清晰。
发布订阅模式
好在Redis也支持Pub/Sub(发布/订阅)。在消息A入队list的同时发布(PUBLISH)消息B到频道channel,此时已经订阅channel的worker就接收到了消息B,知道了list中有消息A进入,即可循环lpop或rpop来消费list中的消息。流程如下:

其中的worker可以是单独的线程,也可以是独立的服务,其充当了Consumer和业务处理者角色。下面做实例说明。
实现概要如下。
【多个消费者订阅channel(即双方约定的List)】
SUBSCRIBE fanoutChannel
【生产者向channel中发布消息】
PUBLISH fanoutChannel "msg1"
PUBLISH fanoutChannel "msg2"
【所有消费者都会收到该消息】
1) "message"
2) "fanoutChannel"
3) "msg1"
1) "message"
2) "fanoutChannel"
3) "msg2"延时队列
利用的是 Redis 的 Sorted Set 这个数据结构的一点点使用技巧。
Redis Sorted Set是有序的、不可重复的String集合。
Sorted Set中的每个元素都需要指派一个分数(score),Sorted Set会根据score对元素进行升序排序。
如果多个元素拥有相同的score,则以字典序进行升序排序。
Sorted Set非常适合用于记录排名、热点话题等场景
思路为使用Sorted Set,拿时间戳作为score。
生产者将消息内容作为member,时间戳作为score调用ZADD来生产消息;
ZADD key score member [[score member] [score member] ...]
消费者用ZRANGEBYSCORE命令获取N秒之前的数据进行轮询处理,使用min和max向前推N秒来卡延时的消息。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
如何实现ack消息队列
ack,即消息确认机制(Acknowledge)。
首先来看RabbitMQ的ack机制:
- Publisher把消息通知给Consumer,如果Consumer已处理完任务,那么它将向Broker发送ACK消息,告知某条消息已被成功处理,可以从队列中移除。如果Consumer没有发送回ACK消息,那么Broker会认为消息处理失败,会将此消息及后续消息分发给其他Consumer进行处理(redeliver flag置为true)。
- 这种确认机制和TCP/IP协议确立连接类似。不同的是,TCP/IP确立连接需要经过三次握手,而RabbitMQ只需要一次ACK。
- 值的注意的是,RabbitMQ当且仅当检测到ACK消息未发出且Consumer的连接终止时才会将消息重新分发给其他Consumer,因此不需要担心消息处理时间过长而被重新分发的情况。
用redis实现ACK
需要注意两点:- work处理失败后,要回滚消息到原始pending队列
- 假如worker挂掉,也要回滚消息到原始pending队列
(该方案主要解决worker挂掉的情况)
- 维护两个队列:pending队列和doing表(hash表)。
- workers定义为ThreadPool。
- 由pending队列出队后,workers分配一个线程(单个worker)去处理消息——给目标消息append一个当前时间戳和当前线程名称,将其写入doing表,然后该worker去消费消息,完成后自行在doing表擦除信息。
- 启用一个定时任务,每隔一段时间去扫描doing队列,检查每隔元素的时间戳,如果超时,则由worker的ThreadPoolExecutor去检查线程是否存在,如果存在则取消当前任务执行,并把事务rollback。最后把该任务从doing队列中pop出,再重新push进pending队列。
- 在worker的某线程中,如果处理业务失败,则主动回滚,并把任务从doing队列中移除,重新push进pending队列。