關於MQ消費者的冪等性問題,在於MQ的重試機制,因爲網絡原因或客戶端延遲消費導致重複消費。使用MQ重試機制需要注意的事項以及如何解決消費者冪等性問題以下將逐一講解。
1. RabbitMQ自動重試機制
消費者在消費消息的時候,如果消費者業務邏輯出現程序異常,這個時候我們如何處理?
使用重試機制,RabbitMQ默認開啓重試機制。
實現原理:
@RabbitHandler註解 底層使用Aop攔截,如果程序(消費者)沒有拋出異常,自動提交事務
如果Aop使用異常通知攔截獲取到異常後,自動實現補償機制,消息緩存在RabbitMQ服務器端
注意:
默認會一直重試到消費者不拋異常爲止,這樣顯然不好。我們需要修改重試機制策略,如間隔3s重試一次)
配置:
spring:
rabbitmq:
# 連接地址
host: 127.0.0.1
# 端口號
port: 5672
# 賬號
username: guest
# 密碼
password: guest
# 地址(類似於數據庫的概念)
virtual-host: /admin_vhost
# 消費者監聽相關配置
listener:
simple:
retry:
# 開啓消費者(程序出現異常)重試機制,默認開啓並一直重試
enabled: true
# 最大重試次數
max-attempts: 5
# 重試間隔時間(毫秒)
initial-interval: 3000
2. 如何合理選擇重試機制?
情況1: 消費者獲取到消息後,調用第三方接口,但接口暫時無法訪問,是否需要重試? 需要重試,可能是因爲網絡原因短暫不能訪問
情況2: 消費者獲取到消息後,拋出數據轉換異常,是否需要重試? 不需要重試,因爲屬於程序bug需要重新發布版本
總結:對於情況2,如果消費者代碼拋出異常是需要發佈新版本才能解決的問題,那麼不需要重試,重試也無濟於事。應該採用日誌記錄+定時任務job進行健康檢查+人工進行補償
3. 調用第三方接口自動實現補償機制
我們知道了,RabbitMQ在消費者消費發生異常時,會自動進行補償機制,所以我們(消費者)在調用第三方接口時,可以根據返回結果判斷是否成功:
成功:正常消費
失敗:手動拋處一個異常,這時RabbitMQ自動給我們做重試 (補償)。
4. 如何解決消費者冪等性問題,防止重複消費 (MQ重試機制需要注意的問題)
產生原因:網絡延遲傳輸中,消費者出現異常或者消費者延遲消費,會造成進行MQ重試補償,在重試過程中,可能會造成重複消費。
面試題:MQ中消費者如何保證冪等性問題,不被重複消費?
僞代碼:
生產者核心代碼:
請求頭設置消息id(messageId)
@Component
public class FanoutProducer {
@Autowired
private AmqpTemplate amqpTemplate;
public void send(String queueName) {
String msg = "my_fanout_msg:" + System.currentTimeMillis();
//請求頭設置消息id(messageId)
Message message = MessageBuilder.withBody(msg.getBytes()).setContentType(MessageProperties.CONTENT_TYPE_JSON)
.setContentEncoding("utf-8").setMessageId(UUID.randomUUID() + "").build();
System.out.println(msg + ":" + msg);
amqpTemplate.convertAndSend(queueName, message);
}
}
消費者核心代碼:
@RabbitListener(queues = "fanout_email_queue")
public void process(Message message) throws Exception {
// 獲取消息Id
String messageId = message.getMessageProperties().getMessageId();
String msg = new String(message.getBody(), "UTF-8");
//② 判斷唯一Id是否被消費,消息消費成功後將id和狀態保存在日誌表中,我們從(①步驟)表中獲取並判斷messageId的狀態即可
//從redis中獲取messageId的value
String value = redisUtils.get(messageId)+"";
if(value.equals("1") ){ //表示已經消費
return; //結束
}
System.out.println("郵件消費者獲取生產者消息" + "messageId:" + messageId + ",消息內容:" + msg);
JSONObject jsonObject = JSONObject.parseObject(msg);
// 獲取email參數
String email = jsonObject.getString("email");
// 請求地址
String emailUrl = "http://127.0.0.1:8083/sendEmail?email=" + email;
JSONObject result = HttpClientUtils.httpGet(emailUrl);
if (result == null) {
// 因爲網絡原因,造成無法訪問,繼續重試
throw new Exception("調用接口失敗!");
}
System.out.println("執行結束....");
//① 執行到這裏已經消費成功,我們可以修改messageId的狀態,並存入日誌表(可以存到redis中,key爲消息Id、value爲狀態)
}
5. SpringBoot整合RabbitMQ應答模式(ACK)
1.修改配置simple下添加 acknowledge-mode: manual:
spring:
rabbitmq:
# 連接地址
host: 127.0.0.1
# 端口號
port: 5672
# 賬號
username: guest
# 密碼
password: guest
# 地址(類似於數據庫的概念)
virtual-host: /admin_vhost
# 消費者監聽相關配置
listener:
simple:
retry:
# 開啓消費者(程序出現異常)重試機制,默認開啓並一直重試
enabled: true
# 最大重試次數
max-attempts: 5
# 重試間隔時間(毫秒)
initial-interval: 3000
# 開啓手動ack
acknowledge-mode: manual
2.消費者增加代碼:
Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG); 手動ack
channel.basicAck(deliveryTag, false);手動簽收
//郵件隊列
@Component
public class FanoutEamilConsumer {
@RabbitListener(queues = "fanout_email_queue")
public void process(Message message, @Headers Map<String, Object> headers, Channel channel) throws Exception {
System.out
.println(Thread.currentThread().getName() + ",郵件消費者獲取生產者消息msg:" + new String(message.getBody(), "UTF-8")
+ ",messageId:" + message.getMessageProperties().getMessageId());
// 手動ack
Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
// 手動簽收
channel.basicAck(deliveryTag, false);
}
}