> 原文鏈接:[http://www.aosabook.org/en/zeromq.html](http://www.aosabook.org/en/zeromq.html)
?MQ是一個消息通信系統,如果你愿意的話也可以稱其為“面向消息的中間件”。?MQ的應用環境很廣泛,包括金融服務、游戲開發、嵌入式系統、學術研究以及航空航天等領域。
消息通信系統完成的工作基本上可看作為負責應用程序之間的即時消息通信。一個應用程序決定發送一個事件給另一個應用程序(或者多個應用程序),它將需要發送的數據組合起來,點擊“發送”按鈕就行了——消息通信系統會搞定剩下的工作。
不同于即時消息通信的是,消息通信系統沒有圖形用戶界面,并假設當出現錯誤時,對端并不會有人為干預的智能化處理。因此,消息通信系統必須既要有高度的容錯性,也要比一般的即時消息通信更快速。
?MQ最初的設想是作為股票交易中的一個極快速的消息通信系統,因此重點放在了高度優化上。項目開始的頭一年都花在制定性能基準測試的方法上了,并嘗試設計出一個盡可能高效的架構。
之后,大約是在項目進行的第二年里,開發的重點轉變成為構建分布式應用程序而提供的一個通用系統,支持任意模式的消息通信、多種傳輸機制、對多種編程語言的綁定等等。
在開發的第三年里,重點主要集中于提高系統的可用性,將學習曲線平坦化。我們已經采用了BSD套接字API,嘗試整理單個消息通信模式的語義等等。
本章試圖向讀者介紹,?MQ為達到上述三個目標是如何設計其內部架構的,也希望給同樣面對這些問題的人提供一些啟示。
啟動?MQ項目的第三年里,其代碼庫已經膨脹的過于龐大。有一項提議要標準化?MQ中所使用的協議,以及實驗性地實現一個類?MQ的消息通信系統以加入到Linux內核中等等。不過,本書并未涵蓋這些主題,更多細節可以參考:[http://www.250bpm.com/concepts](http://www.250bpm.com/concepts),[http://groups.google.com/group/sp-discuss-group](http://groups.google.com/group/sp-discuss-group),和[http://www.250bpm.com/hits](http://www.250bpm.com/hits)。
## 24.1 應用程序 vs 程序庫
?MQ是一個程序庫,不是消息通信服務器。我們花了好幾年時間在AMQP上,這是一種在金融行業中嘗試標準化用于商業消息通信的協議。我們為其編寫了一個參考性的實現,然后部署到幾個主要基于消息通信技術的大型項目中使用——由此我們意識到,智能消息服務器(代理/broker)和啞客戶端之間的這種經典的客戶機/服務器模型是有問題的。
當時我們主要關心的是性能:如果中間有個服務器的話,每條消息都不得不穿越網絡兩次(從發送者到服務器,然后從服務器再到接收者),還附帶有延遲和吞吐量方面的損耗。此外,如果所有的消息都要通過服務器傳遞的話,某一時刻它就必然會成為性能的瓶頸。
第二點需要關心的是關于大規模部署的問題:當消息通信需要跨越公司的界限時,這種中央集權式管理所有消息流的概念就不再有效了。沒有一家公司愿意把對服務器的控制權放在別的公司里,這包含有商業機密以及法律責任相關的問題。實際結果就是每家公司都有一個消息通信服務器,可通過手動橋接的方式連接到其他公司的消息通信系統中。因此整個經濟系統被極大的劃分開來,但是為每個公司維護這樣大量的橋接并沒有使情況變得更好。要解決這個問題,我們需要一個分布式的架構。在這種架構中每一個組件都可以由一個不同的商業實體來管轄。鑒于基于服務器架構的管理單元就是服務器,我們可以通過為每個組件設置一個單獨的服務器來解決這個問題。在這種情況下,我們可以通過使服務器和組件共享同一個進程來進一步地優化設計。我們最終得到的就是一個消息通信的程序庫。
當我們開始設想一種不需要中間服務器的消息通信機制時,也就是?MQ項目開始之時。這需要自下而上的將整個消息通信的概念顛倒過來,將位于網絡中央的集中信息存儲模型替換為基于端到端機制的“智能型終端,沉默化網絡”的架構。正是由于這樣的技術決策,?MQ從一開始就作為一個庫而存在,它不是應用程序。 同時,我們也已經證明了這種架構更加高效(低延遲,高吞吐量)也更加靈活(很容易在此之上構建任意復雜的拓撲結構,而不必拘泥于經典的中心輻射模型)。
然而選擇以庫的形式發布,這其中還有一個意想不到的結果,那就是這么做提高了產品的可用性。用戶反復地表示由于他們不再需要安裝和管理一個獨立的消息通信服務器了,為此他們感到很慶幸。事實證明,去掉中間服務器是首選方案,因為這么做降低了運營的成本(不需要為消息通信服務器安排管理員),也加快了市場響應的時間(沒有必要對客戶、管理層或運營團隊談判溝通是否要運行服務器)。
我們從中學到的是,當開始一個新項目時,你應該盡可能的選擇以庫的形式來設計。我們可以很容易的通過從小型程序中調用庫的實現而創建出一個應用,但是卻幾乎不可能從已有的可執行程序中創建一個庫。庫對用戶來說可以提供更高的靈活性,同時也不需要花費他們很多精力來管理。
## 24.2 全局狀態
全局變量不適于在庫中使用。因為一個進程可能會加載同一個庫幾次,而它們會共用一組全局變量。在圖24.1中,?MQ庫被兩個不同的、彼此獨立的庫所調用,而應用本身調用了這兩個庫。

圖24.1 不同的庫在使用?MQ
當出現這種情況時,兩個?MQ的實例會訪問到相同的變量,這會產生競爭條件,出現奇怪的錯誤和未定義的行為。
要防止出現這種問題,?MQ中沒有使用任何全局變量。相反地,是由庫的使用者來負責顯式地創建全局狀態。包含全局狀態的對象稱為context。從用戶的角度來看,context或多或少類似一個工作者線程池,而從?MQ的角度來看,它僅僅是一個存儲我們所需要的任意全局狀態的對象。在上圖中,libA會有它自己的context,而libB也會有它自己的context。它們之間無法互相干擾。
看到這里應該已經非常明顯了:絕不要在庫中使用全局狀態。如果你這么做了,當庫恰好需要在同一個進程中實例化兩次時,它很可能會崩潰。
## 24.3 性能
當?MQ項目開始之后,主要的目標是優化性能。消息通信系統的性能可以用兩個指標來界定:吞吐量——在一段給定的時間內可以傳遞多少條消息;以及時延——一條消息從一端傳到另一端需要花費多長時間。
我們應該重點關注哪個指標?這兩者之間的關系是什么?這還不明擺著嗎?跑測試,用測試的總時間除以消息的數量,你得到的就是時延。用消息的數量除以總時間,你得到的就是吞吐量。換句話說,時延是吞吐量的倒數。很簡單,不是嗎?
我們并沒有直接開始編碼,而是花了幾周的時間詳細調查性能指標,我們發現吞吐量和時延之間的關系絕非如此簡單,通常這個指標數是相當違反直覺的。
假設A發送消息給B(見圖24.2),測試的總時間是6秒,總共有5條消息傳遞。因此吞吐量是0.83條消息/每秒(5/6),而時延是1.2秒(6/5),對吧?

圖24.2 從A到B發送消息
請再看看這副圖。每條消息從A到B所花費的時間是不同的:2秒、2.5秒、3秒、3.5秒、4秒。平均計算是3秒鐘,這和我們之前計算出的1.2秒相比差太遠了。這個例子很直觀的表明,人們很容易對性能指標產生誤解。
現在來看看吞吐量。測試的總時間是6秒。但是,在A點總共花費了2秒才把所有的消息都發送完畢。從A的角度來看,吞吐量是2.5條消息/秒(5/2)。在B點共花費了4秒才將所有的消息都接收完畢。因此,從B的角度來看,吞吐量是1.25條消息/秒(5/4)。這兩個數據都同之前計算得出的1.2條消息/秒不吻合。
長話短說吧,時延和吞吐量是兩個不同的指標,這是非常明顯的。重要的是理解這兩者之間的區別以及它們的相互關系。時延只能在系統的兩個不同端點之間才能測量,A點本身并沒有什么時延。每條消息都有它們自己的時延,你可以通過多條消息來計算平均時延,但是,對于一個消息流來說并沒有什么時延。
換句話說,吞吐量只能在系統的某個端點處才能測量。發送端有吞吐量,接收端有吞吐量,這兩者之間的任意中間結點也有吞吐量,但對整個系統來說就沒有什么總吞吐量的概念了。另外,吞吐量只對一組消息有意義,單條消息是沒有什么吞吐量可言的。
至于吞吐量和時延之間的關系,我們已經證明了原來它們之間確實有關系。但是,公式表達中涉及到積分,我們就不在這里討論了。要得到更多的信息,可以去讀一讀有關隊列的論文。
關于對消息通信系統進行的基準測試還有許多缺陷存在,但我們不會進一步探討了。這里應該再次強調我們為此得到的教訓:確保理解你正在解決的問題。即使是一個“讓它更快”這樣簡單的問題也會耗費你大量的工作才能正確理解之。更何況如果你不理解問題,你很可能會隱式地將假設和某種流行的觀點置入代碼中,這使得解決方案要么是有缺陷的或者至少會變得非常復雜,又或者會使得該方案沒有達到它應有的適用范圍。
## 24.4 關鍵路徑
我們在性能優化的過程中發現有3個因素會對性能產生嚴重的影響:
* 內存分配的次數
* 系統調用的次數
* 并發模型
但是,并不是每個內存分配或者每個系統調用都會對性能產生同樣的影響。對于消息通信系統的性能,我們所感興趣的是在給定的時間內能在兩點間傳送的消息數量。另外,我們可能會感興趣的是消息從一點傳送到另一點需要多久。
考慮到?MQ被設計為針對長期連接的場景,因此建立一個連接或者處理一個連接錯誤所花費的時間基本上可忽略。這些事件極少發生,因此它們對總體性能的影響可以忽略不計。
代碼庫中某個一遍又一遍被頻繁使用的部分,我們稱之為關鍵路徑。優化應該集中到這些關鍵路徑上來。 讓我們看一個例子:?MQ在內存分配方面并沒有做高度優化。比如,當操作字符串時,常常是在每個轉化的中間階段分配一個新的字符串。但是,如果我們嚴格審查關鍵路徑——實際完成消息通信的部分——我們會發現這部分幾乎沒有使用任何內存分配。如果是短消息,那么每256個消息才會有一次內存分配(這些消息都被保存到一個單獨的大內存塊中)。此外,如果消息流是穩定的,在不出現流峰值的情況下,關鍵路徑部分的內存分配次數會降為零(已分配的內存塊不會返回給系統,而是不斷的進行重用)。
我們從中學到的是:只在對結果能產生影響的地方做優化。優化非關鍵路徑上的代碼只是在做無用功。
## 24.5 內存分配
假設所有的基礎組件都已經初始化完成,兩點之間的一條連接也已經建立完成,此時要發送一條消息時只有一樣東西需要分配內存:消息體本身。因此,要優化關鍵路徑,我們就必須考慮消息體是如何分配的以及是如何在棧上來回傳遞的。
在高性能網絡編程領域中,最佳性能是通過仔細地平衡消息的分配以及消息拷貝所帶來的開銷而實現的,這是常識(比如,[http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf](http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf)?參見針對“小型”、“中型”、“大型”消息的不同處理)。對于小型的消息,拷貝操作比內存分配要經濟的多。只要有需要,完全不分配新的內存塊而直接把消息拷貝到預分配好的內存塊上,這么做是有道理的。另一方面,對于大型的消息,拷貝操作比內存分配的開銷又要昂貴的多。為消息體分配一次內存,然后傳遞指向分配塊的指針,而不是拷貝整個數據。這種方式被稱為“零拷貝”。
?MQ以透明的方式同時處理這兩種情況。一條?MQ消息由一個不透明的句柄來表示。對于非常短小的消息,其內容被直接編碼到句柄中。因此,對句柄的拷貝實際上就是對消息數據的拷貝。當遇到較大的消息時,它被分配到一個單獨的緩沖區內,而句柄只包含一個指向緩沖區的指針。對句柄的拷貝并不會造成對消息數據的拷貝,當消息有數兆字節長時,這么處理是很有道理的(圖24.3)。需要提醒的是,后一種情況里緩沖區是按引用計數的,因此可以做到被多個句柄引用而不必拷貝數據。

圖24.3 消息拷貝(或者不拷貝)
我們從中學到的是:當考慮性能問題時,不要假設存在有一個最佳解決方案。很可能這個問題有多個子問題(例如,小型消息和大型消息),而每一個子問題都有各自的優化算法。
## 24.6 批量處理
前面已經提到過,在消息通信系統中,系統調用的數量太多的話會導致出現性能瓶頸。實際上,這個問題絕非一般。當需要遍歷調用棧時會有不小的性能損失,因此,明智的做法是,當創建高性能的應用時應該盡可能多的去避免遍歷調用棧。
參見圖24.4,為了發送4條消息,你不得不遍歷整個網絡協議棧4次(也就是,?MQ、glibc、用戶/內核空間邊界、TCP實現、IP實現、以太網鏈路層、網卡本身,然后反過來再來一次)。

圖24.4 發送4條消息
但是,如果你決定將這些消息集合到一起成為一個單獨的批次,那么就只需要遍歷一次調用棧了(圖24.5)。這種處理方式對消息吞吐量的影響是巨大的:可大至2個數量級,尤其是如果消息都比較短小,數百個這樣的短消息才能包裝成一個批次。

圖24.5 批量處理消息
另一方面,批量處理會對時延帶來負面影響。我們來分析一下,比如,TCP實現中著名的Nagle算法。它為待發出的消息延遲一定的時間,然后將所有的數據合并成一個單獨的數據包。顯然,數據包中的第一條消息,其端到端的時延要比最后一條消息嚴重的多。因此,如果應用程序需要持續的低時延的話,常見做法是將Nagle算法關閉。更常見的是取消整個調用棧層次上的批量處理(比如,網卡的中斷匯聚功能)。
但同樣,不做批量處理就意味著需要大量穿越整個調用棧,這會導致消息吞吐量降低。似乎我們被困在吞吐量和時延的兩難境地中了。
?MQ嘗試采用以下策略來提供一致性的低時延和高吞吐量。當消息流比較稀疏,不超過網絡協議棧的帶寬時,?MQ關閉所有的批量處理以改善時延。這里的權衡是CPU的使用率會變得略高——我們仍然需要經常穿越整個調用棧。但是在大多數情況下,這并不是個問題。
當消息的速率超過網絡協議棧的帶寬時,消息就必須進行排隊處理了——保存在內存中直到協議棧準備好接收它們。排隊處理就意味著時延的上升。如果消息在隊列中要花費1秒時間,端到端的時延就至少會達到1秒。更糟糕的是,隨著隊列長度的增長,時延會顯著提升。如果隊列的長度沒有限制的話,時延就會超過任何限定值。
據觀察,即使調整網絡協議棧以追求最低的時延(關閉Nagle算法,關閉網卡中斷匯聚功能,等等),由于受前文所述的隊列的影響,時延仍然會比較高。
在這種情況下,積極的采取批量化處理是有意義的。反正時延已經比較高了,也沒什么好顧慮的了。另一方面,積極的采用批量處理能夠提高吞吐量,而且可以清空隊列中等待的消息——這反過來又意味著時延將逐步降低,因為正是排隊才造成了時延的上升。一旦隊列中沒有未發送的消息了,就可以關閉批量處理,進一步的改善時延。
我們觀察到批量處理只應該在最高層進行,這是需要額外注意的一點。如果消息在最高層匯聚為批次,在低層次上就沒什么可做批量處理的了,而且所有低層次的批量處理算法除了會增加總體時延外什么都沒做。 我們從中學到了:在一個異步系統中,要獲得最佳的吞吐量和響應時間,需要在調用棧的底層關閉批量處理算法,而在高層開啟。僅在新數據到達的速率快于它們被處理的速率時才做批量處理。
## 24.7 架構概覽
到目前為止,我們都專注于那些使?MQ變得快速的通用性原則。從現在起,我們可以看一看實際的系統架構了(圖24.6)。

圖24.6 ?MQ的架構框圖
用戶使用被稱為“套接字”的對象同?MQ進行交互。它們同TCP套接字很相似,主要的區別在于這里的套接字能夠處理同多個對端的通信,有點像非綁定的UDP套接字。
套接字對象存在于用戶線程中(見下一節的線程模型討論)。除此之外,?MQ運行多個工作者線程用以處理通信中的異步環節:從網絡中讀取數據、將消息排隊、接受新的連接等等。
工作者線程中存在著多個對象。每一個對象只能由唯一的父對象所持有(所有權由圖中一個簡單的實線來標記)。與子對象相比,父對象可以存在于其他線程中。大多數對象直接由套接字sockets所持有。但是,這里有幾種情況下會出現一個對象由另一個對象所持有,而這個對象又由socket所持有。我們得到的是一個對象樹,每個socket都有一個這樣的對象樹。我們在關閉連接時會用到對象樹,在一個對象關閉它所有的子對象前,任何對象都不能自行關閉。這樣我們可以確保關閉操作可以按預期的行為那樣正常工作。比如,在隊列中等待發送的消息要先發送到網絡中,之后才能終止發送過程。
大致來說,這里有兩種類型的異步對象。有的對象不會涉及到消息傳遞,而有些需要。前者主要負責連接管理。比如,一個TCP監聽對象在監聽接入的TCP連接,并為每一個新的連接創建一個engine/session對象。類似的,一個TCP連接對象嘗試連接到TCP對端,如果成功,它就創建一個engine/session對象來管理這個連接。如果失敗了,連接對象會嘗試重新建立連接。
而后者用來負責數據的傳輸。這些對象由兩部分組成:session對象負責同?MQ的socket交互,而engine對象負責同網絡進行通信。session對象只有一種類型,而對于每一種?MQ所支持的協議都會有不同類型的engine對象與之對應。因此,我們有TCP engine,IPC(進程間通信)engine,PGM engine(一種可靠的多播協議,參見RFC 3208),等等。engine的集合非常廣泛——未來我們可能會選擇實現比如WebSocket engine或者SCTP engine。
session對象同socket之間交換消息。可以由兩個方向來傳遞消息,在每個方向上由一個pipe對象來處理。基本上來說,pipe就是一個優化過的用來在線程之間快速傳遞消息的無鎖隊列。
最后我們來看看context對象(在前一節中提到過,但沒有在圖中表示出來),該對象保存全局狀態,所有的socket和異步對象都可以訪問它。
## 24.8 并發模型
?MQ需要充分利用多核的優勢,換句話說就是隨著CPU核心數的增長能夠線性的擴展吞吐量。
以我們之前對消息通信系統的經驗表明,采用經典的多線程方式(臨界區、信號量等等)并不會使性能得到較大提升。事實上,就算是在多核環境下,一個多線程版的消息通信系統可能會比一個單線程的版本還要慢。有太多時間都花在等待其他線程上了,同時,引入了大量的上下文切換拖慢了整個系統。
針對這些問題,我們決定采用一種不同的模型。目標是完全避免鎖機制,并讓每個線程能夠全速運行。線程間的通信是通過在線程間傳遞異步消息(事件)來實現的。內行人都應該知道,這就是經典的actor模式。
我們的想法是在每一個CPU核心上運行一個工作者線程——讓兩個線程共享同一個核心只會意味著大量的上下文切換而沒有得到任何別的優勢。每一個?MQ的內部對象,比如說TCP engine,將會緊密地關聯到一個特定的工作者線程上。反過來,這意味著我們不再需要臨界區、互斥鎖、信號量等等這些東西了。此外,這些?MQ對象不會在CPU核之間遷移,從而可以避免由于緩存被污染而引起性能上的下降(圖24.7)。

圖24.7 多個工作者線程
這個設計讓很多傳統多線程編程中出現的頑疾都消失了。然而,我們還需要在許多對象間共享工作者線程,這反過來又意味著必須要有某種多任務間的合作機制。這表示我們需要一個調度器,對象必須是事件驅動的,而不是在整個事件循環中來控制。我們必須考慮任意序列的事件,甚至非常罕見的情況也要考慮到。我們必須確保不會有哪個對象持有CPU的時間過長等等。
簡單來說,整個系統必須是全異步的。任何對象都無法承受阻塞式的操作,因為這不僅會阻塞其自身,而且所有共享同一個工作者線程的其他對象也都會被阻塞。所有的對象都必須或顯式或隱式的成為一種狀態機。隨著有數百或數千的狀態機在并行運轉著,你必須處理這些狀態機之間的所有可能發生的交互,而其中最重要的就是——關閉過程。
事實證明,要以一種清晰的方式關閉一個全異步的系統是一個相當復雜的任務。試圖關閉一個有著上千個運轉著的部分的系統,其中有的正在工作中,有的處于空閑狀態,有的正在初始化過程中,有的已經自行關閉了,此時極易出現各種競態條件、資源泄露等諸如此類的情況。?MQ中最為復雜的部分肯定就是這個關閉子系統了。快速檢查一下bug跟蹤系統的記錄顯示,約30%到50%的bug都同關閉有某種聯系。
我們從中學到的是:當要追求極端的性能和可擴展性時,考慮采用actor模型。在這種情況下這幾乎是你唯一的選擇。不過,如果不使用像Erlang或者?MQ這種專門的系統,你將不得不手工編寫并調試大量的基礎組件。此外,從一開始就要好好思考關于系統關閉的步驟。這將是代碼中最為復雜的部分,而如果你沒有清晰的思路該如何實現它,你可能應該重新考慮在一開始就使用actor模型。
## 24.9 無鎖算法
最近比較流行使用無鎖算法。它們是用于線程間通信的一種簡單機制,同時并不會依賴于操作系統內核提供的同步原語,如互斥鎖和信號量。相反,它們通過使用CPU原子操作來實現同步,比如原子化的CAS指令(比較并交換)。我們應該理解清楚的是它們并不是字面意義上的無鎖——相反,鎖機制是在硬件層面實現的。
?MQ在pipe對象中采用無鎖隊列來在用戶線程和?MQ的工作者線程之間傳遞消息。關于?MQ是如何使用無鎖隊列的,這里有兩個有趣的地方。
首先,每個隊列只有一個寫線程,也只有一個讀線程。如果有1對多的通信需求,那么就創建多個隊列(圖24.8)。鑒于采用這種方式時隊列不需要考慮對寫線程和讀線程的同步(只有一個寫線程,也只有一個讀線程),因此可以以非常高效的方式來實現。

圖24.8 隊列
其次,盡管我們意識到無鎖算法要比傳統的基于互斥鎖的算法更加高效,CPU的原子操作開銷仍然非常高昂(尤其是當CPU核心之間有競爭時),對每條消息的讀或者寫都采用原子操作的話,效率將低于我們所能接受的水平。
提高速度的方法——再次采用批量處理。假設你有10條消息要寫入到隊列。比如,可能會出現當你收到一個網絡數據包時里面包含有10條小型的消息的情況。由于接收數據包是一個原子事件,你不能只接收一半,因此這個原子事件導致需要寫10條消息到無鎖隊列中。那么對每條消息都采用一次原子操作就顯得沒什么道理了。相反,你可以讓寫線程擁有一塊自己獨占的“預寫”區域,讓它先把消息都寫到這里,然后再用一次單獨的原子操作,整體刷入隊列。
同樣的方法也適用于從隊列中讀取消息。假設上面提到的10條消息已經刷新到隊列中了。讀線程可以對每條消息采用一個原子操作來讀取,但是,這種做法過于重量級了。相反,讀線程可以將所有待讀取的消息用一個單獨的原子操作移動到隊列的“預讀取”部分。之后就可以從“預讀”緩存中一條一條的讀取消息了。“預讀取”部分只能由讀線程單獨訪問,因此這里沒有什么所謂的同步需求。
圖24.9中左邊的箭頭展示了如何通過簡單地修改一個指針來將預寫入緩存刷新到隊列中的。右邊的箭頭展示了隊列的整個內容是如何通過修改另一個指針來移動到預讀緩存中的。

圖24.9 無鎖隊列
我們從中學到的是:發明新的無鎖算法是很困難的,而且實現起來很麻煩,幾乎不可能對其調試。如果可能的話,可以使用現有的成熟算法而不是自己來發明輪子。當需要追求極度的性能時,不要只依靠無鎖算法。雖然它們的速度很快,但可以在其之上通過智能化的批量處理來顯著提高性能。
## 24.10 API
用戶接口是任何軟件產品中最為重要的部分。這是你的程序唯一暴露給外部世界的部分,如果搞砸了全世界都會恨你的。對于面向最終用戶的產品來說,用戶接口就是圖形用戶界面或者命令行界面,而對于庫來說,那就是API了。
在?MQ的早期版本中,其API是基于AMQP的交易和隊列模型的(參見AMQP規范)。從歷史的角度來看,2007年的白皮書嘗試要將AMQP同一個代理模式的消息通信系統相整合,這很有趣。我于2009年底重新使用BSD套接字API從零開始重寫了整個項目。那就是轉折點,從那一刻起?MQ的用戶數量開始猛增。之前的?MQ是由消息通信領域的專家們所使用的產品,而現在成為任何人都能方便使用的普通工具。在1年左右的時間里,?MQ的用戶社群擴大了10倍之多,我們還實現了對20多種不同編程語言的綁定等等。
用戶接口定義了人們對產品的感觀。基本沒有改變功能——僅僅通過修改了API——?MQ就從一個“企業級消息通信”產品轉變為一個“網絡化”的產品。換句話說,人們對?MQ的感觀從一個“大金融機構所使用的復雜基礎組件”轉變為“嘿,這工具可以幫助我從程序A發送10字節長的消息到程序B”。
我們從中學到的是:正確理解你的項目,根據你對項目的愿景來合理地設計用戶接口。用戶接口同項目的愿景不相符合的話,可以100%保證該項目注定會失敗。
將?MQ的用戶接口替換為BSD套接字API,這其中有個很重要的因素,那就是BSD套接字API并不是一個新的發明,而是早就為人們所熟悉了。事實上,BSD套接字API是當今仍在使用中的最為古老的API之一了。那得回溯到1983年以及4.2版BSD Unix的時代。它已經被廣泛且穩定的使用了幾十年了。
上面的事實帶來了很多優勢。首先,人人都知道BSD套接字API,因此學習的難度曲線非常平坦。就算你從未聽說過?MQ,你也可以在幾分鐘內創建出一個應用程序,這都得感謝你可以重用過去在BSD套接字上積累的經驗。
其次,使用這樣一種被廣泛支持的API使得?MQ可以同已有的技術進行融合。比如,將?MQ對象暴露為“套接字”或者“文件描述符”,這可以讓我們在同樣的事件循環中處理TCP、UDP、管道、文件以及?MQ事件。另一個例子是:要將類似?MQ的功能加入到Linux內核中,這個實驗性的項目就變得非常容易實現了。通過共享相同的概念框架,?MQ可以復用很多已有的基礎組件。
第三,也許也是最重要的一點,那就是BSD套接字API已經存活了將近30年的時間了,盡管中間人們曾多次嘗試替換它。這意味著設計中有某種固有的正確性。BSD套接字API的設計者——無論是故意的還是偶然的——都做出了正確的設計決策。通過借用這套API,我們可以自動分享到這些設計決策,而不必知道這些決策究竟是什么,或者它們到底解決了什么問題。
我們從中學到的是:雖然代碼復用的思想從遠古時代就有了,隨后模式復用的概念也加入了進來,重要的是要以一種更一般化的方式來思考復用。當做產品設計時,參考一下其他相似的產品。調查一下哪些方面是失敗的,哪些方面是成功的,從成功的項目中學習。不要覺得沒有創新就接受不了。復用好的點子、API、概念框架,任何你覺得合適的東西都可以復用。這么做的好處是你可以讓用戶重用他們之前的知識,同時你也可以避免當前你并不了解的技術方面的陷阱。
## 24.11 消息模式
在任何消息通信系統中,所面臨的最重要的設計問題是如何提供一種方式可以讓用戶指定哪條消息可以路由到哪個目的地。這里主要有兩種方法,而且我相信這兩種方法是相當通用的,基本可適用于軟件領域中遇到的任何問題。
第一種方式是吸收Unix哲學中的“只做一件事,并把它做好”的原則。這意味著問題域應該人為地限制在一個較小且易理解的范圍內。然后,程序應該以正確和詳盡的方式來解決這個受限制的問題。在消息通信領域中,一個采用這種方式的例子是MQTT。這是一種將消息分發給一組消費者的協議。它很容易使用,而且在消息分發方面做得很出色,但除此之外它不能用于任何其他用途(比如說RPC)。
另一種方式是致力于一般性,并提供一種功能強大且高度可配置的系統。AMQP就是這樣一個例子。它的隊列和互換的模式提供給用戶可編程的能力,幾乎可以定義出他們可想到的任意一種路由算法。當然了,有得必有失,取舍的結果就是增加了許多選項需要我們去處理。
?MQ選擇了前一種方式,因為這種方式下的產品幾乎所有的人都可以使用,而通用的方式下的產品需要消息通信方面的專家才能用上。為了闡明這個觀點,讓我們看看模式是如何對API的復雜度產生影響的。如下代碼是在通用系統(AMQP)之上的RPC客戶端實現:
~~~
connect ("192.168.0.111")
exchange.declare (exchange="requests", type="direct", passive=false,
durable=true, no-wait=true, arguments={})
exchange.declare (exchange="replies", type="direct",passive=false,
durable=true, no-wait=true, arguments={})
reply-queue=queue.declare(queue="", passive=false, durable=false,
exclusive=true, auto-delete=true, no-wait=false, arguments={})
queue.bind (queue=reply-queue, exchange="replies", routing-key=reply-queue)
queue.consume (queue=reply-queue, consumer-tag="", no-local=false,
no-ack=false, exclusive=true, no-wait=true, arguments={})
request = new-message ("Hello World!")
request.reply-to = reply-queue
request.correlation-id = generate-unique-id ()
basic.publish (exchange="requests", routing-key="my-service",
mandatory=true, immediate=false)
reply = get-message ()
~~~
而另一方面,?MQ將消息劃分為所謂的“消息模式”。幾個模式方面的例子有“發布者/訂閱者”,“請求/回復”或者“并行管線”。每一種消息通信的模式之間都是完全正交的,可被看做是一個單獨的工具。
接下來采用?MQ的請求/回復模式對上面的應用進行重構,注意?MQ將繁雜的選擇縮減為一個單一的步驟,這只要通過選擇正確的消息模式“REQ”就可以了。
~~~
s = socket (REQ)
s.connect ("tcp://192.168.0.111:5555")
s.send ("Hello World!")
reply = s.recv ()
~~~
到這里為止,我們已經可以認為具體化的解決方案比通用型解決方案要更好。我們希望自己的解決方案能盡可能的具體化。但是,同時我們又希望提供給用戶的功能面盡可能的廣。我們該如何解決這個明顯的矛盾?
答案分兩步:
1. 定義一個堆棧層,用以處理某個特定的問題領域。(比如,傳輸、路由、演示等)
2. 為該層提供多種實現方式。對于每種實現的使用,都應該是非互相干擾的。
讓我們看看網絡協議棧中有關傳輸層的例子。傳輸層意味著需要在網絡層(IP)之上提供例如數據流傳輸、流控、可靠性等服務。它是通過定義多種互不干擾的解決方案來實現的:TCP作為面向連接的可靠數據流傳輸機制、UDP作為面向非連接的非可靠式數據包傳輸機制、SCTP作為多個流的傳輸、DCCP作為非可靠性連接等等。
注意,這里每種實現都是完全正交的:UDP端不能同TCP端通信,SCTP端也不能同DCCP端通信。這意味著新的實現可以在任意時刻加到這個棧上,而不會對棧中已有的部分產生影響。相反如果實現是失敗的,則可以被完全丟棄而不會影響傳輸層的整體能力。
同樣的道理也適用于?MQ中定義的消息模式。消息模式在傳輸層(TCP及其它成員)之上組成了新的一層(所謂的“可擴展性層”)。每個消息模式都是這一層的具體實現。它們都是嚴格正交的——“發布者/訂閱者”端無法同“請求/回復”端通信,等等之類。消息模式之間的嚴格分離反過來又意味著新的模式可以按照需求增加進來,開發新模式的實驗如果失敗了,也不會對已有的模式產生影響。
我們從中學到的是:當解決一個復雜且多面化的問題時,單個通用型的解決方案可能并不是最好的方式。相反,我們可以把問題的領域想象成一個抽象層,并基于這個層次提供多個實現,每種實現只致力于解決一種定義良好的情況。當我們這么做時,要仔細劃定用例情況。要確認什么在范圍內,什么不在范圍內。如果對使用范圍限制的太過于嚴格,軟件的應用性就會受到限制。如果對問題定義的太廣,那么產品就會變得非常復雜,給用戶帶來模糊和混亂的感覺。
## 24.12 結論
由于我們的世界里已經充斥著大量通過互聯網相連的小型計算機——移動電話、RFID閱讀器、平板電腦以及便攜式計算機、GPS設備等等。分布式計算已經不再局限于學術領域了,成為了每位開發者需要去解決的日常問題。不幸的是,對此的解決方案大多數都是領域相關的獨門秘技。本文以系統化的方式總結了我們在構建大規模分布式系統中的經驗。本文主要側重于從軟件架構的觀點來闡明我們需要面對的挑戰,希望開源社區中的架構師和程序員會覺得本文很有幫助。
- 前言(卷一)
- 卷1:第1章 Asterisk
- 卷1:第3章 The Bourne-Again Shell
- 卷1:第5章 CMake
- 卷1:第6章 Eclipse之一
- 卷1:第6章 Eclipse之二
- 卷1:第6章 Eclipse之三
- 卷1:第8章 HDFS——Hadoop分布式文件系統之一
- 卷1:第8章 HDFS——Hadoop分布式文件系統之二
- 卷1:第8章 HDFS——Hadoop分布式文件系統
- 卷1:第12章 Mercurial
- 卷1:第13章 NoSQL生態系統
- 卷1:第14章 Python打包工具
- 卷1:第15章 Riak與Erlang/OTP
- 卷1:第16章 Selenium WebDriver
- 卷1:第18章 SnowFlock
- 卷1:第22章 Violet
- 卷1:第24章 VTK
- 卷1:第25章 韋諾之戰
- 卷2:第1章 可擴展Web架構與分布式系統之一
- 卷2:第1章 可擴展Web架構與分布式系統之二
- 卷2:第2章 Firefox發布工程
- 卷2:第3章 FreeRTOS
- 卷2:第4章 GDB
- 卷2:第5章 Glasgow Haskell編譯器
- 卷2:第6章 Git
- 卷2:第7章 GPSD
- 卷2:第9章 ITK
- 卷2:第11章 matplotlib
- 卷2:第12章 MediaWiki之一
- 卷2:第12章 MediaWiki之二
- 卷2:第13章 Moodle
- 卷2:第14章 NginX
- 卷2:第15章 Open MPI
- 卷2:第18章 Puppet part 1
- 卷2:第18章 Puppet part 2
- 卷2:第19章 PyPy
- 卷2:第20章 SQLAlchemy
- 卷2:第21章 Twisted
- 卷2:第22章 Yesod
- 卷2:第24章 ZeroMQ