深刻NSQ 之旅[轉載]

介紹

NSQ是一個實時的分佈式消息平臺。它的設計目標是爲在多臺計算機上運行的鬆散服務提供一個現代化的基礎設施骨架。這篇文章介紹了 基於go語言的NSQ的內部架構,它可以爲高吞吐量的網絡服務器帶來 性能的優化,穩定性和魯棒性。能夠說, 若是不是由於咱們在bitly使用go語言,NSQ就不會存在。這裏既會講NSQ的功能也會涉及語言提供的特徵。固然,語言會影響思惟,此次也不例外。如今回想起來,選擇使用go語言已經收到了十倍的回報。由語言帶來的興奮和社區的積極反饋爲這個項目提供了極大的幫助。html

概要

NSQ是由3個進程組成的:linux

  • nsqd是一個接收、排隊、而後轉發消息到客戶端的進程。git

  • nsqlookupd 管理拓撲信息並提供最終一致性的發現服務。github

  • nsqadmin用於實時查看集羣的統計數據(而且執行各類各樣的管理任務)。golang

 

NSQ中的數據流模型是由streamsconsumers組成的tree。topic是一種獨特的stream。channel是一個訂閱了給定topic的consumers 邏輯分組。sql

    

單個nsqd能夠有多個topic,每一個topic能夠有多個channel。channel接收這個topic全部消息的副本,從而實現多播分發,而channel上的每一個消息被分發給它的訂閱者,從而實現負載均衡。這些基本成員組成了一個能夠表示各類簡單和複雜拓撲結構強大框架。有關NSQ的設計的更多信息請參見設計文檔shell

 

Topics 和 Channels

 

Topics 和 channels,是NSQ的核心成員,它們是如何使用go語言的特色來設計系統的最好示例。Go的channels(爲防止歧義,如下簡稱爲「go-chan」)是表達隊列的一種天然方式,所以一個NSQ的topic/channel,其核心就是一個存放消息指針的go-chan緩衝區。緩衝區的大小由  --mem-queue-size 配置參數肯定。編程

 

讀取數據後,向topic發佈消息的行爲包括:json

 

  • 實例化消息結構 (並分配消息體的字節數組)數組

  • read-lock 並得到 Topic

  • read-lock 並檢查是否能夠發佈

  • 發送到go-chan緩衝區

 

爲了從一個topic和它的channels得到消息,topic不能按典型的方式用go-chan來接收,由於多個goroutines在一個go-chan上接收將會分發消息,而指望的結果是把每一個消息複製到全部channel(goroutine)中。此外,每一個topic維護3個主要goroutine。第一個叫作 router,負責從傳入的go-chan中讀取新發布的消息,並存儲到一個隊列裏(內存或硬盤)。

第二個,稱爲 messagePump, 它負責複製和推送消息到如上所述的channel中。

第三個負責 DiskQueue IO,將在後面討論。

Channels稍微有點複雜,它的根本目的是向外暴露一個單輸入單輸出的go-chan(事實上從抽象的角度來講,消息可能存在內存裏或硬盤上);

   

另外,每個channel維護2個時間優先級隊列,用於延時和消息超時的處理(並有2個伴隨goroutine來監視它們)。並行化的改善是經過管理每一個channel的數據結構來實現,而不是依靠go運行時的全局定時器。

注意:在內部,go運行時使用一個優先級隊列和goroutine來管理定時器。它爲整個time包(但不侷限於)提供了支持。它一般不須要用戶來管理時間優先級隊列,但必定要記住,它是一個有鎖的數據結構,有可能會影響 GOMAXPROCS>1 的性能。請參閱runtime/time.goc

Backend / DiskQueue

NSQ的一個設計目標是綁定內存中的消息數目。它是經過DiskQueue(它擁有前面提到的的topic或channel的第三個goroutine)透明的把消息寫入到磁盤上來實現的。

因爲內存隊列只是一個go-chan,不必先把消息放到內存裏,若是可能的話,退回到磁盤上:

