問題
假設報備服務和積分服務之間是用mq消息進行交互,經紀人報備後,報備服務發出積分加5的mq消息,積分服務接到mq之後就會給經紀人帳號加5積分。
如果積分服務多次接收到同一個消息,就會執行多次積分加5的動作,導致經紀人的積分多加了
解決問題的大方向
1、接收到mq消息後,根據消息id查詢是否已經消費過該消息。如果消費過該消息,轉步驟5;如果沒有消費過,轉步驟2
2、保存mq消費記錄,保存成功,轉步驟3,保存失敗,轉步驟5(多消費者的情況下,保證mq消息也只被消費一次!!!)
3、執行正常的mq消費邏輯,消費成功,轉步驟5,消費失敗,轉步驟4
4、消費失敗,刪除mq消費記錄,nack消息並requeue,結束(確保mq消息下次到達時,可以被消費)
5、消費成功,ack確認mq消息,結束
流程圖如下:
mq-platform-consumer要解決的問題
如何拿到消息id
彎路:
一開始想到的是,用AOP對加了@RabbitListener的註解的方法添加一個arround類型的增強。但現實給了我一巴掌,使用@RabbitListener註解的方法的入參,不強制要求是org.springframework.amqp.core,Message,看了源碼,在MessagingMessageListenerAdapter中,會將Message的消息體做轉換,匹配@RabbitListener方法的入參,所以不強制要求參數類型。
想着,對@RabbitListener方法加AOP行不通,因爲沒有Message參數,那如果,對@RabbitListener方法的調用者用AOP,是不是就可以呢?!實際上也行不通,看了源碼,那些類是手動new出來的,不歸spring管理。。。。。。
還有另外一個更嚴重的問題,用自己定義的MessageListenerContainer的bean來消費消息,根本就不會涉及到@RabbitListener註解。
所以,用aop針對@RabbitListener做增強來實現冪等的方案根本無法實現
正確的道路:
看spring集成rabbitMq源碼的時候,發現幾點:
1、監聽mq消息,是用SimpleRabbitListenerContainerFactory類來產生SimpleRabbitListenerContainer對象,由SimpleRabbitListenerContainer對象來new消費者進行消息監聽
2、SimpleRabbitListenerContainerFactory有一個屬性adviceChain,類似一個責任鏈,在創建SimpleRabbitListenerContainer對象的時候,會把adviceChain的值設置給SimpleRabbitListenerContainer對象。SimpleRabbitListenerContainer監聽到消息的時候,mq消息先經過adviceChain的所有攔截,纔會到達@RabbitListener註解的方法
3、RabbitAutoConfirguration是通過用BeanPostProcessor來爲@RabbitListener方法產生SimpleRabbitListenerContainer對象
利用這三個信息,我們就可以解決AOP方案遇到的問題:
1、定義一個IdempotentOperationsInterceptor類,拿到Message參數中的messageId屬性,做消費檢查
@Slf4j
public class IdempotentOperationsInterceptor extends AbstractConsumerOperationsInterceptor {
private ConsumerManager consumerManager;
public IdempotentOperationsInterceptor(ConsumerManager consumerManager) {
this.consumerManager = consumerManager;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Message message = getMessage(invocation);
if (Objects.isNull(message)) {
return invocation.proceed();
}
String messageId = getMessageId(message);
if (StringUtils.isEmpty(messageId)) {
return invocation.proceed();
}
if (!consumerManager.insertConsumeRecord(messageId)) {
return null;
}
boolean success = false;
Object result;
try {
result = invocation.proceed();
success = true;
} catch (Throwable e) {
Cat.logMetricForCount("consume_message_failed");
log.warn("fail to consume message: {}", message.toString());
throw e;
} finally {
if (!success) {
try {
// TODO 1、觸發告警;2、考慮死信機制,例如與刪除冪等記錄的同一個事務中保存mq消息
consumerManager.deleteConsumeRecord(messageId);
} catch (Throwable e) {
log.warn("fail to delete consume message: {}", message.toString());
}
}
}
return result;
}
private String getMessageId(Message message) {
return Objects.nonNull(message.getMessageProperties()) ? message.getMessageProperties().getMessageId() : null;
}
}
2、同樣使用BeanPostProcessor,把IdempotentOperationsInterceptor類的實例,設置到SimpleRabbitListenerContainerFactory對象adviceChain屬性的數組尾部(該BeanPostProcessor的優先級略高於RabbitAutoConfiguration使用的BeanPostProcessor,詳細代碼見SimpleRabbitListenerContainerFactoryBeanPostProcessor類)
3、將IdempotentOperationsInterceptor設置到SimpleRabbitListenerContainerFactory對象,可以解決加了@RabbitListener註解的方法的冪等,但是對於那些自己手動定義的MessageListenerContainer的bean,卻不起作用。這時候我們可以沿用套路,再增加一個RabbitListenerContainerBeanPostProcessor,該後置處理,將對所有類型是AbstractMessageListenerContainer的bean的adviceChain屬性做處理,將IdempotentOperationsInterceptor強制設置到adviceChain屬性上
用什麼保存消息消費記錄
在公司技術體系下,可以考慮用mysql和redis存儲消費記錄
使用mysql,需要在業務庫中引入一張表:
列名 |
意義 |
---|---|
id | 自增id |
message_id | 消息id |
application | 消息來自哪個應用,防止不同應用有相同的消息id |
create_time | 創建時間 |
update_time | 更新時間,該字段沒存在的必要,但是沒有更新時間,不給建表。。。 |
message_id+application組成一個唯一索引,利用唯一性,可以做到避免多消費者併發消費的問題
使用redis,很方便,單線程,天然能解決併發問題,而且可以設置過期時間,清理歷史數據
mq-platform-consumer中,目前是選擇用mysql。因爲關係型數據庫,支持事務!!在插入消費記錄之前開啓事務,在成功消費mq消息之後,提交事務,消費失敗則回滾事務。這樣可以避免出現mq消費失敗之後,清理消費記錄失敗,導致消息無法被消費的問題。TransactionIdempotentOperationsInterceptor類就是支持事務的冪等檢查。
目前這個方案有些缺陷,就是消費mq的業務邏輯中,如果涉及到大量的遠程調用,會造成長事務。
如果是這種情況,就要考慮使用redis了,但使用redis,就需要用上redis實現分佈式鎖,解決併發消費的問題。