本人在使用RabbitMQ的过程中遇到了一些问题,主要是由于生产者事务还没完全提交,消费者代码已经开始运行引起的,下面把一步一步的解决过程分享给大家。有更好解决方式的同学欢迎评论指导一下。
问题
有个业务,用户每天获取饲料在30g时,和130g时,上报数据到合作方,同时会存一条上报记录。
某天合作方的人反馈,他的饲料获取到了30g,没有收到上报数据,于是赶紧查找问题。
查了一下发现丢失的数据占正常数据的1%。
代码解释:我们是先保存用户的饲料记录,然后队列解耦,消费者查询饲料记录,查出用户当天累计获取的饲料数,判断饲料数是否到达30和130。
一、超出TPS限制导致的通道关闭
2023-06-15 15:16:01.887 ERROR 70300 --- [.55.10.135:5672] o.s.a.r.c.CachingConnectionFactory : Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=530, reply-text=denied for too many requests, ErrorHelp[dce6ef75-0993-4361-8ec8-1ff1c951a887], class-id=60, method-id=40)
2023-06-15 15:16:01.893 ERROR 70300 --- [.55.10.135:5672] o.s.a.r.c.CachingConnectionFactory : Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=530, reply-text=denied for too many requests, ErrorHelp[ecf0f9e7-243e-49e0-b2db-5638cc8b2758], class-id=60, method-id=40)
2023-06-15 15:16:01.893 ERROR 70300 --- [.55.10.135:5672] o.s.a.r.c.CachingConnectionFactory : Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=530, reply-text=denied for too many requests, ErrorHelp[148ccb82-8d48-4833-bc6a-0fb534b42dc7], class-id=60, method-id=40)
之前有过一个秒杀场景,经常有这个报错,reply-text=denied for too many requests 直译过来就是“拒绝过多的请求”,我们买的是阿里云的RabbitMQ产品,当时询问阿里云的技术人员,回复是请求超过了产品的TPS限制,需要花钱提升TPS。
上面报错并非本次场景里找到的报错,只是发生过类似的事,于是怀疑到,可能是偶尔有TPS超出的情况,导致通道关闭,然后消息丢失。然后决定使用生产者confirm的方式来确保数据不会丢失。
参考这个,写得比较简单:link
rabbitmq 整个消息投递的路径为:producer—>exchange—>queue—>consumer
producer到exchange失败会有一个confirmCallback,exchange到queue失败会有一个returnCallback,此处我们使用confirmCallback就足够了。
定义confirmCallback
@Slf4j
@Component
public class ConfirmCallbackService implements RabbitTemplate.ConfirmCallback {
@Autowired
IAlipayGameCenterService alipayGameCenterService;
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if (ack) {
//接收成功,不做处理
} else {
//接收失败
if (correlationData != null){
ReturnedMessage returnedMessage = correlationData.getReturned();
if (returnedMessage != null){
String key = returnedMessage.getRoutingKey();
Message message = returnedMessage.getMessage();
if (message != null){
String body = new String(message.getBody(), StandardCharsets.UTF_8);
if (QueueName.GAMECENTER.equals(key)){
//执行消费者代码,此处省略部分业务代码
JSON.parseObject(body, ?.class);
}
}
}
}
}
}
}
在发送队列时使用confirmCallback
@Autowired
private ConfirmCallbackService confirmCallbackService;
public void sendMessageConfirm(String key, Object object) {
//设置回调
masterRabbitTemplate.setConfirmCallback(confirmCallbackService);
String uuid = IdUtil.randomUUID();
CorrelationData correlationData = new CorrelationData(uuid);
MessageProperties properties = new MessageProperties();
properties.setMessageId(uuid);//消息唯一ID,用力防止幂等性
Message message = new Message(JSONObject.toJSONString(object).getBytes(StandardCharsets.UTF_8), properties);
ReturnedMessage returnedMessage = new ReturnedMessage(message, 0, null, null, key);
correlationData.setReturned(returnedMessage);
masterRabbitTemplate.convertAndSend(key, message, correlationData);
}
在使用上写得比较复杂,因为在确认回调里发现CorrelationData里面消息体是null,于是在生产者里手动new了ReturnedMessage,放入CorrelationData。那些博客里不需要手动new ReturnedMessage就能自动得到消息体的,不知道是怎么做到的。
写好测完后部署上线,运行一段时间看看效果。
二、在生产者事务提交之前就已经开始消费
跑了一段时间,发现线上消息并没有消息进入confirmCallback,但依然有用户获取30g饲料没有上报数据的。重新分享了一遍代码,没看出问题所在。没办法,加了大量日志,看看哪个节点出的问题。
吐槽:我们是分布式多台服务器,但没有集中的日志服务,翻日志得一台一台找。不是迫不得已我也不想加日志
生产者
消费者
加了日志后部署上线,运行一段时间后再查数据。
然后发现生产者保存饲料记录的时间是12:00:04,消费者查出用户饲料数的时间是12:00:03.568。生产者数据库insert肯定是在放入队列之前执行的,但实际数据库的时间比消费者查出饲料数的时间还晚。
很有可能是insert执行完之后,事务还没提交,等到队列发送执行之后,整个方法结束了,再进行事务提交,数据实际落库。而消费者代码在队列发送之后就立马执行了,此时生产者事务还没提交。
三、事务提交后再执行队列
既然推论到是事务过慢的原因,那控制事务落地在发送队列前执完毕就好了。有一种手动提交事务的方式,还有一个在事务提交后再执行后续操作的方式。参考
不过里面的TransactionSynchronizationAdapter已经过时了。
我们改造生产者那里的代码
如果想复制代码,请进入参考里进行复制。
一般情况下,问题应该就此解决了。可实际测起来,问题很多。因为我们是多数据源,这里使用了一个@DSTransactional,由MybatisPLUS提供的多数据源事务注解,此事务脱离了spring提供的原生事务管理,一系列spring的事务功能在此注解下无法使用。
四、在消费者里判断生成者的数据是否插入
既然无法从事务角度解决,只能思考别的方式。我们想了两种:
- 使用延迟队列,让消费者延迟,这样执行消费代码的时候,生产者的数据已经存入mysql。
- 在消费者里判断生产者的数据是否安全落库。
因为数据丢失的情况只占1%,所以我们选择了第二种。
生产者保存饲料记录时,可以得到记录的主键id,把主键id通过队列传入消费者,消费者查询用户饲料数的时候,根据最新的饲料记录id和队列传入的记录id,可以判断查到的是否是最新数据,如果没查到最新数据,等待1秒再查一次。
思路确定了,然后再次更改代码。
生产者
消费者查询用户饲料数
测完后部署上线,运行一段时间后查询数据,确认问题成功解决。