6 消息中间件
消息中间件是分布式系统基础设施之一。消息中间件使得消息发送者不必同步等待消息接收者的响应,而是将消息发送到消息队列中,由消息消费者来异步消费。这种处理模式这种处理模式主要的应用场景有三种:异步通讯;解耦;并发缓冲。
6.1 消息发送的一致性
一致性是说某个动作发生前和发生后都有正确、完整的逻辑意义。那么对于消息来说,一致性是指产生消息的业务动作与消息发送的一致,业务操作成功消息一定要发出去,业务操作失败就不发消息。
假如先进行业务操作,再发送消息,当业务操作完成后消息中间件挂掉了,那么消息就没有发出去,没有达到一致性。反之,先发消息再进行业务操作,消息发出去了业务操作失败问题更大。
在实际场景中,一般是先操作业务再发消息,因为消息系统挂掉的概率是极低的,多数场景中都能够接受这极小概率带来的不一致性。
而对于必须严格保证一致性的场景,则要考虑另外的方案。
JMS的消息发送一致性保证
JMS是Java中关于消息的规范,是一组与具体消息组件无关的消息服务API,类似于数据库的统一访问接口JDBC。消息组件实现了JMS规范,Java应用程序就能够通过JMS接口与消息组件很好地进行通信,如ActiveMQ。当然也并不是所有的消息组件都遵守了JMS规范,如Kafka就没有遵循,但提供了类JMS的特性。
JMS中有几个关键的接口如下:
(1) Destination。消息发送出去之后要达到的通道,即queue(P2P模型)或topic(P/S模型)。
(2) ConnectionFactory和Connection。 连接工厂和连接。
(3) Session。消息会话,通过Connnection创建,用于创建消息生产者,消费者以及消息本身。
(4) MessageConsumer和MessageProducer,消息消费者和生产者。
(5) XXMessage,不同类型的消息对象,包括BytesMessage,MapMessage,ObjectMessage,StreamMessage和TextMessage几种。
在JMS消息模型中有PTP(Point to Point)和Topic两种,因此上面这些关键接口都有对应的子接口,如下图所示:
通常情况下, 使用以上这些接口即可完成消息的发送和接收,但如果要保证消息发送的一致性,那么可以使用以XA的对应接口,对应关系如下图所示:
因为事务的控制是在Session层面上的,所以Session,Connection,ConnectionFactory三个接口有对应的XA系列接口。
JMS中定义的XA系统的接口是为了实现分布式事务的支持,但是这会带来如下的问题:
- 引入了分布式事务,带来了额外的开销并增加了复杂性。
- 对于业务操作有限制,要求业务操作的资源必须支持XA协议,才能够与发送消息一起做成分布式事务。而不是所有的操作资源都是支持XA协议的。
考虑一种变通的方案:
(1) 主动方应用先将消息发送给消息中间件,消息状态标记为待确认。
(2) 消息中间件收到消息之后,将消息持久化到消息存储中,但暂时不投递消息。
(3) 消息中间件返回消息持久化结果(成功/失败),主动方应用根据返回结果进行业务操作(执行操作/放弃操作)。
(4) 业务操作完成后,把业务操作结果发送给消息中间件。
(5) 消息中间件收到业务操作结果后,根据结果进行处理(删除消息/更改消息状态为可发送,并发送消息)。
相比于先进行业务操作然后投递消息,这样做的好处是当中间件持久化消息异常时可以保证一致性。当然这样做也带来了额外的开销和复杂性,但与XA接口相比则要好很多。
6.2 消息模型对消息接收的影响
消息模型有两种,分别是点对点和发布订阅模型。
点对点模型中消息生产者将消息发送到JMS服务器,根据消息达到的顺序形成消息队列。消息队列中的每个消息只能被一个消费者消费。
而发布订阅模型中,消息生产者发送的消息根据topic组织在一起,同一个topic的消息会被所有订阅该topic的消费者消费。也就是说,topic的消息可能会被多个消费者消费。
假设现在有两个消费者集群,每个集群有两台物理节点。那么在点对点消息模型中,消费者消费消息的模型图如下所示:
此种场景下,所有物理节点消费的消息加起来等于全部的消息。
而在发布/订阅模型中消费者消费消息的模型图如下所示:
此种场景中,由于每个消费者节点都订阅了topic,所以该topic中所有的消息均会被每个消费者全部消费。
那么问题来了。假设有两个系统,其服务器集群分别是A和B,每个集群都要消费全部的消息,但每个系统内部的服务器消费的消息不能重复,此时又该如何实现呢?
解决办法是将集群和集群之间对消息的消费当做Topic模型来处理,但集群内部的各个具体实例对消息的消费当做Queue模型来处理。可以引入ClusterId,用id来标识不同的集群。根据ClusterId进行连接的分组,在不同的CulsterId之间保证消息的独立投递,相同的ClusterId的连接则共同消费这些消息,如下图所示:
如果要使用JMS的话,可以把JMS的Topic和Queue也按照上面的思路级联起来使用,如下图所示:
实际上就是增加一个中转层,将一个Topic转换成多个Queue,对应着相应的消费者集群。但这种级联方式相对比较繁重,比在消息中间件服务器端内部进行处理要复杂很多。
6.3 消息的可靠性
消费者订阅消息有两种模式,一种是持久型,一种是非持久型。非持久型是指消费者宕机过程中发布的消息被丢弃,即消费者重启之后无法获取这些已被丢弃的消息。而持久型则可以在重启之后获取消费者下线过程中被发布的消息。
非持久型订阅模式可以保证消息的及时性,如果要保证消息的可靠性的话,则必然要选用持久订阅模式了。
正常情况下,持久订阅模式能订阅所有消息。但由于网络异常等原因,仍然会有一些消息在投递的过程中丢失。
消息从发送端应用到接收端应用,整个过程如下图所示:
在这个过程中就涉及到三个阶段的可靠性保证。
6.3.1 发送端可靠性
消息从发送者送到消息中间件,只有当消息中间件及时、明确地返回成功,才能确认消息可靠地到达了消息中间件了,否则就发送失败。
6.3.2 消息存储的可靠性
当消息到达消息中间件之后,消息的存储可靠性就是需要考虑的问题了。消息如果存储在内存中不做持久化,那么一旦消息系统宕机或断电,所有的消息都会丢失,因此要保证消息存储的可靠必须对消息进行持久化。
主流的消息存储方式主要有三种:
(1) 分布式KV存储
这类存储一般采用诸如levelDB、RocksDB和Redis来作为消息持久化的方式。
(2) 文件系统
目前几种常用的消息中间件均采用的是此种消息存储方式,如RocketMQ、Kafka、RabbitMQ。
(3) 关系型数据库
消息的持久化也可以通过关系型数据库来实现,如ActiveMQ(默认采用KahaDB做消息存储)就可以选用JDBC的方式来做消息持久化。但普通关系型数据库(如Mysql)在单表数据量达到千万级别的情况下,其IO读写性能往往会出现瓶颈,因此并不推荐此种方案。另外,这种方案非常依赖DB,一旦DB出现故障,则MQ的消息就无法持久化从而导致故障。
从存储效率上来看,持久化方案中文件系统最高,关系型数据库最低。同时文件系统存储对外部的依赖也是最小的。
6.3.3 接收端可靠性
最后一步就是消息从消息中间件中到达消费者,和发送端可靠性类似,处理相对简单。需要注意的是,消息中间件需要确认消费者对消息处理完毕之后才能认为消息已被投递,而不能简单根据网络层响应来判断,因为消费者可能在处理消息时发生异常,此时消息是没有被处理的,可能需要再次投递。
上述内容是从设计的角度来讲消息中间件如何保证可靠性的。从工程实践上来讲,当前市面上主流的消息中间件都提供了确认机制来保证消息投递的可靠性,如ActiveMQ的消息确认消费,RabbitMQ的发布者确认,Kafka的ack等等。
6.4 消息确认与消息重复
为保证消息的可靠性,在消息丢失的情况下,会进行消息的重新投递。但这里的消息丢失如果是真实的丢失还好,如果是假丢失,比如消息由中间件投递给消费者,由于某些异常导致消费者的确认回复没有被中间件接收到,消息中间件认为投递失败,从而重新投递,就会导致消息的重复。
在JMS中,session分为事务性和非事务性的。对于后者,消息接收端对收到的消息进行确认,有几种选择,如下表所示:
JMS确认方式 | 描述 |
---|---|
Auto_AcKnowledge | 自动确认。对于同步消费者,Receive方法调用返回。当没有发生异常时,自动确认;对于异步消费者,onMessage方法返回,当没有发生异常时自动确认。 |
Client_AcKnowledge | 手动确认,消费者客户端自行决定确认时机。要求消费者客户端通过ackonwledge方法进行确认。 |
Dups_OK_AcKnowledge | 延时、批量自动确认。允许JMS不必急于确认收到的消息,可在收到多个消息之后一次完成确认。 |
消息的确认机制可以提高消息的可靠性,但也会带来消息的重复投递。比如批量自动确认机制,当收到一批消息后进行确认时发生异常,会导致这一批消息全部重新发送。
对于消息重发,解决办法是保证消息处理逻辑的幂等性。