本文已經收錄到Github倉庫,該倉庫包含計算機基礎、Java基礎、多執行緒、JVM、資料庫、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分散式、微服務、設計模式、架構、校招社招分享等核心知識點,歡迎star~
Github地址:https://github.com/Tyson0314/Java-learning
什麼是RabbitMQ?
RabbitMQ是一個由erlang開發的訊息佇列。訊息佇列用於應用間的非同步協作。
RabbitMQ的元件
Message:由訊息頭和訊息體組成。訊息體是不透明的,而訊息頭則由一系列的可選屬性組成,這些屬性包括routing-key、priority、delivery-mode(是否永續性儲存)等。
Publisher:訊息的生產者。
Exchange:接收訊息並將訊息路由到一個或多個Queue。default exchange 是預設的直連交換機,名字為空字串,每個新建佇列都會自動繫結到預設交換機上,繫結的路由鍵名稱與佇列名稱相同。
Binding:透過Binding將Exchange和Queue關聯,這樣Exchange就知道將訊息路由到哪個Queue中。
Queue:儲存訊息,佇列的特性是先進先出。一個訊息可分發到一個或多個佇列。
Virtual host:每個 vhost 本質上就是一個 mini 版的 RabbitMQ 伺服器,擁有自己的佇列、交換器、繫結和許可權機制。vhost 是 AMQP 概念的基礎,必須在連線時指定,RabbitMQ 預設的 vhost 是 / 。當多個不同的使用者使用同一個RabbitMQ server提供的服務時,可以劃分出多個vhost,每個使用者在自己的vhost建立exchange和queue。
Broker:訊息佇列伺服器實體。
什麼時候使用MQ
對於一些不需要立即生效的操作,可以拆分出來,非同步執行,使用訊息佇列實現。
以常見的訂單系統為例,使用者點選下單按鈕之後的業務邏輯可能包括:扣減庫存、生成相應單據、發簡訊通知。這種場景下就可以用 MQ 。將簡訊通知放到 MQ 非同步執行,在下單的主流程(比如扣減庫存、生成相應單據)完成之後傳送一條訊息到 MQ, 讓主流程快速完結,而由另外的執行緒消費MQ的訊息。
RabbitMQ的優缺點
缺點:使用erlang實現,不利於二次開發和維護;效能較kafka差,持久化訊息和ACK確認的情況下生產和消費訊息單機吞吐量大約在1-2萬左右,kafka單機吞吐量在十萬級別。
優點:有管理介面,方便使用;可靠性高;功能豐富,支援訊息持久化、訊息確認機制、多種訊息分發機制。
RabbitMQ 有哪些重要的角色?
RabbitMQ 中重要的角色有:生產者、消費者和代理。
- 生產者:訊息的建立者,負責建立和推送資料到訊息伺服器;
- 消費者:訊息的接收方,用於處理資料和確認訊息;
- 代理:就是 RabbitMQ 本身,用於扮演“快遞”的角色,本身不生產訊息,只是扮演“快遞”的角色。
Exchange 型別
Exchange分發訊息時根據型別的不同分發策略不同,目前共四種型別:direct、fanout、topic、headers 。headers 模式根據訊息的headers進行路由,此外 headers 交換器和 direct 交換器完全一致,但效能差很多。
Exchange規則。
型別名稱 | 型別描述 |
---|---|
fanout | 把所有傳送到該Exchange的訊息路由到所有與它繫結的Queue中 |
direct | Routing Key==Binding Key |
topic | 模糊匹配 |
headers | Exchange不依賴於routing key與binding key的匹配規則來路由訊息,而是根據傳送的訊息內容中的header屬性進行匹配。 |
direct
direct交換機會將訊息路由到binding key 和 routing key完全匹配的佇列中。它是完全匹配、單播的模式。
fanout
所有發到 fanout 型別交換機的訊息都會路由到所有與該交換機繫結的佇列上去。fanout 型別轉發訊息是最快的。
topic
topic交換機使用routing key和binding key進行模糊匹配,匹配成功則將訊息傳送到相應的佇列。routing key和binding key都是句點號“. ”分隔的字串,binding key中可以存在兩種特殊字元“*”與“##”,用於做模糊匹配,其中“*”用於匹配一個單詞,“##”用於匹配多個單詞。
headers
headers交換機是根據傳送的訊息內容中的headers屬性進行路由的。在繫結Queue與Exchange時指定一組鍵值對;當訊息傳送到Exchange時,RabbitMQ會取到該訊息的headers(也是一個鍵值對的形式),對比其中的鍵值對是否完全匹配Queue與Exchange繫結時指定的鍵值對;如果完全匹配則訊息會路由到該Queue,否則不會路由到該Queue。
訊息丟失
訊息丟失場景:生產者生產訊息到RabbitMQ Server訊息丟失、RabbitMQ Server儲存的訊息丟失和RabbitMQ Server到消費者訊息丟失。
訊息丟失從三個方面來解決:生產者確認機制、消費者手動確認訊息和持久化。
生產者確認機制
生產者傳送訊息到佇列,無法確保傳送的訊息成功的到達server。
解決方法:
- 事務機制。在一條訊息傳送之後會使傳送端阻塞,等待RabbitMQ的回應,之後才能繼續傳送下一條訊息。效能差。
- 開啟生產者確認機制,只要訊息成功傳送到交換機之後,RabbitMQ就會傳送一個ack給生產者(即使訊息沒有Queue接收,也會傳送ack)。如果訊息沒有成功傳送到交換機,就會傳送一條nack訊息,提示傳送失敗。
在 Springboot 是透過 publisher-confirms 引數來設定 confirm 模式:
spring:
rabbitmq:
##開啟 confirm 確認機制
publisher-confirms: true
在生產端提供一個回撥方法,當服務端確認了一條或者多條訊息後,生產者會回撥這個方法,根據具體的結果對訊息進行後續處理,比如重新傳送、記錄日誌等。
// 訊息是否成功傳送到Exchange
final RabbitTemplate.ConfirmCallback confirmCallback = (CorrelationData correlationData, boolean ack, String cause) -> {
log.info("correlationData: " + correlationData);
log.info("ack: " + ack);
if(!ack) {
log.info("異常處理....");
}
};
rabbitTemplate.setConfirmCallback(confirmCallback);
路由不可達訊息
生產者確認機制只確保訊息正確到達交換機,對於從交換機路由到Queue失敗的訊息,會被丟棄掉,導致訊息丟失。
對於不可路由的訊息,有兩種處理方式:Return訊息機制和備份交換機。
Return訊息機制
Return訊息機制提供了回撥函式 ReturnCallback,當訊息從交換機路由到Queue失敗才會回撥這個方法。需要將mandatory
設定為 true
,才能監聽到路由不可達的訊息。
spring:
rabbitmq:
##觸發ReturnCallback必須設定mandatory=true, 否則Exchange沒有找到Queue就會丟棄掉訊息, 而不會觸發ReturnCallback
template.mandatory: true
透過 ReturnCallback 監聽路由不可達訊息。
final RabbitTemplate.ReturnCallback returnCallback = (Message message, int replyCode, String replyText, String exchange, String routingKey) ->
log.info("return exchange: " + exchange + ", routingKey: "
+ routingKey + ", replyCode: " + replyCode + ", replyText: " + replyText);
rabbitTemplate.setReturnCallback(returnCallback);
當訊息從交換機路由到Queue失敗時,會返回 return exchange: , routingKey: MAIL, replyCode: 312, replyText: NO_ROUTE
。
備份交換機
備份交換機alternate-exchange 是一個普通的exchange,當你傳送訊息到對應的exchange時,沒有匹配到queue,就會自動轉移到備份交換機對應的queue,這樣訊息就不會丟失。
消費者手動訊息確認
有可能消費者收到訊息還沒來得及處理MQ服務就當機了,導致訊息丟失。因為訊息者預設採用自動ack,一旦消費者收到訊息後會通知MQ Server這條訊息已經處理好了,MQ 就會移除這條訊息。
解決方法:消費者設定為手動確認訊息。消費者處理完邏輯之後再給broker回覆ack,表示訊息已經成功消費,可以從broker中刪除。當訊息者消費失敗的時候,給broker回覆nack,根據配置決定重新入隊還是從broker移除,或者進入死信佇列。只要沒收到消費者的 acknowledgment,broker 就會一直儲存著這條訊息,但不會 requeue,也不會分配給其他 消費者。
消費者設定手動ack:
##設定消費端手動 ack
spring.rabbitmq.listener.simple.acknowledge-mode=manual
訊息處理完,手動確認:
@RabbitListener(queues = RabbitMqConfig.MAIL_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
long deliveryTag = message.getMessageProperties().getDeliveryTag();
//手工ack;第二個引數是multiple,設定為true,表示deliveryTag序列號之前(包括自身)的訊息都已經收到,設為false則表示收到一條訊息
channel.basicAck(deliveryTag, true);
System.out.println("mail listener receive: " + new String(message.getBody()));
}
當訊息消費失敗時,消費端給broker回覆nack,如果consumer設定了requeue為false,則nack後broker會刪除訊息或者進入死信佇列,否則訊息會重新入隊。
持久化
如果RabbitMQ服務異常導致重啟,將會導致訊息丟失。RabbitMQ提供了持久化的機制,將記憶體中的訊息持久化到硬碟上,即使重啟RabbitMQ,訊息也不會丟失。
訊息持久化需要滿足以下條件:
- 訊息設定持久化。釋出訊息前,設定投遞模式delivery mode為2,表示訊息需要持久化。
- Queue設定持久化。
- 交換機設定持久化。
當釋出一條訊息到交換機上時,Rabbit會先把訊息寫入持久化日誌,然後才向生產者傳送響應。一旦從佇列中消費了一條訊息的話並且做了確認,RabbitMQ會在持久化日誌中移除這條訊息。在消費訊息前,如果RabbitMQ重啟的話,伺服器會自動重建交換機和佇列,載入持久化日誌中的訊息到相應的佇列或者交換機上,保證訊息不會丟失。
映象佇列
當MQ發生故障時,會導致服務不可用。引入RabbitMQ的映象佇列機制,將queue映象到叢集中其他的節點之上。如果叢集中的一個節點失效了,能自動地切換到映象中的另一個節點以保證服務的可用性。
通常每一個映象佇列都包含一個master和多個slave,分別對應於不同的節點。傳送到映象佇列的所有訊息總是被直接傳送到master和所有的slave之上。除了publish外所有動作都只會向master傳送,然後由master將命令執行的結果廣播給slave,從映象佇列中的消費操作實際上是在master上執行的。
訊息重複消費怎麼處理?
訊息重複的原因有兩個:1.生產時訊息重複,2.消費時訊息重複。
生產者傳送訊息給MQ,在MQ確認的時候出現了網路波動,生產者沒有收到確認,這時候生產者就會重新傳送這條訊息,導致MQ會接收到重複訊息。
消費者消費成功後,給MQ確認的時候出現了網路波動,MQ沒有接收到確認,為了保證訊息不丟失,MQ就會繼續給消費者投遞之前的訊息。這時候消費者就接收到了兩條一樣的訊息。由於重複訊息是由於網路原因造成的,無法避免。
解決方法:傳送訊息時讓每個訊息攜帶一個全域性的唯一ID,在消費訊息時先判斷訊息是否已經被消費過,保證訊息消費邏輯的冪等性。具體消費過程為:
- 消費者獲取到訊息後先根據id去查詢redis/db是否存在該訊息
- 如果不存在,則正常消費,消費完畢後寫入redis/db
- 如果存在,則證明訊息被消費過,直接丟棄
消費端怎麼進行限流?
當 RabbitMQ 伺服器積壓大量訊息時,佇列裡的訊息會大量湧入消費端,可能導致消費端伺服器奔潰。這種情況下需要對消費端限流。
Spring RabbitMQ 提供引數 prefetch 可以設定單個請求處理的訊息個數。如果消費者同時處理的訊息到達最大值的時候,則該消費者會阻塞,不會消費新的訊息,直到有訊息 ack 才會消費新的訊息。
開啟消費端限流:
##在單個請求中處理的訊息個數,unack的最大數量
spring.rabbitmq.listener.simple.prefetch=2
原生 RabbitMQ 還提供 prefetchSize 和 global 兩個引數。Spring RabbitMQ沒有這兩個引數。
//單條訊息大小限制,0代表不限制
//global:限制限流功能是channel級別的還是consumer級別。當設定為false,consumer級別,限流功能生效,設定為true沒有了限流功能,因為channel級別尚未實現。
void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException;
什麼是死信佇列?
消費失敗的訊息存放的佇列。
訊息消費失敗的原因:
- 訊息被拒絕並且訊息沒有重新入隊(requeue=false)
- 訊息超時未消費
- 達到最大佇列長度
設定死信佇列的 exchange 和 queue,然後進行繫結:
@Bean
public DirectExchange dlxExchange() {
return new DirectExchange(RabbitMqConfig.DLX_EXCHANGE);
}
@Bean
public Queue dlxQueue() {
return new Queue(RabbitMqConfig.DLX_QUEUE, true);
}
@Bean
public Binding bindingDeadExchange(Queue dlxQueue, DirectExchange deadExchange) {
return BindingBuilder.bind(dlxQueue).to(deadExchange).with(RabbitMqConfig.DLX_QUEUE);
}
在普通佇列加上兩個引數,繫結普通佇列到死信佇列。當訊息消費失敗時,訊息會被路由到死信佇列。
@Bean
public Queue sendSmsQueue() {
Map<String,Object> arguments = new HashMap<>(2);
// 繫結該佇列到私信交換機
arguments.put("x-dead-letter-exchange", RabbitMqConfig.DLX_EXCHANGE);
arguments.put("x-dead-letter-routing-key", RabbitMqConfig.DLX_QUEUE);
return new Queue(RabbitMqConfig.MAIL_QUEUE, true, false, false, arguments);
}
生產者完整程式碼:
@Component
@Slf4j
public class MQProducer {
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RandomUtil randomUtil;
@Autowired
UserService userService;
final RabbitTemplate.ConfirmCallback confirmCallback = (CorrelationData correlationData, boolean ack, String cause) -> {
log.info("correlationData: " + correlationData);
log.info("ack: " + ack);
if(!ack) {
log.info("異常處理....");
}
};
final RabbitTemplate.ReturnCallback returnCallback = (Message message, int replyCode, String replyText, String exchange, String routingKey) ->
log.info("return exchange: " + exchange + ", routingKey: "
+ routingKey + ", replyCode: " + replyCode + ", replyText: " + replyText);
public void sendMail(String mail) {
//貌似執行緒不安全 範圍100000 - 999999
Integer random = randomUtil.nextInt(100000, 999999);
Map<String, String> map = new HashMap<>(2);
String code = random.toString();
map.put("mail", mail);
map.put("code", code);
MessageProperties mp = new MessageProperties();
//在生產環境中這裡不用Message,而是使用 fastJson 等工具將物件轉換為 json 格式傳送
Message msg = new Message("tyson".getBytes(), mp);
msg.getMessageProperties().setExpiration("3000");
//如果消費端要設定為手工 ACK ,那麼生產端傳送訊息的時候一定傳送 correlationData ,並且全域性唯一,用以唯一標識訊息。
CorrelationData correlationData = new CorrelationData("1234567890"+new Date());
rabbitTemplate.setMandatory(true);
rabbitTemplate.setConfirmCallback(confirmCallback);
rabbitTemplate.setReturnCallback(returnCallback);
rabbitTemplate.convertAndSend(RabbitMqConfig.MAIL_QUEUE, msg, correlationData);
//存入redis
userService.updateMailSendState(mail, code, MailConfig.MAIL_STATE_WAIT);
}
}
消費者完整程式碼:
@Slf4j
@Component
public class DeadListener {
@RabbitListener(queues = RabbitMqConfig.DLX_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
long deliveryTag = message.getMessageProperties().getDeliveryTag();
//手工ack
channel.basicAck(deliveryTag,false);
System.out.println("receive--1: " + new String(message.getBody()));
}
}
當普通佇列中有死信時,RabbitMQ 就會自動的將這個訊息重新發布到設定的死信交換機去,然後被路由到死信佇列。可以監聽死信佇列中的訊息做相應的處理。
說說pull模式
pull模式主要是透過channel.basicGet方法來獲取訊息,示例程式碼如下:
GetResponse response = channel.basicGet(QUEUE_NAME, false);
System.out.println(new String(response.getBody()));
channel.basicAck(response.getEnvelope().getDeliveryTag(),false);
怎麼設定訊息的過期時間?
在生產端傳送訊息的時候可以給訊息設定過期時間,單位為毫秒(ms)
Message msg = new Message("tyson".getBytes(), mp);
msg.getMessageProperties().setExpiration("3000");
也可以在建立佇列的時候指定佇列的ttl,從訊息入佇列開始計算,超過該時間的訊息將會被移除。
參考連結
最後給大家分享一個Github倉庫,上面有大彬整理的300多本經典的計算機書籍PDF,包括C語言、C++、Java、Python、前端、資料庫、作業系統、計算機網路、資料結構和演算法、機器學習、程式設計人生等,可以star一下,下次找書直接在上面搜尋,倉庫持續更新中~