为什么需要消息队列
消息队列是历史最悠久的中间件之一,它可以和不同的进程进行通信,从而实现上下游之间的消息传递。基于此特性,我们可以在以下三个场景中使用消息队列。
- 解耦;
- 限流;
- 流量削峰;
1)解耦
先来看解耦,假设有两个服务:A 和 B,当服务 A 依赖服务 B 时,请求的耗时就是这两个服务之和。但如果服务 B 耗时比较长怎么办?
显然这时服务 A 可以将消息发送到队列中,服务 B 从队列里面去取即可,从而实现两个服务之间的逻辑解耦 + 物理解耦。
当用户注册账号时,会将注册信息发给账号服务,账号服务将信息写入数据库后,会调用短信服务给用户发送短信。如果不使用消息队列,那么必须等短信发送成功之后才能返回。
但为了给用户更好的体验,我们可以将发送短信这一步独立出去,账号服务将用户手机号和短信内容投入消息队列中就可以返回了,这样用户就能立刻收到注册结果。而短信服务会消费消息,异步执行发送短信逻辑,这就是消息队列的作用之一:解耦。
使用消息队列进行解耦,不仅可以提升性能,还可以使整个系统更加的模块化。以电商为例,订单服务是电商系统中的核心部分,它会被一系列下游服务依赖。并且随着业务的发展,依赖订单的下游服务会不断增加、不断变化。
因此负责订单服务的开发团队不得不花费大量精力,应对不断增加变化的下游服务,不停地修改调试订单服务与这些下游服务的接口。任何一个下游服务的接口产生变更,都需要订单模块重新进行一次上线,对于一个电商的核心服务来说,这几乎是不可接受的。
因此所有的电商系统都选择用消息队列,来解决这种系统耦合过于紧密的问题。引入消息队列后,订单服务在订单变化时发送一条消息到消息队列的一个主题 order 中,所有下游服务都订阅主题 order,这样每个下游服务都可以获得一份实时完整的订单数据。并且此时下游服务发生变化,不会影响订单服务。
2)限流
一个完善的系统一定具备自我保护的能力,即使面对海量请求,也能尽最大努力去处理,处理不了的则会拒绝掉,从而保证系统运行正常。因此如果我们能预估出系统的最大处理能力,就可以用消息队列实现一个令牌桶,进行流量控制。
令牌桶控制流量的原理是:单位时间内发放固定数量的令牌到令牌桶中,规定服务在处理请求之前必须先从桶中取走一个令牌,如果桶里面没有令牌,则拒绝请求。这样就保证单位时间内,能处理的请求数不超过发放令牌的数量,起到了流量控制的作用。
令牌桶可以简单地用一个有固定容量的消息队列加一个令牌生成器来实现:令牌生成器按照预估的处理能力,匀速生产令牌并放入令牌队列(如果队列满了则丢弃令牌)。网关(流量的入口)在收到请求时从令牌队列消费一个令牌,获取到令牌则继续调用后端服务,如果获取不到令牌则直接返回失败。
3)流量削峰
任何的大型服务,特别是秒杀服务,都离不开消息队列。因为消息队列除了解耦和限流之外,还可以起到流量削峰的作用,就是缓冲瞬时的突发流量,使其更平滑。
对于那些发送能力很强的上游系统,如果没有消息队列的保护,脆弱的下游系统可能会直接被压垮导致全链路服务雪崩。而一旦有了消息队列,它就能够有效地对抗上游的流量冲击,避免了流量的震荡。
我们举一个实际的例子,比如在京东购买商品,当点击购买的时候,会调用订单服务生成对应的订单。然而要处理该订单则会依次调用下游的多个子服务,比如查询登录信息、验证商品信息、确认地址信息,调用银行等支付接口进行扣款等等。
显然上游的订单操作比较简单,它的 TPS 要远高于处理订单的下游服务。因此如果上游和下游直接对接,势必会出现下游服务无法及时处理上游订单从而造成订单堆积的情况。特别是当出现双十一以及秒杀业务的时候,上游订单流量会瞬间增加,可能出现的结果就是直接压垮下游子系统服务。
解决此问题的一个做法是对上游的订单服务进行限流,比如采用上面说的令牌桶。但对于一个电商系统来说,这么做很明显是不合适的,因为问题不是出现在订单服务上面,而且用户买东西还限流,这样钱送到嘴边都赚不到。
所以会引入消息队列来对抗这种上下游系统的 TPS 不一致以及瞬时的峰值流量,引入消息队列之后,上游系统不再直接与下游系统进行交互。当新订单生成之后它仅仅向队列中发送一条消息,而下游消费队列中的消息,从而实现上游订单服务和下游订单处理服务的解耦。
这样当出现秒杀业务的时候,消息队列能够将瞬时增加的订单流量全部以消息的形式保存在队列中,既不影响上游服务的 TPS,同时也给下游服务留出了足够的时间去消费,这就是消息队列存在的最大意义所在。
简单来说,我们在单体应用里面需要使用本地队列解决的问题,在分布式系统中大多都可以用消息队列来解决。但同时我们也要认识到,消息队列也有它的一些问题:
- 引入消息队列带来的延迟问题;
- 增加了系统的复杂度;
- 可能产生数据不一致的问题;
所以在软件开发中没有银弹,需要根据业务的特点和自身条件选择合适的架构。
消息队列该怎么选择
消息队列如同数据结构一样,没有最好的,只有最合适的。但不管哪种消息队列,如果想要用于生产环境中,都应该具备以下几个特点:
- 消息的传递一定是可靠的;
- 支持阻塞等待拉取消息;
- 支持发布 / 订阅模式;
- 具备 ack 机制,消费失败后可重新消费,消息不丢失;
- 实例宕机了,消息也不会丢失,也就是支持数据持久化;
- 消息可积压;
- 支持集群部署;
- 开源免费,社区具有一定的活跃度;
- 生态完善;
- 性能足够好,能满足绝大部分场景;
符合以上需求的消息队列,主要有以下几种。
1)RabbitMQ
RabbitMQ 是一个在 AMQP(高级消息队列协议)基础上完成的可复用的企业消息系统,最早为电信行业系统之间的可靠通信而设计,是当前最主流的消息队列之一。
早期的 RabbitMQ 只支持 AMQP 协议,现在也支持 MQTT 协议。
RabbitMQ 都具备哪些优点呢?
- 采用 Erlang 语言编写,Erlang 语言最初用于交换机领域,它有着和原生 socket 一样的延迟,因此性能较好,吞吐量在万级,并且时效性在微秒级。
- 功能完善,健壮、稳定、易用、跨平台。
- 支持大部分主流语言,文档丰富,还提供了管理界面,并拥有非常高的社区活跃度和更新频率。
有优点,自然就有缺点,缺点如下:
- RabbitMQ 对消息堆积的支持并不好,在它的设计理念里面,消息队列是一个管道,大量的消息积压是一种不正常的情况,应当尽量去避免。当大量消息积压的时候,会导致 RabbitMQ 的性能急剧下降。
- RabbitMQ 的性能比较差,根据官方给出的测试数据以及使用经验,随着硬件配置的不同,它大概每秒钟可以处理几万到十几万条消息。其实这个性能也足够支撑绝大多数的应用场景了,但如果你的应用对消息队列的性能要求非常高,那就不适合选择 RabbitMQ 了。
- RabbitMQ 使用的编程语言 Erlang,这个编程语言不仅非常小众,学习曲线也很陡峭。
2)RocketMQ
阿里巴巴开源的一款消息队列,用 Java 语言实现,在设计时参考了 Kafka,并做了一些改进。在阿里内部广泛应用于订单、交易、重置、流计算、消息推送、日志流式处理、以及 binlog 分发等场景,经历过多次双十一考验,其性能、稳定性和可靠性都是值得信赖的。
优点如下:
- 支持单机吞吐量达到数十万级,可用性高,分布式架构保证消息零丢失;
- 功能较为完善,扩展性好,支持 10 亿级别的消息堆积,不会因为消息堆积导致性能下降;
- 对在线业务的响应时延做了很多的优化,大多数情况下可以做到毫秒级的响应;
所以 RocketMQ 在吞吐量和消息堆积方面要比 RabbitMQ 高很多,如果你比较在意这两个方面,那么可以使用 RocketMQ。而 RocketMQ 的缺点就是支持的客户端语言不多,社区活跃度一般。
3)Kafka
大数据的杀手锏,谈到大数据领域的消息传输,必然离不开 Kafka。这款为大数据而生的消息队列,有着百分级 TPS 的吞吐量,在数据采集、传输、存储的过程中发挥至关重要的作用,任何的大公司、或者做大数据的公司都离不开 Kafka。
- Kafka 的特点是性能卓越,单机写入 TPS 在百万条每秒,时效性也在毫秒级。
- Kafka 是分布式的,一个数据多个副本,少数的机器宕机也不会丢失数据。
- 消费者采用 pull 方式获取消息,消息有序、并且可以保证所有消息被消费且仅被消费一次。
- 拥有优秀的第三方 Kafka Web 管理界面 Kafka-Manager,在日志领域比较成熟,在大数据领域的实时计算以及日志采集等场景中被大规模使用。
- 和周边生态系统的兼容性非常好,在大数据和流计算领域,几乎所有的开源软件系统都会优先支持 kafka。
Kafka 使用 Scala 和 Java 语言开发,设计上大量使用了批量和异步的思想,这种设计使得 Kafka 能做到超高的性能。但也正是这种异步批量设计使得 Kafka 的响应时延比较高,因为客户端发送消息的时候,不会立即发出,而是攒够一批之后一起发送。
所以 Kafka 不太适合在线业务场景,它的重点是吞吐量,而不是低延迟。并且 Kafka 还有如下缺点:
- 单机超过 64 个分区,CPU 使用率会发生明显的飙高现象,队列越多 CPU 使用率越高,发送消息响应时间变长;
- 使用短轮询方式,实时性取决于轮询间隔时间,消费失败不支持重试;
- 虽然支持消息有序,但如果某台机器宕机,就会产生消息乱序。
那么这些消息队列,我应该选择哪一种呢?
RabbitMQ:如果说消息队列并不是你系统的主角之一,你对消息队列的功能和性能都没有很高的要求,只需要一个开箱即用易于维护的产品,建议使用 RabbitMQ。
RocketMQ:天生为金融领域而生,适合可靠性要求很高的场景,尤其是电商里面的订单扣款、以及业务削峰。RocketMQ 在稳定性上绝对值得信赖,毕竟这些业务场景在阿里双十一已经经历了多次考验,如果你的业务也有类似场景,那么建议选择 RocketMQ。
Kafka:基于 Pull 模式来处理消息,追求高吞吐量,一开始的目的就是用于日志收集和传输,高吞吐量是 Kakfka 的目标。因此如果要处理海量的消息(比如日志采集、监控信息、前端埋点),或者使用了大数据、流计算相关的开源产品,那么首选 Kafka。
消息队列的存储模型
任何一款消息队列,都可以视为三部分:生产者、broker、消费者。
- 生产者和消费者都可以视为客户端;
- broker 便是服务端启动之后的进程,比如 Kafka broker;
生产者会将消息发送至 broker 中,broker 会对消息进行存储以及持久化,消费者负责从 broker 中拉取消息。如果抛开那些花里胡哨的概念,其实整个过程是非常简单的。
而这里我们要探讨的是,消息在队列中是如何存储的?
最初的消息队列,就是一个严格意义上的队列,它是一个先进先出的线性表,通常使用链表或数组来实现。队列只允许在后端(称为 rear)进行插入操作,在前端(称为 front)进行删除操作。
这个定义里面包含几个关键点:
- 先进先出:这意味着消息在入队和出队的过程中,需要保证这些消息严格有序,消息的写入顺序和读取顺序是一致的;
- 早期的消息队列,就是按照队列的数据结构来设计的;
- 生产者发消息是入队操作,消费者收消息是出队(删除)操作;
- 服务端存放消息的容器自然就是队列
如果有多个生产者往同一个队列里面发消息,这个队列中可以消费到的消息就是这些生产者生产的所有消息的合集,消息的顺序就是这些生产者发送消息的自然顺序。
同理,如果有多个消费者接收同一个队列的消息,这些消费者之间就是竞争关系,每个消费者只能收到队列中的一部分消息,也就是说任何一条消息只能被其中的一个消费者收到。
如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,比如一份订单数据,要求风控系统、分析系统、支付系统等都需要接收消息。这时单个队列就满足不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。
显然这是个比较笨的做法,同样的一份消息被复制到多个队列中会浪费资源。更重要的是,生产者必须知道有多少个消费者,为每个消费者单独发送一份消息,这实际上违背了消息队列的解耦这个设计初衷。
为了解决这个问题,演化出了另外一种消息模型:发布/订阅模型。
在发布/订阅模型中,消息的发送方被称为发布者(Publisher),消息的接收方被称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先订阅主题,每份订阅中,订阅者都可以接收到主题的所有消息。
在消息领域的历史上很长的一段时间,队列模式和发布/订阅模式是并存的,有些消息队列同时支持这两种消息模型。但我们仔细对比一下这两种模型,会发现它们并没有本质的区别,生产者就是发布者,消费者就是订阅者,队列就是主题。它们最大的区别其实就是,一份消息数据能不能被消费多次的问题。
实际上,在这种发布/订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了,因此发布/订阅模型在功能层面上可以兼容队列模型。
现代的消息队列产品使用的消息模型大多是这种发布/订阅模型,当然也有例外。
RabbitMQ 的消息模型
RabbitMQ 是少数依然坚持使用队列模型的产品之一,那它是怎么解决多个消费者的问题呢?在 RabbitMQ 里面有一个别的消息队列都没有的概念:Exchange(交换机),它位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给 Exchange,由 Exchange 上配置的策略来决定将消息投递到哪些队列中。
同一份消息如果需要被多个消费者消费,则需要配置 Exchange 将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。这也可以变相地实现发布/订阅模型中,一份消息数据可以被多个订阅者来多次消费这样的功能。
所以 RabbitMQ 消息传递模型的核心思想是:生产不会直接将消息发送到队列,而是先发送到交换机。交换机的工作内容就是接收生产者的消息,并且按照指定的规则将消息推入队列,因此交换机必须清楚地知道如何处理接收到的消息,是把这些消息推送到特定队列、还是多个队列,亦或是丢弃它们,这要由交换机的类型决定。
交换机有 4 种类型:direct、fanout、topic、headers,默认是 direct,不同的类型的交换机会有不同的表现。
RabbitMQ 会通过 Binding 将 Exchange 和 Queue 绑定在一起,并且在绑定 Exchange 和 Queue 的同时(可多次绑定),会指定一个 Binding key。而生产者将消息发送到 Exchange 的时候,也会带上一个 Routing key。
- 如果交换机的类型是 direct,它会将消息推送到 Binding Key 和 Routing Key 相匹配的 Queue 中。因为交换机和队列可以多次绑定,所以一个队列可以有多个 Binding Key,只要一个能匹配上即可;
- 如果交换机的类型是 fanout,它会直接把消息推送到所有与它绑定的队列中;
- 如果交换机的类型是 topic,那么 Binding Key 会支持 * 通配符,从而和 Routing Key 进行模糊匹配;
- 如果交换机类型是 headers,会根据发送的消息内容中的 headers 属性进行匹配;
RocketMQ 的消息模型
RocketMQ 使用标准的发布/订阅模型,但它除了生产者、消费者和主题之外,也有队列这个概念,并且队列在 RocketMQ 中是一个非常重要的概念。每个主题包含多个队列,通过多个队列来实现并行生产和消费。需要注意的是,RocketMQ 只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。
RocketMQ 中,订阅者的概念是通过消费者组(Consumer Group)来体现的,每个消费者组都消费主题中一份完整的消息,不同消费者组之间消费进度彼此不受影响。也就是说,一条消息被 Consumer Group1 消费过,也可以再给 Consumer Group2 消费。
但消费者组中包含多个消费者,同一个组内的消费者是竞争关系,每个消费者负责消费组内的一部分消息。如果一条消息被消费者 Consumer1 消费了,那同组的其它消费者就不会再收到这条消息。
在 Topic 的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要 RocketMQ 为每个消费者组在每个队列上维护一个消费位移(Consumer Offset)。这个位移之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位移就加一。
如果你对 RocketMQ 中的这些概念还有些困惑的话,那么没关系,我们看一下 Kafka 的消息模型。如果你熟悉 Kafka 的话,那么你会瞬间理解 RocketMQ。
Kafka 的消息模型
Kafka 的消息模型和 RocketMQ 是完全一样的,上面说的所有 RocketMQ 中的概念,和生产消费过程中的确认机制,都完全适用于 Kafka。唯一的区别是,在 Kafka 中,队列这个概念的名称不一样,Kafka 中对应的名称是分区(Partition),但含义以及功能和 RocketMQ 的队列是没有任何区别的。
所以如果你熟悉 Kafka,那么你会瞬间理解 RocketMQ,因为两者的消息模型是一样的。只不过 RocketMQ 是一个主题对应多个队列,而 Kafka 是一个主题对应多个分区。
小结
以上我们就探讨了消息队列的应用场景,以及它们存储模型之间差异。关于这些队列更详细的内容,可以参考网上的资料。
总之当你的数据量不大时,使用 RabbitMQ 是一个不错的选择。