訊息佇列是典型的:生產者、消費者模型。生產者不斷向訊息佇列中生產訊息,消費者不斷的從佇列中獲取訊息。因為訊息的生產和消費都是非同步的,而且只關心訊息的傳送和接收,沒有業務邏輯的侵入,這樣就實現了生產者和消費者的解耦。
結合前面所說的問題:
-
商品服務對商品增刪改以後,無需去操作索引庫或靜態頁面,只是傳送一條訊息,也不關心訊息被誰接收。
-
搜尋服務和靜態頁面服務接收訊息,分別去處理索引庫和靜態頁面。
如果以後有其它系統也依賴商品服務的資料,同樣監聽訊息即可,商品服務無需任何程式碼修改。
AMQP和JMS
MQ是訊息通訊的模型,併發具體實現。現在實現MQ的有兩種主流方式:AMQP、JMS。
兩者間的區別和聯絡:
-
JMS是定義了統一的介面,來對訊息操作進行統一;AMQP是通過規定協議來統一資料互動的格式
-
JMS限定了必須使用Java語言;AMQP只是協議,不規定實現方式,因此是跨語言的。
-
JMS規定了兩種訊息模型;而AMQP的訊息模型更加豐富
-
ActiveMQ:基於JMS, Apache
-
RabbitMQ:基於AMQP協議,erlang語言開發,穩定性好
-
RocketMQ:基於JMS,阿里巴巴產品,目前交由Apache基金會
-
Kafka:分散式訊息系統,高吞吐量
RabbitMQ
RabbitMQ是基於AMQP的一款訊息管理系統
官方教程:http://www.rabbitmq.com/getstarted.html
RabbitMQ基於Erlang語言開發:
下載和安裝
下載
官網下載地址:http://www.rabbitmq.com/download.html
我們使用的是:3.4.1版本
RabbitMQ提供了6種訊息模型,但是第6種其實是RPC,並不是MQ,因此不予學習。那麼也就剩下5種。
但是其實3、4、5這三種都屬於訂閱模型,只不過進行路由的方式不同。
匯入demo工程
我們通過一個demo工程來了解下RabbitMQ的工作方式:
說明
官方文件說明:
RabbitMQ是一個訊息的代理者(Message Broker):它接收訊息並且傳遞訊息。
你可以認為它是一個郵局:當你投遞郵件到一個郵箱,你很肯定郵遞員會終究會將郵件遞交給你的收件人。與此類似,RabbitMQ 可以是一個郵箱、郵局、同時還有郵遞員。
不同之處在於:RabbitMQ不是傳遞紙質郵件,而是二進位制的資料
基本訊息模型圖:
在上圖的模型中,有以下概念:
-
P:生產者,也就是要傳送訊息的程式
-
C:消費者:訊息的接受者,會一直等待訊息到來。
-
queue:訊息佇列,圖中紅色部分。類似一個郵箱,可以快取訊息;生產者向其中投遞訊息,消費者從其中取出訊息。
生產者
連線工具類:
public class ConnectionUtil { /** * 建立與RabbitMQ的連線 * @return * @throws Exception */ public static Connection getConnection() throws Exception { //定義連線工廠 ConnectionFactory factory = new ConnectionFactory(); //設定服務地址 factory.setHost("192.168.56.101"); //埠 factory.setPort(5672); //設定賬號資訊,使用者名稱、密碼、vhost factory.setVirtualHost("/leyou"); factory.setUsername("leyou"); factory.setPassword("leyou"); // 通過工程獲取連線 Connection connection = factory.newConnection(); return connection; } }
生產者傳送訊息:
public class Send { private final static String QUEUE_NAME = "simple_queue"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 從連線中建立通道,使用通道才能完成訊息相關的操作 Channel channel = connection.createChannel(); // 宣告(建立)佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 訊息內容 String message = "Hello World!"; // 向指定的佇列中傳送訊息 channel.basicPublish("", QUEUE_NAME, null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); //關閉通道和連線 channel.close(); connection.close(); } }
控制檯:
web控制檯檢視訊息
進入佇列頁面,可以看到新建了一個佇列:simple_queue
點選佇列名稱,進入詳情頁,可以檢視訊息:
在控制檯檢視訊息並不會將訊息消費,所以訊息還在。
消費者獲取訊息
public class Recv { private final static String QUEUE_NAME = "simple_queue"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 建立通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [x] received : " + msg + "!"); } }; // 監聽佇列,第二個引數:是否自動進行訊息確認。 channel.basicConsume(QUEUE_NAME, true, consumer); } }
控制檯:
這個時候,佇列中的訊息就沒了:
消費者的訊息確認機制
通過剛才的案例可以看出,訊息一旦被消費者接收,佇列中的訊息就會被刪除。
那麼問題來了:RabbitMQ怎麼知道訊息被接收了呢?
這就要通過訊息確認機制(Acknowlege)來實現了。當消費者獲取訊息後,會向RabbitMQ傳送回執ACK,告知訊息已經被接收。不過這種回執ACK分兩種情況:
-
自動ACK:訊息一旦被接收,消費者自動傳送ACK
-
手動ACK:訊息接收後,不會傳送ACK,需要手動呼叫
大家覺得哪種更好呢?
這需要看訊息的重要性:
-
如果訊息不太重要,丟失也沒有影響,那麼自動ACK會比較方便
-
如果訊息非常重要,不容丟失。那麼最好在消費完成後手動ACK,否則接收訊息後就自動ACK,RabbitMQ就會把訊息從佇列中刪除。如果此時消費者當機,那麼訊息就丟失了。
我們之前的測試都是自動ACK的,如果要手動ACK,需要改動我們的程式碼:
public class Recv2 { private final static String QUEUE_NAME = "simple_queue"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 建立通道 final Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [x] received : " + msg + "!"); // 手動進行ACK channel.basicAck(envelope.getDeliveryTag(), false); } }; // 監聽佇列,第二個引數false,手動進行ACK channel.basicConsume(QUEUE_NAME, false, consumer); } }
注意到最後一行程式碼:
channel.basicConsume(QUEUE_NAME, false, consumer);
如果第二個引數為true,則會自動進行ACK;如果為false,則需要手動ACK。方法的宣告:
說明
在剛才的基本模型中,一個生產者,一個消費者,生產的訊息直接被消費者消費。比較簡單。
Work queues,也被稱為(Task queues),任務模型。
當訊息處理比較耗時的時候,可能生產訊息的速度會遠遠大於訊息的消費速度。長此以往,訊息就會堆積越來越多,無法及時處理。此時就可以使用work 模型:讓多個消費者繫結到一個佇列,共同消費佇列中的訊息。佇列中的訊息一旦消費,就會消失,因此任務是不會被重複執行的。
角色:
-
P:生產者:任務的釋出者
-
C1:消費者,領取任務並且完成任務,假設完成速度較慢
-
C2:消費者2:領取任務並完成任務,假設完成速度快
生產者
生產者與案例1中的幾乎一樣:
public class Send { private final static String QUEUE_NAME = "test_work_queue"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 迴圈釋出任務 for (int i = 0; i < 50; i++) { // 訊息內容 String message = "task .. " + i; channel.basicPublish("", QUEUE_NAME, null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); Thread.sleep(i * 2); } // 關閉通道和連線 channel.close(); connection.close(); } }
不過這裡我們是迴圈傳送50條訊息。
與消費者1基本類似,就是沒有設定消費耗時時間。
這裡是模擬有些消費者快,有些比較慢。
接下來,兩個消費者一同啟動,然後傳送50條訊息:
可以發現,兩個消費者各自消費了25條訊息,而且各不相同,這就實現了任務的分發。
能者多勞
剛才的實現有問題嗎?
-
消費者1比消費者2的效率要低,一次任務的耗時較長
-
然而兩人最終消費的訊息數量是一樣的
-
消費者2大量時間處於空閒狀態,消費者1一直忙碌
現在的狀態屬於是把任務平均分配,正確的做法應該是消費越快的人,消費的越多。
怎麼實現呢?
我們可以修改設定,讓消費者同一時間只接收一條訊息,這樣處理完成之前,就不會接收更多訊息,就可以讓處理快的人,接收更多訊息 :
再次測試:
訂閱模型分類
訂閱模型示意圖:
前面2個案例中,只有3個角色:
-
P:生產者,也就是要傳送訊息的程式
-
C:消費者:訊息的接受者,會一直等待訊息到來。
-
queue:訊息佇列,圖中紅色部分。類似一個郵箱,可以快取訊息;生產者向其中投遞訊息,消費者從其中取出訊息。
而在訂閱模型中,多了一個exchange角色,而且過程略有變化:
-
P:生產者,也就是要傳送訊息的程式,但是不再傳送到佇列中,而是發給X(交換機)
-
C:消費者,訊息的接受者,會一直等待訊息到來。
-
Queue:訊息佇列,接收訊息、快取訊息。
-
Exchange:交換機,圖中的X。一方面,接收生產者傳送的訊息。另一方面,知道如何處理訊息,例如遞交給某個特別佇列、遞交給所有佇列、或是將訊息丟棄。到底如何操作,取決於Exchange的型別。Exchange有以下3種型別:
-
Fanout:廣播,將訊息交給所有繫結到交換機的佇列
-
Direct:定向,把訊息交給符合指定routing key 的佇列
-
Topic:萬用字元,把訊息交給符合routing pattern(路由模式) 的佇列
-
Exchange(交換機)只負責轉發訊息,不具備儲存訊息的能力,因此如果沒有任何佇列與Exchange繫結,或者沒有符合路由規則的佇列,那麼訊息會丟失!
Fanout,也稱為廣播。
流程說明
流程圖:
在廣播模式下,訊息傳送流程是這樣的:
-
1) 可以有多個消費者
-
2) 每個消費者有自己的queue(佇列)
-
3) 每個佇列都要繫結到Exchange(交換機)
-
4) 生產者傳送的訊息,只能傳送到交換機,交換機來決定要發給哪個佇列,生產者無法決定。
-
5) 交換機把訊息傳送給繫結過的所有佇列
-
6) 佇列的消費者都能拿到訊息。實現一條訊息被多個消費者消費
生產者
兩個變化:
-
1) 宣告Exchange,不再宣告Queue
-
2) 傳送訊息到Exchange,不再傳送到Queue
public class Send { private final static String EXCHANGE_NAME = "fanout_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告exchange,指定型別為fanout channel.exchangeDeclare(EXCHANGE_NAME, "fanout"); // 訊息內容 String message = "Hello everyone"; // 釋出訊息到Exchange channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes()); System.out.println(" [生產者] Sent '" + message + "'"); channel.close(); connection.close(); } }
public class Recv { private final static String QUEUE_NAME = "fanout_exchange_queue_1"; private final static String EXCHANGE_NAME = "fanout_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機 channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, ""); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者1] received : " + msg + "!"); } }; // 監聽佇列,自動返回完成 channel.basicConsume(QUEUE_NAME, true, consumer); } }
要注意程式碼中:佇列需要和交換機繫結
消費者2
public class Recv2 { private final static String QUEUE_NAME = "fanout_exchange_queue_2"; private final static String EXCHANGE_NAME = "fanout_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機 channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, ""); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者2] received : " + msg + "!"); } }; // 監聽佇列,手動返回完成 channel.basicConsume(QUEUE_NAME, true, consumer); } }
測試
說明
在Fanout模式中,一條訊息,會被所有訂閱的佇列都消費。但是,在某些場景下,我們希望不同的訊息被不同的佇列消費。這時就要用到Direct型別的Exchange。
在Direct模型下:
-
佇列與交換機的繫結,不能是任意繫結了,而是要指定一個
RoutingKey
(路由key) -
訊息的傳送方在 向 Exchange傳送訊息時,也必須指定訊息的
RoutingKey
。 -
Exchange不再把訊息交給每一個繫結的佇列,而是根據訊息的
Routing Key
進行判斷,只有佇列的Routingkey
與訊息的Routing key
完全一致,才會接收到訊息
流程圖:
圖解:
-
P:生產者,向Exchange傳送訊息,傳送訊息時,會指定一個routing key。
-
X:Exchange(交換機),接收生產者的訊息,然後把訊息遞交給 與routing key完全匹配的佇列
-
C1:消費者,其所在佇列指定了需要routing key 為 error 的訊息
-
C2:消費者,其所在佇列指定了需要routing key 為 info、error、warning 的訊息
生產者
此處我們模擬商品的增刪改,傳送訊息的RoutingKey分別是:insert、update、delete
public class Send { private final static String EXCHANGE_NAME = "direct_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告exchange,指定型別為direct channel.exchangeDeclare(EXCHANGE_NAME, "direct"); // 訊息內容 String message = "商品新增了, id = 1001"; // 傳送訊息,並且指定routing key 為:insert ,代表新增商品 channel.basicPublish(EXCHANGE_NAME, "insert", null, message.getBytes()); System.out.println(" [商品服務:] Sent '" + message + "'"); channel.close(); connection.close(); } }
消費者1
public class Recv { private final static String QUEUE_NAME = "direct_exchange_queue_1"; private final static String EXCHANGE_NAME = "direct_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機,同時指定需要訂閱的routing key。假設此處需要update和delete訊息 channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "update"); channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "delete"); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者1] received : " + msg + "!"); } }; // 監聽佇列,自動ACK channel.basicConsume(QUEUE_NAME, true, consumer); } }
消費者2
public class Recv2 { private final static String QUEUE_NAME = "direct_exchange_queue_2"; private final static String EXCHANGE_NAME = "direct_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機,同時指定需要訂閱的routing key。訂閱 insert、update、delete channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "insert"); channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "update"); channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "delete"); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者2] received : " + msg + "!"); } }; // 監聽佇列,自動ACK channel.basicConsume(QUEUE_NAME, true, consumer); } }
測試
我們分別傳送增、刪、改的RoutingKey,發現結果:
訂閱模型-Topic
說明
Topic
型別的Exchange
與Direct
相比,都是可以根據RoutingKey
把訊息路由到不同的佇列。只不過Topic
型別Exchange
可以讓佇列在繫結Routing key
的時候使用萬用字元!
Routingkey
一般都是有一個或多個單片語成,多個單詞之間以”.”分割,例如: item.insert
萬用字元規則:
#
:匹配一個或多個詞
*
:匹配不多不少恰好1個詞
舉例:
audit.#
:能夠匹配audit.irs.corporate
或者 audit.irs
audit.*
:只能匹配audit.irs
圖示:
解釋:
-
紅色Queue:繫結的是
usa.#
,因此凡是以usa.
開頭的routing key
都會被匹配到 -
黃色Queue:繫結的是
#.news
,因此凡是以.news
結尾的routing key
都會被匹配
生產者
使用topic型別的Exchange,傳送訊息的routing key有3種: item.isnert
、item.update
、item.delete
:
public class Send { private final static String EXCHANGE_NAME = "topic_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告exchange,指定型別為topic channel.exchangeDeclare(EXCHANGE_NAME, "topic"); // 訊息內容 String message = "新增商品 : id = 1001"; // 傳送訊息,並且指定routing key 為:insert ,代表新增商品 channel.basicPublish(EXCHANGE_NAME, "item.insert", null, message.getBytes()); System.out.println(" [商品服務:] Sent '" + message + "'"); channel.close(); connection.close(); } }
我們此處假設消費者1只接收兩種型別的訊息:更新商品和刪除商品
public class Recv { private final static String QUEUE_NAME = "topic_exchange_queue_1"; private final static String EXCHANGE_NAME = "topic_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機,同時指定需要訂閱的routing key。需要 update、delete channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "item.update"); channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "item.delete"); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者1] received : " + msg + "!"); } }; // 監聽佇列,自動ACK channel.basicConsume(QUEUE_NAME, true, consumer); } }
消費者2 我們此處假設消費者2接收所有型別的訊息:新增商品,更新商品和刪除商品。
/** * 消費者2 */ public class Recv2 { private final static String QUEUE_NAME = "topic_exchange_queue_2"; private final static String EXCHANGE_NAME = "topic_exchange_test"; public static void main(String[] argv) throws Exception { // 獲取到連線 Connection connection = ConnectionUtil.getConnection(); // 獲取通道 Channel channel = connection.createChannel(); // 宣告佇列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); // 繫結佇列到交換機,同時指定需要訂閱的routing key。訂閱 insert、update、delete channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "item.*"); // 定義佇列的消費者 DefaultConsumer consumer = new DefaultConsumer(channel) { // 獲取訊息,並且處理,這個方法類似事件監聽,如果有訊息的時候,會被自動呼叫 @Override public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body) throws IOException { // body 即訊息體 String msg = new String(body); System.out.println(" [消費者2] received : " + msg + "!"); } }; // 監聽佇列,自動ACK channel.basicConsume(QUEUE_NAME, true, consumer); } }