本文只讨论可靠性传输相关的问题,预减库存的问题在另一篇博客:
如何实现消息的可靠性传输,已在其他两篇博文中总结了,此处不赘述
RabbitMQ实现可靠性传输 理论篇
RabbitMQ实现可靠性传输 代码篇
这里记录一下易错的,需要注意的点:
消息的唯一ID是需要手动分配的,消息的持久化也是需要额外做的。
两者都需要通过MessagePostProcessor
完成
限制重发次数交由定时任务完成。
重发需要注意的点:
超出限制后,记得移除redis中的相关key,并入库记录
重发时,消息的correlationId要注意设置为同一个,不应且不能更换。
这是保证消息幂等性的大前提。
除了交由定时任务完成,貌似有提供的接口,因为配置参数有 template.retry。但笔者不会用
而可靠性传输中,最难处理的,无疑就是消费端了。因为需要采用manual
手动确认模式,而这里要注意的点很多
先看伪代码
@RabbitHandler
@RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
public void receive(String message, Channel channel, Message messages) throws Exception {
// 消息幂等性的处理
String correlatonId = messages.getMessageProperties().getCorrelationId();
//根据correlationId判断消息是否被消费过
if (消息已经被消费过) {
return;
}
if(库存不足) {
return;
}
if (已经秒杀到) {// 重复下单
return;
}
//进行原子操作:1.库存减1,2.下订单,3.写入秒杀订单--->是一个事务
miaoshaService.miaosha(user,goodsvo);
}
manual
模式中,最关键的就是需要对消息进行回应。
因此,三个if
在return前,以及最后的原子操作后,都需要进行统一操作:
channel.basicAck
这样就完成了最初的处理。
接下来要继续考虑:原子操作中,是否会出错?答案是肯定的,最显然就是DuplicateKeyException
,因为我们在数据库层面也做了订单的唯一约束。
完全是可能出现这个情况的:比如同一用户的多个相同请求分别到了不同的消费者,在订单生成前,还没有缓存到
redis
。此时并不会被第三步的if
处理
因此这里,我们是需要对原子操作这步进行 try{} catch{}
的。
如果不进行处理,事务的确是会正常回滚。但问题是,此时是不会进行上述的同一操作的,而不进行回应,对MQ来说,该消费者就一直处于消费消息的状态,不会再分发消息,(不考虑prefetch),直到连接中断为止。即相当于该消费者无效了。
如果此时有别的消费者,那还能继续消费消息(这种情况一般都是全都没考虑,很有可能一起卡死GG ),否则的话,消息就一直不会被消费,这样服务就不可用了。
解释完try{} catch{}
的必要性,其实这里要处理的就不只是DuplicateKeyException
了,业务上,应该是把所有可能发生的异常都提前catch
起来。
但很容易忽略的是,兜底的必要性:最下层catch(Exception e)
,此时就应该try{}
整个方法了
原因也不难理解,先不说异常可能会漏,运行时发生什么奇奇怪怪的异常都是常有的事。要保证服务的可用性,就必须要保证对消息进行响应。
因此伪代码应该是:
@RabbitHandler
@RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
public void receive(String message, Channel channel, Message messages) throws Exception {
// try住整个代码,防止漏异常。如果catch需要的try中的变量,则提前声明就好了
try{
// 消息幂等性的处理
String correlatonId = messages.getMessageProperties().getCorrelationId();
//根据correlationId判断消息是否被消费过
if (消息已经被消费过) {
统一操作
return;
}
if(库存不足) {
统一操作
return;
}
if (已经秒杀到) {// 重复下单
统一操作
return;
}
//进行原子操作:1.库存减1,2.下订单,3.写入秒杀订单--->是一个事务
miaoshaService.miaosha(user,goodsvo);
}catch (DuplicateKeyException e){
统一操作
}catch (其他可能的业务异常){
这里就不一定是统一操作了,可能是Nack
}catch (Exception e){
同上,看业务处理。但必须对消息进行确认
}
}
考虑问题:能否用finally
,这应该也算资源释放的范畴吧。
笔者没有用,原因主要有两点:
首先就是不同的业务异常,不一定都是回复ack、nack
1的解决办法很简单,就是其他逻辑不变,finally 统一 nack且重回队 requeue = false
这样前面只要回应了,finally的回应就不会生效。
但考虑网络等问题,如果正常的响应丢失了,而finally的响应却被成功接收了。那就是业务异常了。这是很严重的问题。
当然了,这个也是看业务场景的
拓展两个问题:
回发的响应丢失问题。此时等同于没响应。简单的做法就是多发几次,搞个延时队列之类的 但其实这是个"死局" ,因为消费端是无法知道响应是否被接收的,并没有像生产者确认模式那样的相关接口。如果运气不好,重发的全都丢了,也没办法知道。所以笔者也没有作处理
如果其实有接口,或者有相关解决办法,欢迎告知,进行讨论。
异常的重试问题。因为有些异常可能就是短时间的问题,并不是真的有错。
如果一棍子打死,全部重回,这肯定是不可取的。因为真正的、无法被解决的异常消息会进行死循环:入队-> 分发 -> 拒绝且重回 -> 入队 ...
而全部不重回,作为死信配合死信交换机处理。这其实不失为一种做法。但对那些"假异常"就有点"不公平" (死信队列的异常越少,维护也更容易啊)
这里有更优的做法,就是进行有限次数的重试,排除网络等因素的影响。
那如何进行重试呢?这里可能第一反应想到的就是消费端自带的重试:
#启动重试
spring.rabbitmq.listener.direct.retry.enabled=true
#相关的配置
spring.rabbitmq.listener.direct.retry.max-attempts=3
这看起来是挺香的,笔者一开始也的确是这么想的。但是实操完了却发现一个很大的问题:
重试超过限制后抛出的异常,不能被catch{}
捕获。
考虑过全局异常处理器,但发现全局异常处理器只能处理throw出的异常。直接触发的异常无法处理。
而且全局异常处理时,回发消息需要的信息太多,不知如何传输。没有采用
尽力看过源码以及测试后,还是没有找到较优雅的解决办法。
因此笔者采用的是最笨的办法: (注意配置的重试还是要开的,否则抛出异常是不会触发重试。但要注意重试次数 < 设置的max-attempts ,防止触发超限异常,导致无法回发消息)
通过内存记录,Map去保存(消息id,重试次数),完成重试限制。
不使用redis,一是减轻redis的压力,二是这样实现更方便
参考代码如下:
// 成员变量: private static Map errorLogMap = new HashMap<>();
catch (Exception e) {
// 需要注意不同消息间的隔离问题,用correlationId作key
// 此时注意关闭applicaiton.properties的重试配置。因为无法捕获超限异常。
int retryTime = errorLogMap.getOrDefault(correlatonId, 0);
// 注意次数 < 设置的最大重试次数
if (retryTime < 3) {
errorLogMap.put(correlatonId, retryTime + 1);
throw e;
} else {
// 从Map中移除,防止内存溢出
errorLogMap.remove(correlatonId);
//重试超限,requeue = false。成为死信
channel.basicNack(messages.getMessageProperties().getDeliveryTag(), false, false);
入库,记录异常
}
}
关于如何正确使用自带的重试机制,记录一下笔者的收获及问题:
(debug不易)
@RabbitListener(errorHandler = "rabbitListenerErrorHandler")
通过自定义相关的RabbitListenerErrorHandler
,的确可以监听队列内抛出的异常,也可以通过相关消息获取回channel进行应答。但问题是:该异常处理器仍然无法捕获到重试超过限制抛出的AmqpRejectAndDontRequeueException
。当然了,有了这个处理器,我们就可以把代码中的
try{}catch
给提取出来。还是有意义的。记录下代码:@Bean public RabbitListenerErrorHandler rabbitListenerErrorHandler() { return new RabbitListenerErrorHandler() { @Override public Object handleError(Message amqpMessage, org.springframework.messaging.Message<?> message, ListenerExecutionFailedException exception) throws Exception { MessageHeaders messageHeaders = message.getHeaders(); // 通过debug找到的这个key字段,可以获取消费者中的channel,进而响应 Channel channel = messageHeaders.get("amqp_channel", Channel.class); channel.basicAck(amqpMessage.getMessageProperties().getDeliveryTag(), false); // 或者return null; throw exception; } }; }
配置
MessageRecoverer
,该类会在重试次数超限,还是抛出异常的情况下才会调用。通过设置为其实现类
RepublishMessageRecoverer
,完成消息的重发布。@Bean public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) { return new RepublishMessageRecoverer(rabbitTemplate, DEAD_EXCHANGE); }
经测试也确实成功了。而现在的问题是,无法获取原channel 及 原消息,即无法进行应答。
还有一个
RetryTemplate
,是在debug到最后抛出的。这个没研究懂
这部分参考博文:
消息手动确认模式的几点说明
本文完,有误欢迎指出