在代码里一定是先操作数据库再发送消息,避免因数据库回滚导致的数据不一致。
但是如果先操作数据库,后发送消息,发消息出现问题,那不是一样会出现业务数据不一致?
所以,这是一个经典面试题:在使用MQ实现异步通信的过程中,有消息丢了怎么办?或者MQ消息重复了怎么办?
这就是RabbitMq的可靠性投递问题,当然,RabbitMQ在设计的时候其实就考虑了这一点,提供了很多保证消息可靠投递的机制。
可靠性只是问题一方面,发消息的效率同样是我们需要考虑的问题,二者无法兼得。如果在发消息的每一个环节都采取相关措施来保证可靠性,势必会对消息的收发效率造成影响。
所以,这些手段并不是一定要用。例如:业务实施一致性要求不是很高的场合,可以牺牲可靠性来换取效率。比如发通知或日志的场景,如果没有收到通知,不会造成很大的影响,不需要严格保证所有消息都发送成功。如果失败了,再次发送即可。
根据RabbitMQ的工作模型,来分析RabbitMQ为我们提供了哪些可靠性措施?
在我们使用RabbitMq收发消息的时候,主要有4个环节
1)代表消息从生产者发送到Broker
生产者把消息发送到Broker之后,怎么知道自己的消息有没有被Broker成功接收?如果Broker不给应答,生产者不断发送,那有可能是一厢情愿,消息全部进了黑洞。
2)代表消息从Exchange路由到Queue
Exchange是一个绑定队列,它的职责是分发消息。如果他不能分发消息怎么办?也就是说,找不到队列或者找不到正确队列,怎么处理?
3)代表消息在Queue中存储
队列有自己的数据库(Mnesia),他是真正用来存储消息的。如果还没有消费者来消费,那么消息要一直存储在队列里面。怎么保证消息在队列稳定的存储呢?
4)代表消费者订阅Queue并消费消息
队列的特性是什么?FIFO。队列里的消息是一条一条投递的,也就是说,只有上一条消息被消费者接收以后,才能把这一条消息从数据库删掉,继续投递下一条消息。
反过来说,如果消费者不签收,我不能去派送下一个快件的,总不能丢在门口就跑吧?
问题来了,Broker(快递总部)怎么知道消费者已经接收了消息呢?
我们从这4个环节入手,分析如何保证消息的可靠性。
第一个环节是生产者发送消息到Broker,先说一下什么情况下会发送失败?
可能是因为网络连接或者Broker的问题(比如硬盘故障、磁盘写满)导致消息发送失败,生产者不能确定Broker有没有正确接收。
如果我们来设计,肯定要给生产者发送消息的接口一个应答,生产者才可以明确知道消息有没有发送成功。
在RabbirtMq里面提供了2种机制服务端确认机制,也就是生产者发送消息给RabbitMq的服务端的时候,服务端会通过某种方式返回一个应答。只要生产者收到这个应答,就知道消息发送成功了。
第一种是Transaction(事务模式) ,第二种是Confirm(确认模式)
事务模式怎么使用呢?它在创建channel的时候,可以把信道设置成事务模式,然后就可以发消息给RabbitMq了。
如果Chanel.txCommit();的方法调用成功,就说明事务提交成功,则消息一定到达RabbitMq中。
如果在事务提交执行之前由于RabbitMq异常崩溃或者其他原因抛出异常,这个时候我们便可以将其捕获,进而通过执行channel.txRollback()方法来实现事务回滚。
在事务模式里面,只有收到了服务端的Commit-OK的指令,才能提交成功。
但是事务模式有一个缺点,他是阻塞的,一条消息没有发送完毕,不能发送下一条消息,它会榨干RabbitMQ服务器的性能。所以不建议生产环境使用。
那么有没有其它可以保证被Broker接收,但是又不大量消耗性能的方式呢?
这个就是第二种模式,叫做确认模式。
确认模式有3种,一种是普通确认模式。
在生产者这边通过调用channel.confirmSelect()方法将信道设置为Confirm模式,然后发送消息。一旦消息被投递到交换机之后(跟是否路由到队列没有关系),RabbitMQ就会发送一个确认(Basic.Ack)给生产者,也就是调用channel.waitForConfims()返回true,这样生产者就知道消息被服务端接收了。
如果网络错误,就会抛出连接异常。如果交换机不存在,会抛出404错误。
//开启发送方确认模式
channel,confirmSelect();
channel.basicPublish("",Queue_name,null,mag.getBytes())
//普通Confirm,发送一条,确认一条
if(channel.waitForConfirms()){
System.out.println("消息发送成功")
}
这种发送1条确认1条的方式消息效率还是不太高,所以我们还有一种批量确认的方式。
批量确认,就是在开启Confirm模式后,先发送一批消息。
try{
channel.confirmSelect();
for(int i=0;i<5;i++){
channel.basicPublish("",Queue_Name,null;(msg+"_"+i).getBytes());
}
//批量确认结果,ACK如果是Multiple=true,代表ACK里面的Delivery-Tag之前的消息都被确认了
//比如5条消息可能只收到1个ACK,也可能是收到2个(抓包才可以看到)
//直到所有消息都发布,只要有一个未被Broker确认就会IOException
channel.waitForConfirmsOrDie();
System.out.println("消息发送完毕,批量确认成功")
}catch(Exception e){
//出现异常,可能需要对所有消息进行重发
e.printStackTrace();
}
只要 channel.waitForConfirmsOrDie();方法没有抛出异常,就代表消息都被服务端接收了。
批量确认的方式比单条确认的方式效率要高,但是也有两个问题:
第一个就是批量的数量的确定。对于不同的业务,到底发送多少条消息确认一次?
数量太少,效率提升不上去。数量多的话,又带来另一个问题,比如我们发1000条消息才确认1次,如果999条消息都被服务器接收了,如果第1000条消息被拒绝了,那么前面所有的消息都要重发。
有没有一种方式,可以一边发送一边确认呢?这个就是异步确认模式。
异步确认模式需要添加一个ConfirmListener,并且用一个SortedSet来维护一个批次中没有被确认的消息。
//用来维护未确认消息的deliveryTag
SortedSet<Long> confirmSet = Collections.synchronizedSortedSet(new TreeSet<Long>());
//开启发送确认方式
channel.confirmSelect();
//这里不会打印所有响应的ACK;ACK可能有多个,有可能一次确认多条,也有可能一次确认一条。
//异步监听确认和未确认的消息
//如果要重复运行,先停掉之前的生产者,清空队列
channel.addConfirmListener(new ConfirmListener() {
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
if (multiple) {
confirmSet.headSet(deliveryTag + 1).clear();
} else {
confirmSet.remove(deliveryTag);
}
}
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
System.out.println("Nack, SeqNo: " + deliveryTag + ", multiple: " + multiple);
if (multiple) {
confirmSet.headSet(deliveryTag + 1).clear();
} else {
confirmSet.remove(deliveryTag);
}
}
});
while (true) {
long nextSeqNo = channel.getNextPublishSeqNo();
//发送消息
channel.basicPublish(ConfirmConfig.exchangeName, ConfirmConfig.routingKey, MessageProperties.PERSISTENT_TEXT_PLAIN, ConfirmConfig.msg_10B.getBytes());
confirmSet.add(nextSeqNo);
}
System.out.println("所有消息"+confirmSet)
第二个环节就是消息从交换机路由到队列。大家思考一下,什么情况下,消息会无法路由到正确的队列?
可能是因为routingkey错误,或者队列不存在(但是生产环境下基本不会出现这2种问题)
我们有2种方式处理无法路由的消息,一种就是让服务端重发给生产者,一种是让交换机路由到另一个备份交换机。
1)消息回发
channel.addReturnListener();
2)路由到备份交换机
在创建交换机的时候,从属性中指定备份交换机。
Map<String,Object> map = new HashMap<String,Object>();
map.put("alternate-exchange","BeiYong_Exchange");
channel.exchangeDeclare("Orignal_Exchange","topic",false.false,false,map);
第三个环节是消息在队列中存储,如果没有消费者的话,队列一直存在数据库中。
如果RabbitMQ的服务或者硬件故障,比如宕机、重启等,可能会导致内存中的消息丢失,所以我们要把消息本身和元数据(队列、交换机、绑定关系)都保存到磁盘。
durable:没有持久化的队列,保存在内存中,服务重启后队列和消息都会消失(钱和人一起没了)
autoDelete:没有消费者连接的时候,自动删除
exclusive:排他性队列的特点
1)只对首次声明它的连接(Connection)可见
2)会在其连接断开的时候自动删除
//deliveryMode(2) 2代表消息持久化
AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder().deliveryMode(2)
.build();
channel.basicPublish("exchange.persistent", "persistent",properties, "persistent_test_message".getBytes());
如果消息没有持久化,保存在内存中,队列还在,但是消息在重启后会消失(人生最最痛苦的事情:人还在,钱没了)。
如果只有一个RabbitMq的节点,即使交换机、队列、消息都做了持久化,如果服务崩溃或者硬件故障(机房起火被炸我们先不讲…),RabbitMq的服务一样是不可用的。
所以为了提高MQ服务的可用性,保障消息传输,我们需要有多个RabbitMQ的节点。
如果消费者收到消息后没来得及处理即发生异常,或者处理过程中发生异常,会导致(4)失败。服务端应该以某种方式得知消费者对消息的接收情况,并决定是否重新投递这条消息给其它消费者。
RabbitMQ提供了消费者的消息确认机制(message ackknowledgement),消费者可以自动或手动发送ACK给服务端。
如果没有ACK会怎么办?永远等待下去?也不会
没有收到ACK的消息,消费者断开连接后,RabbitMQ会把这条消息发给其他消费者。如果没有其他消费者,消费者重启后会重新消费这条消息,重复执行业务逻辑。
消费者怎么给Broker应答呢?有2种方式,一种是自动ACK,一种是手动ACK。
首先是自动ACK,这个是默认的情况。也就是说我们没有在消费者处编写ACK的代码,消费者会在收到消息的时候就自动发送ACK,而不是在方法执行完毕的时候发送ACK(并不关心你有没有正常消费)。
如果想要等消息消费完毕或者方法执行完毕才发送ACK,需要先把自动ACK设置成手动ACK。把autoAck设置成false。
channel.basicConsume(Queue_Name,false,consumer);
这个时候RabbitMQ会等待消费者显式的回复ACK后才从队列中移去消息。
channel.basicAck(envelope.getDeliveryTag(),true);
在Spring Boot中application.yml:
spring:
rabbitmq:
host: 148.70.153.63
port: 5672
username: libai
password: password
listener:
simple:
# 手动确认
acknowledge-mode: manual
# 拒绝消息是否重回队列
default-requeue-rejected: true
注意这3个值的区别:
none:自动ACK
manual:手动ACK
auto:如果方法未抛出异常,则发送ack。
如果方法抛出异常,并且不是AmqpRejectAndDontRequeueException则发送nack,并且重新入队列。如果抛出异常是AmqpRejectAndDontRequeueException则发送nack,但不会重新入队列。
如果消费出了问题,确实不能发送ACK告诉服务端成功消费了怎么办?当然有拒绝消息的命令,而且还可以让消息重新入队列给其它消费者消费。
如果消息无法处理或者消费失败,也有2种拒绝方式,Basic.Reject()拒绝单条,Basic.Nack()批量拒绝。
if(msg.contains("拒收")){
//拒绝消息
//requeue:是否重新入队列,true:是;false:直接丢弃,相当于告诉队列可以直接删除掉
//如果只有这一个消费者,requeue为true的时候会造成消息重复消费
channel.basicReject(envelope.getDeliveryTag(),false);
}else if(msg.contains("异常")){
//批量拒绝
//requeue:是否重新入队列
//如果只有这一个消费者,requeue为true的时候会造成消息重复消费
channel.basicNack(envelope.getDeliveryTag(),true,false)
}
如果requeue参数设置为true,可以把这条消息重新存入队列,以便于发给下一个消费者(当然,只有一个消费者的时候,这种方式可能会出现无限循环重复消费的情况。可以投递到新的队列中,或者只打印异常日志)。
简单总结一下:
从生产者到Broker,交换机到队列,队列存储,队列到消费者,我们都有相应的方法知道消费有没有正常流转,或者说当消息没有正常流转的时候采取相关措施。
思考:服务端收到ACK或者Nack,生产者会知道吗?即使消费者没有接收到消息,或者消费时出现异常,生产者也是完全不知情的。这个是符合解耦思想的,不然你用MQ干嘛?
但是,如果为了保证一致性,生产者必须要知道消费者有没有成功消费,怎么办?
例如,我们寄出去一个快递,是怎么知道收件人有没有收到的?
因为有物流跟踪和签收反馈,所以寄件人可以知道。
但是在没有电话的时代,我们寄出去一封信,是怎么知道收信人有没有收到信件?只有收到回信,才知道寄出去的信被收到了。
所以,这个是生产者最终确定消费者有没有消费成功的两种方式:
1)消费者收到消息,处理完毕后,调用生产者的API(思考:是否破坏解耦?)
2)消费者收到消息,处理完毕,发送一条响应消息给生产者。
如果生产者的API就是没有调用。也没有收到消费者的响应消息,怎么办?
不要着急,先等等,可能是消费者处理时间太长或者网络超时。
你发微信消息给朋友去吃烧烤,他没有立即回复消息,别担心,可能是路上出车祸。但是如果一直不回复消息就不行了。
生产者和消费者之间应该约定一个超时时间,对于超出这个时间没有得到响应的消息,才确定为消费失败,比如5分钟。
5分钟,对于临时性故障,比如网络恢复或者重启应用,重启数据库,应该够了。
过了5分钟依然没有得到回复的消息,我们才判断为消费失败。
确定为消费失败以后怎么办呢?肯定要重发消息了。
不过这里面有几个问题:
1、谁来重发?
假如这个消息是业务人员操作产生的,对于异步的操作来说,他只要提交了请求就OK了,后面成不成功是不归他管的。所以肯定是后台的代码重发的。不可能让业务人员重新做一笔交易。
先创建一个定时任务,比如1分钟跑一次,找到业务表里面这条业务状态是中间状态的记录,查询出来,构建为MQ消息,重新发送。
也可以单独设计一张消息表,登记本系统发送出去的消息,找出状态是未回复的消息发送(值得注意:这种做法无疑会消耗性能、消耗数据库存储空间)
2、隔多久重发一次?
假如消费者一直没有回复,比如它重启20分钟,你5分钟内尝试重发,肯定不能正常消费。所以重发肯定不只发一次,要尝试多次,但是又不能发的太频繁,给他一点恢复时间。比如可以设置衰减机制,第一次隔一分钟,第二次隔两分钟(谈恋爱的时候,发消息不回复,开始一天联系一次,后来一周联系一次,慢慢的失去了信心)。
时间由定时任务的执行时间决定。
3、一共重发几次?
终极问题来了,如果消费者真的死了。而你的程序10分钟重发1次,一个小时6条消息,一天就重发了100多条消息,后面绝大部分时间是在做无用功,还无端的造成服务端的MQ消息堆积。
所以,重发消息务必要控制次数,比如设置成3次。
这要在消息表里面记录次数来实现,发一次就加1.
4、重发什么内容?
重发,是否发送一模一样内容?
如果消费者状态是正常的,每条消息都可以正常处理。只是在响应或者调用API的时候出了问题,会不会出现消息的重复消费?例如:存款1000元,ATMC重发了3次存款消息,核心系统一共处理了4次,账户余额增加了4000元。
所以,为了避免相同消息的重复处理,必须要采取一定的措施。RabbitMQ服务端是没有这种控制的(同一批的消息有个递增的DeliveryTag),RabbitMQ他并不知道对于你的业务来说什么才是重复的消息。所以这个只能在消费端控制。
消息出现重复消费可能会有2个原因:
1、生产者的问题,重复发送消息,比如开启了Confirm模式但未收到确认,消息重复投递。
2、消费者的问题,由于消费者未发送ACK或者其他原因,消息重复消费。
3、生产者代码或者网络问题。
对于重复发送的消息,可以对每一条消息生成唯一的业务ID,通过日志或者消息落库来做重复控制。
例如:在金融系统中有一个叫流水号的东西。不管你在柜面汇款,还是ATM取款,或者信用卡消费,都会有一个唯一的序号。通过这个序号就可以找到唯一的一笔消息。
参考:银行的重张控制环节,对于近来的每一笔交易,第一件要做的事情就是查询是否重复。
大家有没有用微信支付的时候被提示可能是重复支付?
业务要素一致(付款人ID,商户ID、交易类型、交易地点、交易时间)。可能是同一笔消息。
如果消费者宕机了,或者代码出现了bug导致无法正常消费,在我们尝试多次重发以后,消息最终也没有得到处理。怎么办?
例如存款场景,客户的钱已经被吞了,但是余额没有增加,这个时候应该怎么处理?
如果客户没有主动通知银行,他没有及时查询余额,这个问题是怎么发现的?银行最终怎么把这个账务做平?
在我们的金融系统中,都会有双方对账或者多方对账的操作,通常是在一天的业务结束之后,第二天营业之前。金融系统里面,多一分钱少一分钱都是非常严重的问题。
我们会约定一个标准,比如ATM跟核心系统对账,肯定是以核心系统的账务为准。ATMC获取到核心的对账文件,然后解析登记成数据,然后跟自己记录的流水比较。找出核心有ATM没有,或者ATM有核心没有,或者两边都有但是金额不一致的数据。
对账之后,我们再手工平账。比如取款记了账但是没有吐钞的,做一笔冲正。存款吞了钞没记账的,要么把钱退给客户,要么补一笔帐。
消息的顺序性指的是 消费者消费消息的顺序跟生产者生产消息的顺序是一致的。
在RabbitMQ中,一个队列有多个消费者时,由于不同的消费者消费的速度时不一样的,顺序性无法保证。只有一个队列且仅有一个消费者的情况才能保证顺序消费(不同的业务消息发送到不同的专用队列)
除了负载的场景,不要用多个消费者消费消息。
集群主要用于实现高可用与负载均衡。
高可用:如果集群中某些MQ服务器不可用,客户端还可以连接到其它MQ服务器。不至于影响业务。
负载均衡:在高并发的场景下,单台MQ服务器能处理的消息有限,可以分发给多台MQ服务器。减少消息延迟。
因为做集群,需要面对数据同步和通信的问题。因为Erlang天生具备分布式的特性,所以RabbitMQ天然支持集群,不需要通过引入zk来实现数据同步。
RabbitMq通过erlang.cookie来验证身份,需要在所有节点保持一致。
服务的端口5672,
UI的端口15672,
集群的端口是25672。
集群通过25672端口两两通信,需要开放防火墙的端口。
需要注意的是,RabbitMq集群无法搭建在广域网上,除非使用federation或者shovel等插件(没这个必要,在同一个机房做集群)
集群有两种节点类型,一种是磁盘节点(Disc Node),一种是内存节点(RAM Node)
磁盘节点:将元数据(包括队列名字属性、交换机的类型名字属性、绑定关系、vhost)放在磁盘中。未指定类型的情况下,默认为磁盘节点。
集群中至少需要1个磁盘节点用来持久化元数据,否则全部内存节点崩溃时,就无从同步元数据。
内存节点:将元数据放在内存中。
PS:内存节点会将磁盘节点的地址存放在磁盘(不然重启就没办法同步数据了)。如果是持久化消息,会同时存放在内存和磁盘。
我们一般把应用连接到内存节点(读写快),磁盘节点用来备份。
RabbitMq有两种集群模式:普通集群模式和镜像队列模式
普通集群模式下,不同的节点之间只会相互同步元数据(交换机、队列、绑定关系、Vhost的定义),而不会同步消息。
比如,队列1的消息只存储在节点1上。节点2和节点3同步队列1的定义,但是没有同步消息。
假如生产者连接的是节点3,要将消息通过交换机A路由到队列1,最终消息还是会转发到节点1上存储,因为队列1的内容只在节点1上。
同理,如果消费者连接的是节点2,要从队列1上拉取消息,消息会从节点1转发到节点2上。其它节点起到一个路由的作用,类似于指针。
这样是不是会有一个问题,如果节点1宕机了,队列1的所有数据就全部丢失了。为什么不把消息在每个节点上复制一份?
主要出于存储和同步数据的网络开销,如果所有节点都存储相同数据,就无法达到线性的增加性能和存储容量的目的(堆机器)
这就是一个分片存储思想。
当然,如果保证队列的高可用性,就不能用这种集群模式了,因为节点失效将导致相关队列不可用。因此我们需要第二种集群模式
镜像队列模式下,消息内容会在镜像节点间同步,可用性更高。不过也有一定的副作用,系统性能会降低,节点过多的情况下同步的代价比较大。
集群搭建成功后,如果有多个内存节点,那么生产者和消费者应该连接到那个内存节点?我们不可能在业务代码中实现根据一定策略来选择要使用的服务器。这样客户端代码会出现很多重复,修改起来也很麻烦。
所以需要一个负载均衡组件(例如HAProxy,LVS,Nginx),由负载的组件来做路由。这个时候,只需要连接到负载组件的IP地址就可以了。
但是,如果这个负载组件也挂了呢?客户端就无法连接到任意一台MQ服务器了。所以负载软件本身也要做一个集群。新的问题来了,如果有两台负载软件,客户端该连那个?
负载之上再负载?这个时候我们需要换个思路。
我们需要这样一个组件:
1、它本身有路由负载功能,可以监控集群中节点的状态(比如监控HAProxy),如果某个节点出现异常或者发生故障,就把它剔除掉。
2、为了提高可用性,它也可以部署多个服务,但是只有一个自动选举出来的Master服务器(叫做主路由器),通过广播心跳实现。
3、Master服务器对外提供一个虚拟IP,提供各种网络功能。也就是谁抢占到IP,就由谁对外提供网络服务。应用端只需要连接到这个IP就行了。
这个协议叫做VRRP协议(虚拟路由冗余协议Virtual Router Redundancy Protocol),这个组件就是Keepalived,它具有Load Balance 和 High AVailability的功能。