脈絡:應用程式正在 Publish-Subscribe Channel 上接收訊息。
為什麼這會是問題#
訊息一旦被加入通道,就會留在那裡直到被消費、過期(見 Message Expiration),或系統崩潰(除非用了 Guaranteed Delivery)。這對 Point-to-Point Channel 上的訊息成立,但 Publish-Subscribe Channel 的運作方式不太一樣。
訊息被發布到發布訂閱通道時,訊息系統必須把它送給每個訂閱者——怎麼做屬於實作細節(可以保留訊息直到「尚未收到的訂閱者清單」清空,也可以複製後分送給每個訂閱者)。
這裡還有一個時序議題:若訂閱者訂閱與訊息發布「差不多」同時發生會怎樣? 訂閱者收得到嗎?這取決於訊息系統的實作。保險起見,訂閱者應該在感興趣的訊息被發布之前就先訂閱好。
實務上,訂閱者是透過關閉連線來取消訂閱的——不需要明確的取消訂閱動作,關掉連線就好。
有時想錯過,有時不想#
有時應用程式偏好忽略自己斷線之後發布的訊息,因為斷線正意味著它對之後發布的東西沒興趣。
例如一個賣磚頭的 B2B/C 應用程式訂閱了「買家索求磚頭」的通道;若它不再賣磚頭、或暫時缺貨,它可能決定斷開連線,以免收到自己反正也無法滿足的請求。
但這種行為也可能造成劣勢——「你打盹,你就輸了」的作法會讓應用程式錯過它需要的訊息。若應用程式崩潰、或必須停機維護,它可能想知道自己不在的那段時間錯過了哪些訊息。
訊息傳遞的整個宗旨,就是讓通訊即使在收發雙方與網路不同時運作時仍然可靠。
第三種狀態:閒置#
訂閱者通常不是連線中(已訂閱)就是斷線(已取消訂閱),但還有第三種可能的狀態:閒置(inactive)——已斷線、卻仍然訂閱著,因為它想收到自己斷線期間發布的訊息。
於是問題變成:訊息系統要怎麼知道一個斷線的訂閱者是「閒置」還是「已取消訂閱」?
必須有兩種訂閱:一種在訂閱者斷線時就結束,另一種即使應用程式斷線也存活,只有在應用程式明確取消訂閱時才中止。
解法#
持久訂閱為閒置的訂閱者保存訊息,並在它重新連線時把這些訊息遞送給它。
同一通道上的其他訂閱者可以不是持久的——它們就是非持久訂閱者。
流程:
- 建立訂閱 → 訂閱者活躍
- 關閉連線 → 變成閒置
- 閒置期間發布者發布訊息 → 非持久訂閱者會錯過;因為它是持久的,訊息系統替它保存下來
- 重新訂閱 → 再次活躍 → 訊息系統遞送先前排隊的訊息
- 處理完畢後若不想再收訊息,就關閉連線(再次閒置);並且因為不想再讓訊息系統替它保存訊息,還要取消訂閱

圖 10-10:持久訂閱的循序圖
如果持久訂閱者永遠不取消訂閱?#
範例:股票交易與 JMS 持久訂閱
股票交易#
股票交易系統可能用 Publish-Subscribe Channel 廣播股價變動——每次股價改變就發布一則訊息。兩個訂閱者:
- 顯示當前股價的 GUI → 訂閱可以是非持久的,因為它顯示的是當前價格。GUI 崩潰失去連線時,保存它無法顯示的價格變動毫無意義。
- 儲存當日交易區間的資料庫 → 應該用 Durable Subscriber。運行時它能顯示至今的區間;失去連線後重新連上時,它能處理期間發生的價格變動並更新區間。
JMS 持久訂閱#
持久訂閱的一個挑戰是:如何區分「重新連線的舊訂閱者」與「全新的訂閱者」?
在 JMS 中,持久訂閱由三項準則識別:
- 所訂閱的 topic
- 連線的 client ID
- 訂閱者的 subscription name
連線的 client ID 是其 connection factory 的屬性,在用訊息系統的管理工具建立 factory 時設定;subscription name 必須對每個訂閱者唯一(在特定 topic 與 client ID 之下)。
ConnectionFactory factory = // obtain the factory
// the factory has the client ID
Connection connection = factory.createConnection();
// the connection has the same client ID as the factory
Topic topic = // obtain the topic
String clientID = connection.getClientID(); // just in case you're curious
String subscriptionName = "subscriber1"; // some UID for the subscription
Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
TopicSubscriber subscriber = session.createDurableSubscriber(topic, subscriptionName);這個訂閱者現在是活躍的,會像非持久訂閱者一樣收到發布到該 topic 的訊息。要讓它閒置就關閉它:
subscriber.close();訂閱者現在斷線、因而閒置;發布到其 topic 的任何訊息都會被保存下來,等它重新連線時遞送。
要讓訂閱重新活躍,必須用相同的 topic、client ID 與 subscription name 建立一個新的持久訂閱者——程式碼與先前完全相同。
因為「建立持久訂閱」與「重新連上既有訂閱」的程式碼一模一樣,只有訊息系統知道這個持久訂閱是既有的還是新的。
一個有趣的後果是:重新連上訂閱的應用程式,未必是先前斷線的那個應用程式。只要新應用程式用了相同的 topic、相同的 connection factory(因而相同的 client ID)與相同的 subscription name,訊息系統就無法分辨兩者,會把「舊應用程式斷線前未收到的所有訊息」全部遞送給新應用程式。
要停止訊息系統為這個閒置訂閱者排隊訊息,應用程式必須明確地取消訂閱:
subscriber.close();
// subscriber is now inactive, messages will be saved
session.unsubscribe(subscriptionName);
// subscription is removed取消訂閱後,訂閱就從 topic 上被移除,訊息不會再遞送給這個訂閱者。