1
2
3
4
5
6
7
8
9
10
for  msg := range c.incomingMsgChan {
     select {
     case  c.memoryMsgChan <- msg:
     default :
         err := WriteMessageToBackend(&msgBuf, msg, c.backend)
         if  err != nil {
             // ... handle errors ...          }
     }
}

利用go語言的select語句,只須要幾行代碼就能夠實現這個功能:上面的default分支只有在memoryMsgChan 滿的狀況下才會執行。

NSQ也有臨時channel的概念。臨時channel會丟棄溢出的消息(而不是寫入到磁盤),當沒有客戶訂閱後它就會消失。這是一個Go接口的完美用例。Topics和channels有一個的結構成員被聲明爲Backend接口,而不是一個具體的類型。通常的 topics和channels使用DiskQueue,而臨時channel則使用了實現Backend接口的DummyBackendQueue。

 

減小垃圾回收的壓力

 

在任何帶有垃圾回收的環境裏,你都會多多少少感覺到吞吐量(工做有效性)、延遲(響應能力)、駐留集大小(內存使用量)的壓力。就 Go 1.2 而言,垃圾回收有標記-清除(併發的)、再也不生、不緊湊、阻止一切運行、大致精準的特色。大致精準是由於剩下的工做沒有及時的完成(這是 Go 1.3 的計劃)。Go 的垃圾回收機制固然會持續改進,但廣泛的真理是:建立的垃圾越少,回收垃圾的時間越少。

首先,理解垃圾回收是如何在實際的工做負載中運行的是很是重要的。爲此,nsqd 以 statsd 的格式 (與其它內部指標一塊兒) 發佈垃圾回收的統計信息。nsqadmin 顯示這些指標的圖表,可讓你深刻了解它在頻率和持續時間兩方面產生的影響:

 

 

 

爲了減小垃圾,你須要知道它們是在哪生成的。再次回到Go的工具鏈,它提供的答案以下:

 

  • 使用testing包和go test -benchmen來基準測試熱點代碼路徑。它配置了每一個迭代分配的數字(基準的運行可與benchcmp進行比較)。

  • 使用 go build -gcflags -m 建立,將會輸出逃逸分析的結果。

 

除此以外,它還提供了nsqd 的以下優化:

 

  • 避免把[]byte 轉化爲字符串類型.

  • 重複使用緩存或者對象(有時也許是sync.Pool又稱爲issue4720).

  • 預分配切片(特別是make的能力)並老是知曉鏈中各個條目的數量和大小。

  • 提供各類配置面板(如消息大小)的限制。

  • 避免封裝(如使用interface{})或者沒必要要的包裝類(例如 用一struct給一個多值的go-chan).

  • 在熱代碼路徑(它指定的)中避免使用defer。

 

TCP 協議

 

NSQ的TCP協議是一個閃亮的會話典範,在這個會話中垃圾回收優化的理論發揮了極大的效用。

 

協議的結構是一個有很長的前綴框架,這使得協議更直接,易於編碼和解碼。

 

1
2
3
4
5
[x][x][x][x][x][x][x][x][x][x][x][x]...
|  (int32) ||  (int32) || (binary)
|  4-byte  ||  4-byte  || N-byte
------------------------------------...
     size      frame ID     data

 

由於框架的組成部分的確切類型和大小是提早知道的,因此咱們能夠規避了使用方便的編碼二進制包的Read()和Write()封裝(及它們外部接口的查找和會話)反之咱們使用直接調用 binary.BigEndian方法。

 

爲了消除socket 輸入輸出的系統調用,客戶端net.Conn被封裝了bufio.Readerbufio.Writer。這個Reader經過暴露ReadSlice(),複用了它本身的緩衝區。這樣幾乎消除了讀完socket時的分配,這極大的下降了垃圾回收的壓力。這多是由於與數據相關的大多數命令並無逃逸(在邊緣狀況下這是假的,數據被強制複製)。

在更低層,MessageID 被定義爲 [16]byte,這樣能夠將其做爲 map 的 key(slice 沒法用做 map 的 key)。然而,考慮到從 socket 讀取的數據被保存爲 []byte,勝於經過分配字符串類型的 key 來產生垃圾,而且爲了不從 slice 到 MessageID 的支撐數組產生複製操做,unsafe 包被用來將 slice 直接轉換爲 MessageID:

1
id := *(*nsq.MessageID)(unsafe.Pointer(&msgID))

 

注意: 這是個技巧。若是編譯器對此已經作了優化,或者 Issue 3512 被打開可能會解決這個問題,那就不須要它了。issue 5376 也值得通讀,它講述了在無須分配和拷貝時,和 string 類型可被接收的地方,能夠交換使用的「類常量」的 byte 類型。

相似的,Go 標準庫僅僅在 string 上提供了數值轉換方法。爲了不 string 的分配,nsqd 使用了 慣用的十進制轉換方法,用於對 []byte 直接操做。

這些看起來像是微優化,但 TCP 協議包含了一些最熱的代碼執行路徑。整體來講,以每秒數萬消息的速度來講,它們對分配和系統開銷的數量有着顯著的影響:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
benchmark                    old ns/op     new  ns/op    delta
BenchmarkProtocolV2Data           3575         1963  -45.09%
  
benchmark                    old ns/op     new  ns/op    delta
BenchmarkProtocolV2Sub256        57964        14568  -74.87%
BenchmarkProtocolV2Sub512        58212        16193  -72.18%
BenchmarkProtocolV2Sub1k         58549        19490  -66.71%
BenchmarkProtocolV2Sub2k         63430        27840  -56.11%
  
benchmark                   old allocs    new  allocs    delta
BenchmarkProtocolV2Sub256           56           39  -30.36%
BenchmarkProtocolV2Sub512           56           39  -30.36%
BenchmarkProtocolV2Sub1k            56           39  -30.36%
BenchmarkProtocolV2Sub2k            58           42  -27.59%
 

 

HTTP

 

NSQ的HTTP API是基於 Go's net/http 包實現的. 就是 常見的HTTP應用,在大多數高級編程語言中都能直接使用而無需額外的三方包。 簡潔就是它最有力的武器,Go的 HTTP tool-chest最強大的就是其調試功能.  net/http/pprof 包直接集成了HTTP server,能夠方便的訪問CPU, heap,    goroutine, and OS 進程文檔 .gotool就能直接實現上述操做:

 

1
$ go tool pprof http: //127 .0.0.1:4151 /debug/pprof/profile

 

 

 

這對於調試和實時監控進程很是有用!

此外,/stats端端返回JSON或是美觀的文本格式信息,這讓管理員使用命令行實時監控很是容易:

 

1
watch  -n 0.5  'curl -s http://127.0.0.1:4151/stats | grep -v connected'

 

 打印出的結果以下:

 

 

此外, Go 1.2 還有不少監控指標measurable HTTP performance gains. 每次更新Go版本後都能看到性能方面的改進,真是讓人振奮!

依賴關係

源於其它生態系統,使用GO(理論匱乏)語言的依賴管理還得花點時間去適應

NSQ 就並非單一的整個 repo庫, 經過 _relative imports_ 而無需區別內部的包資源, 最終產生結構化的依賴管理。

主流的觀點有如下兩個:

  • Vendoring:拷貝應用須要的正確版本號到本地倉庫並修改import 路徑到本地庫地址

  • Virtual Env: 列出構建是須要的版本信息,建立包含相關信息的GOPATH環境變量 

Note: 這僅僅應用於二級制包,對於可導入的包版本不起做用

NSQ使用 godep提供 (2) 中的實現.

它的實現原理是複製依賴關係到 Godeps文件中, 以後生成GOPATH環境變量。構建時,它使用Go環境中的工具鏈 來完成工做。 Godeps就是json格式,能夠手動修改。

它還支持go的get. 例如,構建一個 NSQ版本:

1
$ godep get github.com /bitly/nsq/ ...

 

測試

 

Go語言提供了內置的測試和基線。因爲其簡單的併發操做建模,在測試環境里加入nsqd 實例垂手可得。可是,在測試初始化的時候會有個問題:全局狀態。最明顯的就是引用運行態nsqd 實例的全局變量 i.e.var nsqd *NSQd.

因而某些測試就無可避免的使用局部變量去保存該值i.e.nsqd := NewNSQd(...).這也就意味着全局狀態並未指向運行態的值,使測試失去了意義。

應對這個問題,Context結構體被引入以保存配置項metadata和實時nsqd的父類。全部全局狀態的子引用都經過訪問該Context來安全的獲取相應值(主題,渠道,協議處理等等),這樣測試起來也更有保障。

可靠性

一個系統,若是在面對變幻的網絡環境和不可預知的事件時不具有可靠性,將不會是一個表現良好的分佈式生產環境。NSQ的設計和實現方式,使它能容忍錯誤並以一種始終如一的,可預期的和穩定的方式來運行。它的首要的設計哲學是快速失敗,認爲錯誤都是致命的,並提供一種方式來調試遇到的任何問題。不過,爲了能有所行動,你必需要可以檢測異常環境...

心跳檢測和超時

NSQ的TCP協議是須要推送的.在通過創建鏈接,三次握手,客戶在aRDYstate的訂閱數被置爲0.當準備接受消息時,經過更新RDYstate來控制將要接受的消息數目。NSQ 客戶端libraries將在後臺持續管理這一環節,最終造成相應的消息流。 週期性的, nsqd 會發送心跳檢測鏈接狀態.客戶端能夠設置這個間隔時間但nsqd須要在發送下調指令前收到上條請求的回覆。 應用層面的心跳檢測和RDYstate組合可以避免 head-of-line blocking,它會是心跳檢測失效 (i.e.若是用戶等待處理消息前OS的緩存已滿,則心跳檢測失效).

爲了確保進程的正常工做,全部的網絡IO都會依據心跳檢測的間隔時間來設置邊界.這意味着你甚至能夠斷開客戶端和 nsqd 的網絡鏈接,而沒必要擔憂問題被發現並恰當的處理。一旦發現致命錯誤,客戶鏈接將被強關。發送中的消息超時並重新加入新的客戶端接受隊列。最後,錯誤日誌會被保存並增長內部評價矩陣內容。

 

管理Goroutines

 

啓用goroutines很簡單,但後續工做卻不是那麼容易弄好的。避免出現死鎖是一個挑戰。一般都是由於在排序上出了問題,goroutine可能在接到上游的消息前就收到了go-chan的退出信號。爲啥提到這個?簡單,一個未正確處理的goroutine就是內存泄露。更深刻的分析,nsqd 進程含有多個激活的goroutines。從內部狀況來看,消息的全部權是不停在變得。爲了能正確的關掉goroutines,實時統計全部的進程信息是很是重要的。雖沒有什麼神奇的方法,但下面的幾點能讓工做簡單一點...

 

WaitGroups

 

sync 包提供了 sync.WaitGroup, 它能夠計算出激活態的goroutines數(比提供退出的平均等待時間)

 

爲了使代碼簡潔nsqd 使用以下wrapper:

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type WaitGroupWrapper  struct  {
     sync.WaitGroup
}
  
func (w *WaitGroupWrapper) Wrap(cb func()) {
     w.Add(1)
     go func() {
         cb()
         w.Done()
     }()
}
  // can be used as follows: wg := WaitGroupWrapper{}
wg.Wrap(func() { n.idPump() }) // ... wg.Wait()

 

 

 

退出信號

在含有多個子goroutines中觸發事件最簡單的辦法就是用一個go-chan,並在完成後關閉。全部當中暫停的動做將被激活,這就無需再向每一個goroutine發送相關的信號了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type WaitGroupWrapper  struct  {
     sync.WaitGroup
}
  
func (w *WaitGroupWrapper) Wrap(cb func()) {
     w.Add(1)
     go func() {
         cb()
         w.Done()
     }()
}
  // can be used as follows: wg := WaitGroupWrapper{}
wg.Wrap(func() { n.idPump() }) // ... wg.Wait()

 

同步退出

 

想可靠的,無死鎖,全部路徑都保有信息的實現是很難的。下面是一些提示:

 

  • 理想狀況下,在go-chan發送消息的goroutine也應爲關閉消息負責.

  • 若是消息須要保留,確保相關go-chans被清空(尤爲是無緩衝的!),以保證發送者能夠繼續進程.

  • 另外,若是消息再也不是相關的,在單個go-chan上的進程應該轉換到包含推出信號的select上 (如上所述)以保證發送者能夠繼續進程.

 

通常的順序應該是:

 

  • 中止接受新的鏈接(中止監聽)

  • 向goroutines發出退出信號(見上文)

  • 等待WaitGroup的goroutine中退出(見上文)

  • 恢復緩衝數據

  • 剩下的部分保存到磁盤

 

日誌

 

最後,最重要的工做是記錄你的Go例程的入口和出口日誌!這使得它更容易識別死鎖或泄漏的狀況。nsqd日誌行包括信息Go例程與他們的兄弟姐妹(和父母),如客戶端的遠程地址或主題/渠道名。日誌是冗長的,但還不至於到接受不了的程度。這個是有兩面性的,但nsqd傾斜當故障發生時向日志中放入更多的信息,,而不是爲了不繁瑣而下降日誌定位問題的有效性。

 

原文地址: http://www.oschina.net/translate/day-22-a-journey-into-nsq

相關文章:

nsq初探

相關文章
相關標籤/搜索