?MQ是一個(gè)消息通信系統(tǒng),如果你愿意的話也可以稱其為“面向消息的中間件”。?MQ的應(yīng)用環(huán)境很廣泛,包括金融服務(wù)、游戲開發(fā)、嵌入式系統(tǒng)、學(xué)術(shù)研究以及航空航天等領(lǐng)域。
消息通信系統(tǒng)完成的工作基本上可看作為負(fù)責(zé)應(yīng)用程序之間的即時(shí)消息通信。一個(gè)應(yīng)用程序決定發(fā)送一個(gè)事件給另一個(gè)應(yīng)用程序(或者多個(gè)應(yīng)用程序),它將需要發(fā)送的數(shù)據(jù)組合起來,點(diǎn)擊“發(fā)送”按鈕就行了——消息通信系統(tǒng)會(huì)搞定剩下的工作。
不同于即時(shí)消息通信的是,消息通信系統(tǒng)沒有圖形用戶界面,并假設(shè)當(dāng)出現(xiàn)錯(cuò)誤時(shí),對(duì)端并不會(huì)有人為干預(yù)的智能化處理。因此,消息通信系統(tǒng)必須既要有高度的容錯(cuò)性,也要比一般的即時(shí)消息通信更快速。
?MQ最初的設(shè)想是作為股票交易中的一個(gè)極快速的消息通信系統(tǒng),因此重點(diǎn)放在了高度優(yōu)化上。項(xiàng)目開始的頭一年都花在制定性能基準(zhǔn)測(cè)試的方法上了,并嘗試設(shè)計(jì)出一個(gè)盡可能高效的架構(gòu)。
之后,大約是在項(xiàng)目進(jìn)行的第二年里,開發(fā)的重點(diǎn)轉(zhuǎn)變成為構(gòu)建分布式應(yīng)用程序而提供的一個(gè)通用系統(tǒng),支持任意模式的消息通信、多種傳輸機(jī)制、對(duì)多種編程語言的綁定等等。
在開發(fā)的第三年里,重點(diǎn)主要集中于提高系統(tǒng)的可用性,將學(xué)習(xí)曲線平坦化。我們已經(jīng)采用了BSD套接字API,嘗試整理單個(gè)消息通信模式的語義等等。
本章試圖向讀者介紹,?MQ為達(dá)到上述三個(gè)目標(biāo)是如何設(shè)計(jì)其內(nèi)部架構(gòu)的,也希望給同樣面對(duì)這些問題的人提供一些啟示。
啟動(dòng)?MQ項(xiàng)目的第三年里,其代碼庫已經(jīng)膨脹的過于龐大。有一項(xiàng)提議要標(biāo)準(zhǔn)化?MQ中所使用的協(xié)議,以及實(shí)驗(yàn)性地實(shí)現(xiàn)一個(gè)類?MQ的消息通信系統(tǒng)以加入到Linux內(nèi)核中等等。不過,本書并未涵蓋這些主題,更多細(xì)節(jié)可以參考:http://www.250bpm.com/concepts,http://groups.google.com/group/sp-discuss-group,和http://www.250bpm.com/hits。
?MQ是一個(gè)程序庫,不是消息通信服務(wù)器。我們花了好幾年時(shí)間在AMQP上,這是一種在金融行業(yè)中嘗試標(biāo)準(zhǔn)化用于商業(yè)消息通信的協(xié)議。我們?yōu)槠渚帉懥艘粋€(gè)參考性的實(shí)現(xiàn),然后部署到幾個(gè)主要基于消息通信技術(shù)的大型項(xiàng)目中使用——由此我們意識(shí)到,智能消息服務(wù)器(代理/broker)和啞客戶端之間的這種經(jīng)典的客戶機(jī)/服務(wù)器模型是有問題的。
當(dāng)時(shí)我們主要關(guān)心的是性能:如果中間有個(gè)服務(wù)器的話,每條消息都不得不穿越網(wǎng)絡(luò)兩次(從發(fā)送者到服務(wù)器,然后從服務(wù)器再到接收者),還附帶有延遲和吞吐量方面的損耗。此外,如果所有的消息都要通過服務(wù)器傳遞的話,某一時(shí)刻它就必然會(huì)成為性能的瓶頸。
第二點(diǎn)需要關(guān)心的是關(guān)于大規(guī)模部署的問題:當(dāng)消息通信需要跨越公司的界限時(shí),這種中央集權(quán)式管理所有消息流的概念就不再有效了。沒有一家公司愿意把對(duì)服務(wù)器的控制權(quán)放在別的公司里,這包含有商業(yè)機(jī)密以及法律責(zé)任相關(guān)的問題。實(shí)際結(jié)果就是每家公司都有一個(gè)消息通信服務(wù)器,可通過手動(dòng)橋接的方式連接到其他公司的消息通信系統(tǒng)中。因此整個(gè)經(jīng)濟(jì)系統(tǒng)被極大的劃分開來,但是為每個(gè)公司維護(hù)這樣大量的橋接并沒有使情況變得更好。要解決這個(gè)問題,我們需要一個(gè)分布式的架構(gòu)。在這種架構(gòu)中每一個(gè)組件都可以由一個(gè)不同的商業(yè)實(shí)體來管轄。鑒于基于服務(wù)器架構(gòu)的管理單元就是服務(wù)器,我們可以通過為每個(gè)組件設(shè)置一個(gè)單獨(dú)的服務(wù)器來解決這個(gè)問題。在這種情況下,我們可以通過使服務(wù)器和組件共享同一個(gè)進(jìn)程來進(jìn)一步地優(yōu)化設(shè)計(jì)。我們最終得到的就是一個(gè)消息通信的程序庫。
當(dāng)我們開始設(shè)想一種不需要中間服務(wù)器的消息通信機(jī)制時(shí),也就是?MQ項(xiàng)目開始之時(shí)。這需要自下而上的將整個(gè)消息通信的概念顛倒過來,將位于網(wǎng)絡(luò)中央的集中信息存儲(chǔ)模型替換為基于端到端機(jī)制的“智能型終端,沉默化網(wǎng)絡(luò)”的架構(gòu)。正是由于這樣的技術(shù)決策,?MQ從一開始就作為一個(gè)庫而存在,它不是應(yīng)用程序。 同時(shí),我們也已經(jīng)證明了這種架構(gòu)更加高效(低延遲,高吞吐量)也更加靈活(很容易在此之上構(gòu)建任意復(fù)雜的拓?fù)浣Y(jié)構(gòu),而不必拘泥于經(jīng)典的中心輻射模型)。
然而選擇以庫的形式發(fā)布,這其中還有一個(gè)意想不到的結(jié)果,那就是這么做提高了產(chǎn)品的可用性。用戶反復(fù)地表示由于他們不再需要安裝和管理一個(gè)獨(dú)立的消息通信服務(wù)器了,為此他們感到很慶幸。事實(shí)證明,去掉中間服務(wù)器是首選方案,因?yàn)檫@么做降低了運(yùn)營(yíng)的成本(不需要為消息通信服務(wù)器安排管理員),也加快了市場(chǎng)響應(yīng)的時(shí)間(沒有必要對(duì)客戶、管理層或運(yùn)營(yíng)團(tuán)隊(duì)談判溝通是否要運(yùn)行服務(wù)器)。
我們從中學(xué)到的是,當(dāng)開始一個(gè)新項(xiàng)目時(shí),你應(yīng)該盡可能的選擇以庫的形式來設(shè)計(jì)。我們可以很容易的通過從小型程序中調(diào)用庫的實(shí)現(xiàn)而創(chuàng)建出一個(gè)應(yīng)用,但是卻幾乎不可能從已有的可執(zhí)行程序中創(chuàng)建一個(gè)庫。庫對(duì)用戶來說可以提供更高的靈活性,同時(shí)也不需要花費(fèi)他們很多精力來管理。
全局變量不適于在庫中使用。因?yàn)橐粋€(gè)進(jìn)程可能會(huì)加載同一個(gè)庫幾次,而它們會(huì)共用一組全局變量。在圖24.1中,?MQ庫被兩個(gè)不同的、彼此獨(dú)立的庫所調(diào)用,而應(yīng)用本身調(diào)用了這兩個(gè)庫。
圖24.2 從A到B發(fā)送消息
請(qǐng)?jiān)倏纯催@副圖。每條消息從A到B所花費(fèi)的時(shí)間是不同的:2秒、2.5秒、3秒、3.5秒、4秒。平均計(jì)算是3秒鐘,這和我們之前計(jì)算出的1.2秒相比差太遠(yuǎn)了。這個(gè)例子很直觀的表明,人們很容易對(duì)性能指標(biāo)產(chǎn)生誤解。
現(xiàn)在來看看吞吐量。測(cè)試的總時(shí)間是6秒。但是,在A點(diǎn)總共花費(fèi)了2秒才把所有的消息都發(fā)送完畢。從A的角度來看,吞吐量是2.5條消息/秒(5/2)。在B點(diǎn)共花費(fèi)了4秒才將所有的消息都接收完畢。因此,從B的角度來看,吞吐量是1.25條消息/秒(5/4)。這兩個(gè)數(shù)據(jù)都同之前計(jì)算得出的1.2條消息/秒不吻合。
長(zhǎng)話短說吧,時(shí)延和吞吐量是兩個(gè)不同的指標(biāo),這是非常明顯的。重要的是理解這兩者之間的區(qū)別以及它們的相互關(guān)系。時(shí)延只能在系統(tǒng)的兩個(gè)不同端點(diǎn)之間才能測(cè)量,A點(diǎn)本身并沒有什么時(shí)延。每條消息都有它們自己的時(shí)延,你可以通過多條消息來計(jì)算平均時(shí)延,但是,對(duì)于一個(gè)消息流來說并沒有什么時(shí)延。
換句話說,吞吐量只能在系統(tǒng)的某個(gè)端點(diǎn)處才能測(cè)量。發(fā)送端有吞吐量,接收端有吞吐量,這兩者之間的任意中間結(jié)點(diǎn)也有吞吐量,但對(duì)整個(gè)系統(tǒng)來說就沒有什么總吞吐量的概念了。另外,吞吐量只對(duì)一組消息有意義,單條消息是沒有什么吞吐量可言的。
至于吞吐量和時(shí)延之間的關(guān)系,我們已經(jīng)證明了原來它們之間確實(shí)有關(guān)系。但是,公式表達(dá)中涉及到積分,我們就不在這里討論了。要得到更多的信息,可以去讀一讀有關(guān)隊(duì)列的論文。
關(guān)于對(duì)消息通信系統(tǒng)進(jìn)行的基準(zhǔn)測(cè)試還有許多缺陷存在,但我們不會(huì)進(jìn)一步探討了。這里應(yīng)該再次強(qiáng)調(diào)我們?yōu)榇说玫降慕逃?xùn):確保理解你正在解決的問題。即使是一個(gè)“讓它更快”這樣簡(jiǎn)單的問題也會(huì)耗費(fèi)你大量的工作才能正確理解之。更何況如果你不理解問題,你很可能會(huì)隱式地將假設(shè)和某種流行的觀點(diǎn)置入代碼中,這使得解決方案要么是有缺陷的或者至少會(huì)變得非常復(fù)雜,又或者會(huì)使得該方案沒有達(dá)到它應(yīng)有的適用范圍。
我們?cè)谛阅軆?yōu)化的過程中發(fā)現(xiàn)有3個(gè)因素會(huì)對(duì)性能產(chǎn)生嚴(yán)重的影響:
但是,并不是每個(gè)內(nèi)存分配或者每個(gè)系統(tǒng)調(diào)用都會(huì)對(duì)性能產(chǎn)生同樣的影響。對(duì)于消息通信系統(tǒng)的性能,我們所感興趣的是在給定的時(shí)間內(nèi)能在兩點(diǎn)間傳送的消息數(shù)量。另外,我們可能會(huì)感興趣的是消息從一點(diǎn)傳送到另一點(diǎn)需要多久。
考慮到?MQ被設(shè)計(jì)為針對(duì)長(zhǎng)期連接的場(chǎng)景,因此建立一個(gè)連接或者處理一個(gè)連接錯(cuò)誤所花費(fèi)的時(shí)間基本上可忽略。這些事件極少發(fā)生,因此它們對(duì)總體性能的影響可以忽略不計(jì)。
代碼庫中某個(gè)一遍又一遍被頻繁使用的部分,我們稱之為關(guān)鍵路徑。優(yōu)化應(yīng)該集中到這些關(guān)鍵路徑上來。 讓我們看一個(gè)例子:?MQ在內(nèi)存分配方面并沒有做高度優(yōu)化。比如,當(dāng)操作字符串時(shí),常常是在每個(gè)轉(zhuǎn)化的中間階段分配一個(gè)新的字符串。但是,如果我們嚴(yán)格審查關(guān)鍵路徑——實(shí)際完成消息通信的部分——我們會(huì)發(fā)現(xiàn)這部分幾乎沒有使用任何內(nèi)存分配。如果是短消息,那么每256個(gè)消息才會(huì)有一次內(nèi)存分配(這些消息都被保存到一個(gè)單獨(dú)的大內(nèi)存塊中)。此外,如果消息流是穩(wěn)定的,在不出現(xiàn)流峰值的情況下,關(guān)鍵路徑部分的內(nèi)存分配次數(shù)會(huì)降為零(已分配的內(nèi)存塊不會(huì)返回給系統(tǒng),而是不斷的進(jìn)行重用)。
我們從中學(xué)到的是:只在對(duì)結(jié)果能產(chǎn)生影響的地方做優(yōu)化。優(yōu)化非關(guān)鍵路徑上的代碼只是在做無用功。
假設(shè)所有的基礎(chǔ)組件都已經(jīng)初始化完成,兩點(diǎn)之間的一條連接也已經(jīng)建立完成,此時(shí)要發(fā)送一條消息時(shí)只有一樣?xùn)|西需要分配內(nèi)存:消息體本身。因此,要優(yōu)化關(guān)鍵路徑,我們就必須考慮消息體是如何分配的以及是如何在棧上來回傳遞的。
在高性能網(wǎng)絡(luò)編程領(lǐng)域中,最佳性能是通過仔細(xì)地平衡消息的分配以及消息拷貝所帶來的開銷而實(shí)現(xiàn)的,這是常識(shí)(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf?參見針對(duì)“小型”、“中型”、“大型”消息的不同處理)。對(duì)于小型的消息,拷貝操作比內(nèi)存分配要經(jīng)濟(jì)的多。只要有需要,完全不分配新的內(nèi)存塊而直接把消息拷貝到預(yù)分配好的內(nèi)存塊上,這么做是有道理的。另一方面,對(duì)于大型的消息,拷貝操作比內(nèi)存分配的開銷又要昂貴的多。為消息體分配一次內(nèi)存,然后傳遞指向分配塊的指針,而不是拷貝整個(gè)數(shù)據(jù)。這種方式被稱為“零拷貝”。
?MQ以透明的方式同時(shí)處理這兩種情況。一條?MQ消息由一個(gè)不透明的句柄來表示。對(duì)于非常短小的消息,其內(nèi)容被直接編碼到句柄中。因此,對(duì)句柄的拷貝實(shí)際上就是對(duì)消息數(shù)據(jù)的拷貝。當(dāng)遇到較大的消息時(shí),它被分配到一個(gè)單獨(dú)的緩沖區(qū)內(nèi),而句柄只包含一個(gè)指向緩沖區(qū)的指針。對(duì)句柄的拷貝并不會(huì)造成對(duì)消息數(shù)據(jù)的拷貝,當(dāng)消息有數(shù)兆字節(jié)長(zhǎng)時(shí),這么處理是很有道理的(圖24.3)。需要提醒的是,后一種情況里緩沖區(qū)是按引用計(jì)數(shù)的,因此可以做到被多個(gè)句柄引用而不必拷貝數(shù)據(jù)。
圖24.4 發(fā)送4條消息
但是,如果你決定將這些消息集合到一起成為一個(gè)單獨(dú)的批次,那么就只需要遍歷一次調(diào)用棧了(圖24.5)。這種處理方式對(duì)消息吞吐量的影響是巨大的:可大至2個(gè)數(shù)量級(jí),尤其是如果消息都比較短小,數(shù)百個(gè)這樣的短消息才能包裝成一個(gè)批次。
圖24.6 ?MQ的架構(gòu)框圖
用戶使用被稱為“套接字”的對(duì)象同?MQ進(jìn)行交互。它們同TCP套接字很相似,主要的區(qū)別在于這里的套接字能夠處理同多個(gè)對(duì)端的通信,有點(diǎn)像非綁定的UDP套接字。
套接字對(duì)象存在于用戶線程中(見下一節(jié)的線程模型討論)。除此之外,?MQ運(yùn)行多個(gè)工作者線程用以處理通信中的異步環(huán)節(jié):從網(wǎng)絡(luò)中讀取數(shù)據(jù)、將消息排隊(duì)、接受新的連接等等。
工作者線程中存在著多個(gè)對(duì)象。每一個(gè)對(duì)象只能由唯一的父對(duì)象所持有(所有權(quán)由圖中一個(gè)簡(jiǎn)單的實(shí)線來標(biāo)記)。與子對(duì)象相比,父對(duì)象可以存在于其他線程中。大多數(shù)對(duì)象直接由套接字sockets所持有。但是,這里有幾種情況下會(huì)出現(xiàn)一個(gè)對(duì)象由另一個(gè)對(duì)象所持有,而這個(gè)對(duì)象又由socket所持有。我們得到的是一個(gè)對(duì)象樹,每個(gè)socket都有一個(gè)這樣的對(duì)象樹。我們?cè)陉P(guān)閉連接時(shí)會(huì)用到對(duì)象樹,在一個(gè)對(duì)象關(guān)閉它所有的子對(duì)象前,任何對(duì)象都不能自行關(guān)閉。這樣我們可以確保關(guān)閉操作可以按預(yù)期的行為那樣正常工作。比如,在隊(duì)列中等待發(fā)送的消息要先發(fā)送到網(wǎng)絡(luò)中,之后才能終止發(fā)送過程。
大致來說,這里有兩種類型的異步對(duì)象。有的對(duì)象不會(huì)涉及到消息傳遞,而有些需要。前者主要負(fù)責(zé)連接管理。比如,一個(gè)TCP監(jiān)聽對(duì)象在監(jiān)聽接入的TCP連接,并為每一個(gè)新的連接創(chuàng)建一個(gè)engine/session對(duì)象。類似的,一個(gè)TCP連接對(duì)象嘗試連接到TCP對(duì)端,如果成功,它就創(chuàng)建一個(gè)engine/session對(duì)象來管理這個(gè)連接。如果失敗了,連接對(duì)象會(huì)嘗試重新建立連接。
而后者用來負(fù)責(zé)數(shù)據(jù)的傳輸。這些對(duì)象由兩部分組成:session對(duì)象負(fù)責(zé)同?MQ的socket交互,而engine對(duì)象負(fù)責(zé)同網(wǎng)絡(luò)進(jìn)行通信。session對(duì)象只有一種類型,而對(duì)于每一種?MQ所支持的協(xié)議都會(huì)有不同類型的engine對(duì)象與之對(duì)應(yīng)。因此,我們有TCP engine,IPC(進(jìn)程間通信)engine,PGM engine(一種可靠的多播協(xié)議,參見RFC 3208),等等。engine的集合非常廣泛——未來我們可能會(huì)選擇實(shí)現(xiàn)比如WebSocket engine或者SCTP engine。
session對(duì)象同socket之間交換消息??梢杂蓛蓚€(gè)方向來傳遞消息,在每個(gè)方向上由一個(gè)pipe對(duì)象來處理。基本上來說,pipe就是一個(gè)優(yōu)化過的用來在線程之間快速傳遞消息的無鎖隊(duì)列。
最后我們來看看context對(duì)象(在前一節(jié)中提到過,但沒有在圖中表示出來),該對(duì)象保存全局狀態(tài),所有的socket和異步對(duì)象都可以訪問它。
?MQ需要充分利用多核的優(yōu)勢(shì),換句話說就是隨著CPU核心數(shù)的增長(zhǎng)能夠線性的擴(kuò)展吞吐量。
以我們之前對(duì)消息通信系統(tǒng)的經(jīng)驗(yàn)表明,采用經(jīng)典的多線程方式(臨界區(qū)、信號(hào)量等等)并不會(huì)使性能得到較大提升。事實(shí)上,就算是在多核環(huán)境下,一個(gè)多線程版的消息通信系統(tǒng)可能會(huì)比一個(gè)單線程的版本還要慢。有太多時(shí)間都花在等待其他線程上了,同時(shí),引入了大量的上下文切換拖慢了整個(gè)系統(tǒng)。
針對(duì)這些問題,我們決定采用一種不同的模型。目標(biāo)是完全避免鎖機(jī)制,并讓每個(gè)線程能夠全速運(yùn)行。線程間的通信是通過在線程間傳遞異步消息(事件)來實(shí)現(xiàn)的。內(nèi)行人都應(yīng)該知道,這就是經(jīng)典的actor模式。
我們的想法是在每一個(gè)CPU核心上運(yùn)行一個(gè)工作者線程——讓兩個(gè)線程共享同一個(gè)核心只會(huì)意味著大量的上下文切換而沒有得到任何別的優(yōu)勢(shì)。每一個(gè)?MQ的內(nèi)部對(duì)象,比如說TCP engine,將會(huì)緊密地關(guān)聯(lián)到一個(gè)特定的工作者線程上。反過來,這意味著我們不再需要臨界區(qū)、互斥鎖、信號(hào)量等等這些東西了。此外,這些?MQ對(duì)象不會(huì)在CPU核之間遷移,從而可以避免由于緩存被污染而引起性能上的下降(圖24.7)。
圖24.8 隊(duì)列
其次,盡管我們意識(shí)到無鎖算法要比傳統(tǒng)的基于互斥鎖的算法更加高效,CPU的原子操作開銷仍然非常高昂(尤其是當(dāng)CPU核心之間有競(jìng)爭(zhēng)時(shí)),對(duì)每條消息的讀或者寫都采用原子操作的話,效率將低于我們所能接受的水平。
提高速度的方法——再次采用批量處理。假設(shè)你有10條消息要寫入到隊(duì)列。比如,可能會(huì)出現(xiàn)當(dāng)你收到一個(gè)網(wǎng)絡(luò)數(shù)據(jù)包時(shí)里面包含有10條小型的消息的情況。由于接收數(shù)據(jù)包是一個(gè)原子事件,你不能只接收一半,因此這個(gè)原子事件導(dǎo)致需要寫10條消息到無鎖隊(duì)列中。那么對(duì)每條消息都采用一次原子操作就顯得沒什么道理了。相反,你可以讓寫線程擁有一塊自己獨(dú)占的“預(yù)寫”區(qū)域,讓它先把消息都寫到這里,然后再用一次單獨(dú)的原子操作,整體刷入隊(duì)列。
同樣的方法也適用于從隊(duì)列中讀取消息。假設(shè)上面提到的10條消息已經(jīng)刷新到隊(duì)列中了。讀線程可以對(duì)每條消息采用一個(gè)原子操作來讀取,但是,這種做法過于重量級(jí)了。相反,讀線程可以將所有待讀取的消息用一個(gè)單獨(dú)的原子操作移動(dòng)到隊(duì)列的“預(yù)讀取”部分。之后就可以從“預(yù)讀”緩存中一條一條的讀取消息了。“預(yù)讀取”部分只能由讀線程單獨(dú)訪問,因此這里沒有什么所謂的同步需求。
圖24.9中左邊的箭頭展示了如何通過簡(jiǎn)單地修改一個(gè)指針來將預(yù)寫入緩存刷新到隊(duì)列中的。右邊的箭頭展示了隊(duì)列的整個(gè)內(nèi)容是如何通過修改另一個(gè)指針來移動(dòng)到預(yù)讀緩存中的。
更多建議: