引用官方的說法: “ZMQ (以下 ZeroMQ 簡稱 ZMQ)是一個簡單好用的傳輸層,像框架一樣的一個 socket library,他使得 Socket 編程更加簡單、簡潔和性能更高。是一個消息處理隊列庫,可在多個線程、內核和主機盒之間彈性伸縮。ZMQ 的明確目標是“成爲標準網絡協議棧的一部分,之後進入 Linux 內核”。現在還未看到它們的成功。但是,它無疑是極具前景的、並且是人們更加需要的“傳統”BSD 套接字之上的一層封裝。ZMQ 讓編寫高性能網絡應用程序極爲簡單和有趣。”
近幾年有關”Message Queue”的項目層出不窮,知名的就有十幾種,這主要是因爲後摩爾定律時代,分佈式處理逐漸成爲主流,業界需要一套標準來解決分佈式計算環境中節點之間的消息通信。幾年的競爭下來,Apache 基金會旗下的符合 AMQP/1.0標準的 RabbitMQ 已經得到了廣泛的認可,成爲領先的 MQ 項目。
與 RabbitMQ 相比,ZMQ 並不像是一個傳統意義上的消息隊列服務器,事實上,它也根本不是一個服務器,它更像是一個底層的網絡通訊庫,在 Socket API 之上做了一層封裝,將網絡通訊、進程通訊和線程通訊抽象爲統一的 API 接口。
二、ZMQ 是什麼?
閱讀了 ZMQ 的 Guide 文檔後,我的理解是,這是個類似於 Socket 的一系列接口,他跟 Socket 的區別是:普通的 socket 是端到端的(1:1的關係),而 ZMQ 卻是可以N:M 的關係,人們對 BSD 套接字的瞭解較多的是點對點的連接,點對點連接需要顯式地建立連接、銷燬連接、選擇協議(TCP/UDP)和處理錯誤等,而 ZMQ 屏蔽了這些細節,讓你的網絡編程更爲簡單。ZMQ 用於 node 與 node 間的通信,node 可以是主機或者是進程。
三、本文的目的
在集羣對外提供服務的過程中,我們有很多的配置,需要根據需要隨時更新,那麼這個信息如果推動到各個節點?並且保證信息的一致性和可靠性?本文在介紹 ZMQ 基本理論的基礎上,試圖使用 ZMQ 實現一個配置分發中心。從一個節點,將信息無誤的分發到各個服務器節點上,並保證信息正確性和一致性。
四、ZMQ 的三個基本模型
ZMQ 提供了三個基本的通信模型,分別是“Request-Reply “,”Publisher-Subscriber“,”Parallel Pipeline”,我們從這三種模式一窺 ZMQ 的究竟
ZMQ 的 hello world!
由 Client 發起請求,並等待 Server 迴應請求。請求端發送一個簡單的 hello,服務端則迴應一個 world。請求端和服務端都可以是 1:N 的模型。通常把 1 認爲是 Server ,N 認爲是 Client 。ZMQ 可以很好的支持路由功能(實現路由功能的組件叫作 Device),把 1:N 擴展爲N:M (只需要加入若干路由節點)。如圖 1 所示:
圖1:ZMQ 的 Request-Reply 通信
服務端的 php 程序如下:
<?php /* * Hello World server * Binds REP socket to tcp://*:5555 * Expects "Hello" from client, replies with "World" * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ $context = new ZMQContext (1); // Socket to talk to clients $responder = new ZMQSocket ($context, ZMQ::SOCKET_REP); $responder->bind ("tcp://*:5555"); while(true) { // Wait for next request from client $request = $responder->recv (); printf ("Received request: [%s]\n", $request); // Do some 'work' sleep (1); // Send reply back to client $responder->send ("World"); }
Client 程序如下:
<?php /* * Hello World client * Connects REQ socket to tcp://localhost:5555 * Sends "Hello" to server, expects "World" back * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ $context = new ZMQContext (); // Socket to talk to server echo "Connecting to hello world server...\n"; $requester = new ZMQSocket ($context, ZMQ::SOCKET_REQ); $requester->connect ("tcp://localhost:5555"); for($request_nbr = 0; $request_nbr != 10; $request_nbr++) { printf ("Sending request %d...\n", $request_nbr); $requester->send ("Hello"); $reply = $requester->recv (); printf ("Received reply %d: [%s]\n", $request_nbr, $reply); }
從以上的過程,我們可以瞭解到使用 ZMQ 寫基本的程序的方法,需要注意的是:
a) 服務端和客戶端無論誰先啓動,效果是相同的,這點不同於 Socket。
b) 在服務端收到信息以前,程序是阻塞的,會一直等待客戶端連接上來。
c) 服務端收到信息以後,會 send 一個“World”給客戶端。值得注意的是一定是 client 連接上來以後,send 消息給 Server,然後 Server 再 rev 然後響應 client,這種一問一答式的。如果 Server 先 send,client 先 rev 是會報錯的。
d) ZMQ 通信通信單元是消息,他除了知道 Bytes 的大小,他並不關心的消息格式。因此,你可以使用任何你覺得好用的數據格式。Xml、Protocol Buffers、Thrift、json 等等。
e) 雖然可以使用 ZMQ 實現 HTTP 協議,但是,這絕不是他所擅長的。
ZMQ 的 Publish-subscribe 模式
我們可以想象一下天氣預報的訂閱模式,由一個節點提供信息源,由其他的節點,接受信息源的信息,如圖 2 所示:
圖2:ZMQ 的 Publish-subscribe
示例代碼如下 :
Publisher:
<?php /* * Weather update server * Binds PUB socket to tcp://*:5556 * Publishes random weather updates * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ // Prepare our context and publisher $context = new ZMQContext (); $publisher = $context->getSocket (ZMQ::SOCKET_PUB); $publisher->bind ("tcp://*:5556"); while (true) { // Get values that will fool the boss $zipcode = mt_rand(0, 100000); $temperature = mt_rand(-80, 135); $relhumidity = mt_rand(10, 60); // Send message to all subscribers $update = sprintf ("%05d %d %d", $zipcode, $temperature, $relhumidity); $publisher->send ($update); }</pre> Subscriber <pre><?php /* * Weather update client * Connects SUB socket to tcp://localhost:5556 * Collects weather updates and finds avg temp in zipcode * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ $context = new ZMQContext (); // Socket to talk to server echo "Collecting updates from weather server…", PHP_EOL; $subscriber = new ZMQSocket ($context, ZMQ::SOCKET_SUB); $subscriber->connect ("tcp://localhost:5556"); // Subscribe to zipcode, default is NYC, 10001 $filter = $_SERVER['argc'] > 1 ? $_SERVER['argv'][1] : "10001"; $subscriber->setSockOpt (ZMQ::SOCKOPT_SUBSCRIBE, $filter); // Process 100 updates $total_temp = 0; for ($update_nbr = 0; $update_nbr < 100; $update_nbr++) { $string = $subscriber->recv (); sscanf ($string, "%d %d %d", $zipcode, $temperature, $relhumidity); $total_temp += $temperature; } printf ("Average temperature for zipcode '%s' was %dF\n", $filter, (int) ($total_temp / $update_nbr));
這段代碼講的是,服務器端生成隨機數 zipcode、temperature、relhumidity 分別代表城市代碼、溫度值和溼度值。然後不斷的廣播信息,而客戶端通過設置過濾參數,接受特定城市代碼的信息,收集完了以後,做一個平均值。
a) 與 Hello World 不同的是,Socket 的類型變成 SOCKET_PUB 和 SOCKET_SUB 類型。
b) 客戶端需要$subscriber->setSockOpt (ZMQ::SOCKOPT_SUBSCRIBE, $filter);設置一個過濾值,相當於設定一個訂閱頻道,否則什麼信息也收不到。
c) 服務器端一直不斷的廣播中,如果中途有 Subscriber 端退出,並不影響他繼續的廣播,當 Subscriber 再連接上來的時候,收到的就是後來發送的新的信息了。這對比較晚加入的,或者是中途離開的訂閱者,必然會丟失掉一部分信息,這是這個模式的一個問題,所謂的 Slow joiner。稍後,會解決這個問題。
d) 但是,如果 Publisher 中途離開,所有的 Subscriber 會 hold 住,等待 Publisher 再上線的時候,會繼續接受信息。
ZMQ 的 PipeLine 模型
想象一下這樣的場景,如果需要統計各個機器的日誌,我們需要將統計任務分發到各個節點機器上,最後收集統計結果,做一個彙總。PipeLine 比較適合於這種場景,他的結構圖,如圖 3 所示。
圖3:ZMQ 的 PipeLine 模型
Parallel task ventilator in PHP
<?php /* * Task ventilator * Binds PUSH socket to tcp://localhost:5557 * Sends batch of tasks to workers via that socket * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ $context = new ZMQContext (); // Socket to send messages on $sender = new ZMQSocket ($context, ZMQ::SOCKET_PUSH); $sender->bind ("tcp://*:5557"); echo "Press Enter when the workers are ready: "; $fp = fopen('php://stdin', 'r'); $line = fgets($fp, 512); fclose($fp); echo "Sending tasks to workers…", PHP_EOL; // The first message is "0" and signals start of batch $sender->send (0); // Send 100 tasks $total_msec = 0; // Total expected cost in msecs for ($task_nbr = 0; $task_nbr < 100; $task_nbr++) { // Random workload from 1 to 100msecs $workload = mt_rand(1, 100); $total_msec += $workload; $sender->send ($workload); } printf ("Total expected cost: %d msec\n", $total_msec); sleep (1); // Give 0MQ time to deliver
Parallel task worker in PHP
<?php /* * Task worker * Connects PULL socket to tcp://localhost:5557 * Collects workloads from ventilator via that socket * Connects PUSH socket to tcp://localhost:5558 * Sends results to sink via that socket * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ $context = new ZMQContext (); // Socket to receive messages on $receiver = new ZMQSocket ($context, ZMQ::SOCKET_PULL); $receiver->connect ("tcp://localhost:5557"); // Socket to send messages to $sender = new ZMQSocket ($context, ZMQ::SOCKET_PUSH); $sender->connect ("tcp://localhost:5558"); // Process tasks forever while (true) { $string = $receiver->recv (); // Simple progress indicator for the viewer echo $string, PHP_EOL; // Do the work usleep($string * 1000); // Send results to sink $sender->send (""); }
Parallel task sink in PHP
<?php /* * Task sink * Binds PULL socket to tcp://localhost:5558 * Collects results from workers via that socket * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ // Prepare our context and socket $context = new ZMQContext (); $receiver = new ZMQSocket ($context, ZMQ::SOCKET_PULL); $receiver->bind ("tcp://*:5558"); // Wait for start of batch $string = $receiver->recv (); // Start our clock now $tstart = microtime(true); // Process 100 confirmations $total_msec = 0; // Total calculated cost in msecs for ($task_nbr = 0; $task_nbr < 100; $task_nbr++) { $string = $receiver->recv (); if($task_nbr % 10 == 0) { echo ":"; } else { echo "."; } } $tend = microtime(true); $total_msec = ($tend - $tstart) * 1000; echo PHP_EOL; printf ("Total elapsed time: %d msec", $total_msec); echo PHP_EOL;
從程序中,我們可以看到,task ventilator 使用的是 SOCKET_PUSH,將任務分發到 Worker 節點上。而 Worker 節點上,使用 SOCKET_PULL 從上游接受任務,並使用 SOCKET_PUSH 將結果彙集到 Slink。值得注意的是,任務的分發的時候也同樣有一個負載均衡的路由功能,worker 可以隨時自由加入,task ventilator 可以均衡將任務分發出去。
五、其他擴展模式
通常,一個節點,即可以作爲 Server,同時也能作爲 Client,通過 PipeLine 模型中的 Worker,他向上連接着任務分發,向下連接着結果蒐集的 Sink 機器。因此,我們可以藉助這種特性,豐富的擴展原有的三種模式。例如,一個代理 Publisher,作爲一個內網的 Subscriber 接受信息,同時將信息,轉發到外網,其結構圖如圖 4 所示。
圖4:ZMQ 的擴展模式
六、多個服務器
ZMQ 和 Socket 的區別在於,前者支持N:M的連接,而後者則只是1:1的連接,那麼一個 Client 連接多個 Server 的情況是怎樣的呢,我們通過圖 5 來說明。
圖5:ZMQ 的N:1的連接情況
我們假設 Client 有 R1,R2,R3,R4四個任務,我們只需要一個 ZMQ 的 Socket,就可以連接四個服務,他能夠自動均衡的分配任務。如圖 5 所示,R1,R4自動分配到了節點A,R2到了B,R3到了C。如果我們是N:M的情況呢?這個擴展起來,也不難,如圖 6 所示。
圖6:N:M的連接
我們通過一箇中間結點(Broker)來進行負載均衡的功能。我們通過代碼瞭解,其中的 Client 和我們的 Hello World 的 Client 端是一樣的,而 Server 端的不同是,他不需要監聽端口,而是需要連接 Broker 的端口,接受需要處理的信息。所以,我們重點閱讀 Broker 的代碼:
<?php /* * Simple request-reply broker * @author Ian Barber <ian (dot) barber (at) gmail (dot) com> */ // Prepare our context and sockets $context = new ZMQContext (); $frontend = new ZMQSocket ($context, ZMQ::SOCKET_ROUTER); $backend = new ZMQSocket ($context, ZMQ::SOCKET_DEALER); $frontend->bind ("tcp://*:5559"); $backend->bind ("tcp://*:5560"); // Initialize poll set $poll = new ZMQPoll (); $poll->add ($frontend, ZMQ::POLL_IN); $poll->add ($backend, ZMQ::POLL_IN); $readable = $writeable = array(); // Switch messages between sockets while(true) { $events = $poll->poll ($readable, $writeable); foreach($readable as $socket) { if($socket === $frontend) { // Process all parts of the message while(true) { $message = $socket->recv (); // Multipart detection $more = $socket->getSockOpt (ZMQ::SOCKOPT_RCVMORE); $backend->send ($message, $more ? ZMQ::MODE_SNDMORE : null); if(!$more) { break; // Last message part } } } else if($socket === $backend) { $message = $socket->recv (); // Multipart detection $more = $socket->getSockOpt (ZMQ::SOCKOPT_RCVMORE); $frontend->send ($message, $more ? ZMQ::MODE_SNDMORE : null); if(!$more) { break; // Last message part } } } }
Broker 監聽了兩個端口,接受從多個 Client 端發送過來的數據,並將數據,轉發給 Server。在 Broker 中,我們監聽了兩個端口,使用了兩個 Socket,那麼對於多個 Socket 的情況,我們是不需要通過輪詢的方式去處理數據的,在之前,我們可以使用 libevent 實現,異步的信息處理和傳輸。而現在,我們只需要使用 ZMQ 的$poll->poll 以實現多個 Socket 的異步處理。
七、進程間的通信
ZMQ 不僅能通過 TCP 完成節點間的通信,也可以通過 Socket 文件完成進程間的通信。如圖 7 所示,我們 fork 三個 PHP 進程,將進程 1 的數據,通過 Socket 文件發送到進程3。
圖7:進程間的通信
<?php function step1() { $context = new ZMQContext (); // Signal downstream to step 2 $sender = new ZMQSocket ($context, ZMQ::SOCKET_PAIR); $sender->connect ("ipc://step2.ipc"); $sender->send ("hello ,i am step1"); } function step2() { $pid = pcntl_fork (); if($pid == 0) { step1(); exit(); } $context = new ZMQContext (); // Bind to ipc: endpoint, then start upstream thread $receiver = new ZMQSocket ($context, ZMQ::SOCKET_PAIR); $receiver->bind ("ipc://step2.ipc"); // Wait for signal sleep(10); $strings = $receiver->recv (); echo "step2 receiver is $strings". PHP_EOL; sleep(10); // Signal downstream to step 3 $sender = new ZMQSocket ($context, ZMQ::SOCKET_PAIR); $sender->connect ("ipc://step3.ipc"); $sender->send ($strings); } // Start upstream thread then bind to icp: endpoint $pid = pcntl_fork (); if($pid == 0) { step2(); exit(); } $context = new ZMQContext (); $receiver = new ZMQSocket ($context, ZMQ::SOCKET_PAIR); $receiver->bind ("ipc://step3.ipc"); // Wait for signal $sr = $receiver->recv (); echo "the result is {$sr}".PHP_EOL;
在運行中,我們可以看到多了兩個文件,如圖 8 所示。
圖8:運行過程中生成的文件
八、利用 ZeroMQ 實現一個配置推送中心
當我們將 WEB 代碼部署到集羣上的時候,如果需要實時的將最新的配置信息,主動的推送到各個機器節點。在此過程中,我們一定要保證,各個節點收到的信息的一致性和正確性,如果使用 HTTP,由於他的無狀態性,我們無法保證信息的一致性,當然,你可以使用 HTTP 來實現,只是更復雜,爲什麼不用 ZMQ?他能讓你更簡單的實現這些功能。
我們使用 ZMQ 的信息訂閱模式。在那個模式中,我們注意到,對於後來的加入節點,始終會丟失在他加入之前,已經發送的信息(Slow joiner)。我們可以開啓另外一個 ZMQ 的通信通道,用於報告當前節點的情況(節點的身份、準備狀態等),其結構如圖 9 所示。
圖9:擴展 ZMQ 的訂閱者模式
我們通過$context->getSocket (ZMQ::SOCKET_REQ);設置一個新的 Request-Reply 連接,來用於 Subscriber 向 Publisher 報告自己的身份信息,而 Publisher 則等待所有的 Subscriber 都連接上的時候,再選擇 Publish 自己的信息。
Subscriber 端的程序如下:
<?php $hostname = $_SERVER['argc'] > 1 ? $_SERVER['argv'][1] : "s1"; $context = new ZMQContext (2); $sub = new ZMQSocket ($context,ZMQ::SOCKET_SUB); $sub->connect ("tcp://localhost:5561"); //$subscriber->setSockOpt (ZMQ::SOCKOPT_IDENTITY, $hostname); $sub->setSockOpt (ZMQ::SOCKOPT_SUBSCRIBE,""); $client = $context->getSocket (ZMQ::SOCKET_REQ); $client->connect ("tcp://localhost:5562"); while(1) { //$client->connect ("tcp://localhost:5562"); $client->send ($hostname); $version = $client->recv (); echo $version."\r\n"; if (!empty($version)) { $recive = $sub->recv (); $vars = json_decode ($recive); var_dump($vars); } }
Publisher 端的程序如下:
<?php $CONFIG["TAOKE_BTS"]["ENABLE"] = true; $CONFIG["QP_BTS"]["ENABLE"] = true; $CONFIG["QP_BTS"]["TK_TEST"] = 13; $string = json_encode ($CONFIG); $clients = array("s2","s1","s3"); $context = new ZMQContext (10); //Socket talk to clients $publisher = new ZMQSocket ($context,ZMQ::SOCKET_PUB); $publisher->bind ("tcp://*:5561"); //Socket to publish message $server = new ZMQSocket ($context,ZMQ::SOCKET_REP); $server->bind ("tcp://*:5562"); while(count($clients)!=0) { $client_name = $server->recv (); echo "{$client_name} is connect!\r\n"; if (in_array($client_name, $clients)) { //coming one client $key = array_search($client_name, $clients); unset($clients[$key]); echo "$client_name has come in!\r\n"; $server->send ("Version is 2.0"); } else { $server->send ("You are a stranger!"); } } $publisher->send ($string); ?>
每個節點通過 5562 端口,使用 Rep 模式和 Publisher 連接,通過這個連接告之 Publisher 自己的機器名,而 Publisher 端通過白名單的方式,維護一個機器列表,當機器列表中所有的機器連接上來以後,通過 5561 端口,將最新的配置信息發送出去。
後續的處理,Subscriber 可以選擇將配置信息寫入到 APC 緩存,程序將始終從緩存中讀取部分配置信息,Subscriber 並將更新後的狀態信息,實時的通過 5562 報告給 Publisher。
雖然,在本示例中不會出現,但是,如果需要發佈的信息量過大,在接受信息的過程中,Subscriber 端突然中斷網絡(或者是程序崩潰),那麼當他在連接上來的時候,有部分信息就會丟失?ZMQ 考慮到這個問題,通過$subscriber->setSockOpt (ZMQ::SOCKOPT_IDENTITY, $hostname);設置一個 id,當這個 id 的 Subscriber 重新連接上來的時候,他可以從上次中斷的地方,繼續接受信息,當然,節點的中斷,不會影響其他的節點繼續的接受信息。
那麼 ZMQ 是怎麼實現斷線重連後,繼續發送信息呢 ?他會將斷開的 Subscriber 應該接受到的信息發到內存中,等待他重新上線後,將緩存的信息,繼續發送給他。當然,內存必然是有限的,過多就會出現內存溢出。ZMQ 通過
SetSockOpt (ZMQ::SOCKOPT_SWAP, 250000)設置 Swap 空間的大小,來防止 out of memory and crash。最終,我們的程序運行結果,如圖 10 所示。
圖 10:配置中心的運行結果
當然,這只是一個大體的思路,如果應用到實際的成產環境中,還需要考慮更多的問題,包含穩定性,容錯等等。然而,ZMQ 由於高併發,以及穩定性和易用性,前景不錯,他的目標是進入 Linux 內核,我們期待那一天的到來。
參考資料 :
http://www.infoq.com/cn/news/2010/09/introduction-zero-mq Infoq 對 zeromq 的簡介
http://zguide.zeromq.org/page:all ZeroMQ 的 guide 文檔