NSQ 由 3 個(gè)守護(hù)進(jìn)程組成:
nsqd 是接收、隊(duì)列和傳送消息到客戶端的守護(hù)進(jìn)程。
nsqlookupd 是管理的拓?fù)湫畔?,并提供了最終一致發(fā)現(xiàn)服務(wù)的守護(hù)進(jìn)程。
在 NSQ 數(shù)據(jù)流建模為一個(gè)消息流和消費(fèi)者的樹。一個(gè)話題(topic)是一個(gè)獨(dú)特的數(shù)據(jù)流。一個(gè) 通道(channel) 是消費(fèi)者訂閱了某個(gè) 話題 的邏輯分組。
http://wiki.jikexueyuan.com/project/nsq-guide/images/internal1.gif" alt="topics/channels" />
單個(gè) nsqd 可以有很多的話題,每個(gè)話題可以有多通道。一個(gè)通道接收到一個(gè)話題中所有消息的副本,啟用組播方式的傳輸,使消息同時(shí)在每個(gè)通道的所有訂閱用戶間分發(fā),從而實(shí)現(xiàn)負(fù)載平衡。
這些原語組成一個(gè)強(qiáng)大的框架,用于表示各種簡單和復(fù)雜的拓?fù)浣Y(jié)構(gòu)。
有關(guān) NSQ 的設(shè)計(jì)的更多信息請參見設(shè)計(jì)文檔。
話題(topic)和通道(channel),NSQ 的核心基礎(chǔ),最能說明如何把 Go 語言的特點(diǎn)無縫地轉(zhuǎn)化為系統(tǒng)設(shè)計(jì)。
Go 語言中的通道(channel)(為消除歧義以下簡稱為“go-chan”)是實(shí)現(xiàn)隊(duì)列一種自然的方式,因此一個(gè) NSQ 話題(topic)/通道(channel),其核心,只是一個(gè)緩沖的 go-chan Message指針。緩沖區(qū)的大小等于 --mem-queue-size 的配置參數(shù)。
在懂了讀數(shù)據(jù)后,發(fā)布消息到一個(gè)話題(topic)的行為涉及到:
話題(topic) 時(shí)的讀-鎖;從一個(gè)話題中的通道獲取消息不能依賴于經(jīng)典的 go-chan 語義,因?yàn)槎鄠€(gè) goroutines 在一個(gè) go-chan 上接收消息將會分發(fā)消息,而最終要的結(jié)果是復(fù)制每個(gè)消息到每一個(gè)通道(goroutine)。
替代的是,每個(gè)話題維護(hù)著 3 個(gè)主要的 goroutines。第一個(gè)被稱為 router,它負(fù)責(zé)用來從 incoming go-chan 讀取最近發(fā)布的消息,并把消息保存到隊(duì)列中(內(nèi)存或硬盤)。
第二個(gè),稱為 messagePump,是負(fù)責(zé)復(fù)制和推送消息到如上所述的通道。
第三個(gè)是負(fù)責(zé) DiskQueue IO 和將在后面討論。
通道是一個(gè)有點(diǎn)復(fù)雜,但共享著 go-chan 單一輸入和輸出(抽象出來的事實(shí)是,在內(nèi)部,消息可能會在內(nèi)存或磁盤上):
http://wiki.jikexueyuan.com/project/nsq-guide/images/internal2.png" alt="queue goroutine" />
此外,每個(gè)通道的維護(hù)負(fù)責(zé) 2 個(gè)時(shí)間排序優(yōu)先級隊(duì)列,用來實(shí)現(xiàn)傳輸中(in-flight)消息超時(shí)(第 2 個(gè)隨行 goroutines 用于監(jiān)視它們)。
并行化的提高是通過每個(gè)數(shù)據(jù)結(jié)構(gòu)管理一個(gè)通道,而不是依靠 Go 運(yùn)行時(shí)的全局定時(shí)器調(diào)度。
注意:在內(nèi)部,Go 運(yùn)行時(shí)使用一個(gè)單一優(yōu)先級隊(duì)列和的 goroutine 來管理定時(shí)器。這支持(但不局限于)的整個(gè) time package。它通常避免了需要一個(gè)用戶空間的時(shí)間順序的優(yōu)先級隊(duì)列,但要意識到這是一個(gè)很重要的一個(gè)有著單一鎖的數(shù)據(jù)結(jié)構(gòu),有可能影響GOMAXPROCS > 1 的表現(xiàn)。
NSQ 的設(shè)計(jì)目標(biāo)之一就是要限定保持在內(nèi)存中的消息數(shù)。它通過 DiskQueue 透明地將溢出的消息寫入到磁盤上(對于一個(gè)話題或通道而言,DiskQueue 擁有的第三個(gè)主要的 goroutine)。
由于內(nèi)存隊(duì)列只是一個(gè) go-chan,把消息放到內(nèi)存中顯得不重要,如果可能的話,則退回到磁盤:
for msg := range c.incomingMsgChan {
select {
case c.memoryMsgChan <- msg:
default:
err := WriteMessageToBackend(&msgBuf, msg, c.backend)
if err != nil {
// ... handle errors ...
}
}
}
說到 Go select 語句的優(yōu)勢在于用在短短的幾行代碼實(shí)現(xiàn)這個(gè)功能:default 語句只在 memoryMsgChan 已滿的情況下執(zhí)行。
NSQ 還具有的臨時(shí)通道的概念。臨時(shí)的通道將丟棄溢出的消息(而不是寫入到磁盤),在沒有客戶端訂閱時(shí)消失。這是一個(gè)完美的 Go’s Interface 案例。話題和通道有一個(gè)結(jié)構(gòu)成員聲明為一個(gè) Backend interface,而不是一個(gè)具體的類型。正常的話題和通道使用 DiskQueue,而臨時(shí)通道連接在 DummyBackendQueue中,它實(shí)現(xiàn)了一個(gè) no-op 的Backend。
在任何垃圾回收環(huán)境中,你可能會關(guān)注到吞吐量量(做無用功),延遲(響應(yīng)),并駐留集大?。╢ootprint)。
Go 的1.2版本,GC 采用,mark-and-sweep (parallel), non-generational, non-compacting, stop-the-world 和 mostly precise。這主要是因?yàn)槭S嗟墓ぷ魑赐瓿桑ㄋA(yù)定于Go 1.3 實(shí)現(xiàn))。
Go 的 GC 一定會不斷改進(jìn),但普遍的真理是:你創(chuàng)建的垃圾越少,收集的時(shí)間越少。
首先,重要的是要了解 GC 在真實(shí)的工作負(fù)載下是如何表現(xiàn)。為此,nsqd 以 statsd 格式發(fā)布的 GC 統(tǒng)計(jì)(伴隨著其他的內(nèi)部指標(biāo))。nsqadmin 顯示這些度量的圖表,讓您洞察 GC 的影響,頻率和持續(xù)時(shí)間:
http://wiki.jikexueyuan.com/project/nsq-guide/images/internal3.png" alt="single node view" />
為了切實(shí)減少垃圾,你需要知道它是如何生成的。再次 Go toolchain 提供了答案:
testing package 和 go test -benchmem 來 benchmark 熱點(diǎn)代碼路徑。它分析每個(gè)迭代分配的內(nèi)存數(shù)量(和 benchmark 運(yùn)行可以用 benchcmp 進(jìn)行比較)。考慮到這一點(diǎn),下面的優(yōu)化證明對 nsqd 是有用的:
[]byte 到 string 的轉(zhuǎn)換sync.Pool 又名 issue 4720)make 時(shí)指定容量)并且總是知道其中承載元素的數(shù)量和大小interface{})或一些不必要的包裝類型(例如一個(gè)多值的”go-chan” 結(jié)構(gòu)體)defer (它也消耗內(nèi)存)NSQ 的 TCP 協(xié)議 protocol_spec 是一個(gè)這些 GC 優(yōu)化概念發(fā)揮了很大作用的的例子。
該協(xié)議用含有長度前綴的幀構(gòu)造,使其可以直接高效的編碼和解碼:
[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
由于提前知道了幀部件的確切類型與大小,我們避免了 encoding/binary 便利 Read() 和 Write() 包裝(以及它們外部 interface 的查詢與轉(zhuǎn)換),而是直接調(diào)用相應(yīng)的 binary.BigEndian 方法。
為了減少 socket 的 IO 系統(tǒng)調(diào)用,客戶端 net.Conn 都用 bufio.Reader 和bufio.Writer 包裝。Reader 暴露了 ReadSlice() ,它會重復(fù)使用其內(nèi)部緩沖區(qū)。這幾乎消除了從 socket 讀出數(shù)據(jù)的內(nèi)存分配,大大降低 GC 的壓力。這可能是因?yàn)榕c大多數(shù)命令關(guān)聯(lián)的數(shù)據(jù)不會被忽視(在邊緣情況下,這是不正確的,數(shù)據(jù)是顯示復(fù)制的)。
在一個(gè)更低的水平,提供一個(gè) MessageID 被聲明為 [16]byte,以便能夠把它作為一個(gè) map key(slice 不能被用作 map key)。然而,由于從 socket 讀取數(shù)據(jù)存儲為 []byte,而不是通過分配字符串鍵產(chǎn)生垃圾,并避免從 slice 的副本拷貝的數(shù)組形式的MessageID, unsafe package 是用來直接把 slice 轉(zhuǎn)換成一個(gè) MessageID:
id := *(*nsq.MessageID)(unsafe.Pointer(&msgID))
注: 這是一個(gè) hack。它將不是必要的,如果編譯器優(yōu) 和 Issue 3512 解決這個(gè)問題。另外值得一讀通過issue 5376,其中談到的“const like” byte 類型 與 string 類型可以互換使用,而不需要分配和復(fù)制。
同樣,Go 標(biāo)準(zhǔn)庫只提供了一個(gè)數(shù)字轉(zhuǎn)換成 string 的方法。為了避免 string 分配,nsqd 使用一個(gè)自定義的10進(jìn)制轉(zhuǎn)換方法在 []byte 直接操作。
這些看似微觀優(yōu)化,但卻包含了 TCP 協(xié)議中一些最熱門的代碼路徑??傮w而言,每秒上萬消息的速度,對分配和開銷的數(shù)目顯著影響:
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%
NSQ 的 HTTP API 是建立在 Go 的 net/http 包之上。因?yàn)樗皇?net/http,它可以利用沒有特殊的客戶端庫的幾乎所有現(xiàn)代編程環(huán)境。
它的簡單性掩蓋了它的能力,作為 Go 的 HTTP tool-chest 最有趣的方面之一是廣泛的調(diào)試功能支持。該 net/http/pprof 包直接集成了原生的 HTTP 服務(wù)器,暴露獲取 CPU,堆,goroutine 和操作系統(tǒng)線程性能的 endpoints。這些可以直接從 go tool 找到:
$ go tool pprof http://127.0.0.1:4151/debug/pprof/profile
這對調(diào)試和分析一個(gè)運(yùn)行的進(jìn)程非常有價(jià)值!
此外,/stats endpoint 返回的指標(biāo)以任何 JSON 或良好格式的文本來呈現(xiàn),很容易使管理員能夠?qū)崟r(shí)從命令行監(jiān)控:
$ watch -n 0.5 'curl -s http://127.0.0.1:4151/stats | grep -v connected'
這產(chǎn)生的連續(xù)輸出如下:
[page_views ] depth: 0 be-depth: 0 msgs: 105525994 e2e%: 6.6s, 6.2s, 6.2s
[page_view_counter ] depth: 0 be-depth: 0 inflt: 432 def: 0 re-q: 34684 timeout: 34038 msgs: 105525994 e2e%: 5.1s, 5.1s, 4.6s
[realtime_score ] depth: 1828 be-depth: 0 inflt: 1368 def: 0 re-q: 25188 timeout: 11336 msgs: 105525994 e2e%: 9.0s, 9.0s, 7.8s
[variants_writer ] depth: 0 be-depth: 0 inflt: 592 def: 0 re-q: 37068 timeout: 37068 msgs: 105525994 e2e%: 8.2s, 8.2s, 8.2s
[poll_requests ] depth: 0 be-depth: 0 msgs: 11485060 e2e%: 167.5ms, 167.5ms, 138.1ms
[social_data_collector ] depth: 0 be-depth: 0 inflt: 2 def: 3 re-q: 7568 timeout: 402 msgs: 11485060 e2e%: 186.6ms, 186.6ms, 138.1ms
[social_data ] depth: 0 be-depth: 0 msgs: 60145188 e2e%: 199.0s, 199.0s, 199.0s
[events_writer ] depth: 0 be-depth: 0 inflt: 226 def: 0 re-q: 32584 timeout: 30542 msgs: 60145188 e2e%: 6.7s, 6.7s, 6.7s
[social_delta_counter ] depth: 17328 be-depth: 7327 inflt: 179 def: 1 re-q: 155843 timeout: 11514 msgs: 60145188 e2e%: 234.1s, 234.1s, 231.8s
[time_on_site_ticks] depth: 0 be-depth: 0 msgs: 35717814 e2e%: 0.0ns, 0.0ns, 0.0ns
[tail821042#ephemeral ] depth: 0 be-depth: 0 inflt: 0 def: 0 re-q: 0 timeout: 0 msgs: 33909699 e2e%: 0.0ns, 0.0ns, 0.0ns
最后,每個(gè) Go release 版本帶來可觀的 HTTP 性能提升autobench。與 Go 的最新版本重新編譯時(shí),它總是很高興為您提供免費(fèi)的性能提升!
對于其它生態(tài)系統(tǒng),Go 依賴關(guān)系管理(或缺乏)的哲學(xué)需要一點(diǎn)時(shí)間去適應(yīng)。
NSQ 從一個(gè)單一的巨大倉庫衍化而來的,包含相關(guān)的 imports 和小到未分離的內(nèi)部 packages,完全遵守構(gòu)建和依賴管理的最佳實(shí)踐。
有兩大流派的思想:
GOPATH 環(huán)境變量包含這些固定依賴。注: 這確實(shí)只適用于二進(jìn)制包,因?yàn)樗鼪]有任何意義的一個(gè)導(dǎo)入的包,使中間的決定,如一種依賴使用的版本。
NSQ 使用 gpm 提供如上述2種的支持。
它的工作原理是在 Godeps 文件記錄你的依賴,方便日后構(gòu)建 GOPATH 環(huán)境。為了編譯,它在環(huán)境里包裝并執(zhí)行的標(biāo)準(zhǔn) Go toolchain。該 Godeps 文件僅僅是 JSON 格式,可以進(jìn)行手工編輯。
Go 提供了編寫測試和基準(zhǔn)測試的內(nèi)建支持,這使用 Go 很容易并發(fā)操作進(jìn)行建模,這是微不足道的建立起來的一個(gè)完整的實(shí)例 nsqd 到您的測試環(huán)境中。
然而,最初實(shí)現(xiàn)有可能變成測試問題的一個(gè)方面:全局狀態(tài)。最明顯的 offender 是運(yùn)行時(shí)使用該持有 nsqd 的引用實(shí)例的全局變量,例如包含配置元數(shù)據(jù)和到 parent nsqd 的引用。
某些測試會使用短形式的變量賦值,無意中在局部范圍掩蓋這個(gè)全局變量,即 nsqd := NewNSQd(...) 。這意味著,全局引用沒有指向了當(dāng)前正在運(yùn)行的實(shí)例,破壞了測試實(shí)例。
要解決這個(gè)問題,一個(gè)包含配置元數(shù)據(jù)和到 parent nsqd 的引用上下文結(jié)構(gòu)被傳來傳去。到全局狀態(tài)的所有引用都替換為本地的語境,允許 children(話題(topic),通道(channel),協(xié)議處理程序等)來安全地訪問這些數(shù)據(jù),使之更可靠的測試。
一個(gè)面對不斷變化的網(wǎng)絡(luò)條件或突發(fā)事件不健壯的系統(tǒng),不會是一個(gè)在分布式生產(chǎn)環(huán)境中表現(xiàn)良好的系統(tǒng)。
NSQ 設(shè)計(jì)和的方式是使系統(tǒng)能夠容忍故障而表現(xiàn)出一致的,可預(yù)測的和令人吃驚的方式來實(shí)現(xiàn)。
總體理念是快速失敗,把錯(cuò)誤當(dāng)作是致命的,并提供了一種方式來調(diào)試發(fā)生的任何問題。
但是,為了應(yīng)對,你需要能夠檢測異常情況。
NSQ 的 TCP 協(xié)議是面向 push 的。在建立連接,握手,和訂閱后,消費(fèi)者被放置在一個(gè)為 0 的 RDY 狀態(tài)。當(dāng)消費(fèi)者準(zhǔn)備好接收消息,它更新的 RDY 狀態(tài)到準(zhǔn)備接收消息的數(shù)量。NSQ 客戶端庫不斷在幕后管理,消息控制流的結(jié)果。
每隔一段時(shí)間,nsqd 將發(fā)送一個(gè)心跳線連接??蛻舳丝梢耘渲眯奶g的間隔,但 nsqd 會期待一個(gè)回應(yīng)在它發(fā)送下一個(gè)心掉之前。
組合應(yīng)用級別的心跳和 RDY 狀態(tài),避免頭阻塞現(xiàn)象,也可能使心跳無用(即,如果消費(fèi)者是在后面的處理消息流的接收緩沖區(qū)中,操作系統(tǒng)將被填滿,堵心跳)
為了保證進(jìn)度,所有的網(wǎng)絡(luò) IO 時(shí)間上限勢必與配置的心跳間隔相關(guān)聯(lián)。這意味著,你可以從字面上拔掉之間的網(wǎng)絡(luò)連接 nsqd 和消費(fèi)者,它會檢測并正確處理錯(cuò)誤。
當(dāng)檢測到一個(gè)致命錯(cuò)誤,客戶端連接被強(qiáng)制關(guān)閉。在傳輸中的消息會超時(shí)而重新排隊(duì)等待傳遞到另一個(gè)消費(fèi)者。最后,錯(cuò)誤會被記錄并累計(jì)到各種內(nèi)部指標(biāo)。
非常容易啟動 goroutine。不幸的是,不是很容易以協(xié)調(diào)他們的清理工作。避免死鎖也極具挑戰(zhàn)性。大多數(shù)情況下這可以歸結(jié)為一個(gè)順序的問題,在上游 goroutine 發(fā)送消息到 go-chan 之前,另一個(gè) goroutine 從 go-chan 上接收消息。
為什么要關(guān)心這些?這很顯然,孤立的 goroutine 是內(nèi)存泄漏。內(nèi)存泄露在長期運(yùn)行的守護(hù)進(jìn)程中是相當(dāng)糟糕的,尤其當(dāng)期望的是你的進(jìn)程能夠穩(wěn)定運(yùn)行,但其它都失敗了。
更復(fù)雜的是,一個(gè)典型的 nsqd 進(jìn)程中有許多參與消息傳遞 goroutines。在內(nèi)部,消息的“所有權(quán)”頻繁變化。為了能夠完全關(guān)閉,統(tǒng)計(jì)全部進(jìn)程內(nèi)的消息是非常重要的。
雖然目前還沒有任何靈丹妙藥,下列技術(shù)使它變得更輕松管理。
sync 包提供了 sync.WaitGroup, 可以被用來累計(jì)多少個(gè) goroutine 是活躍的(并且意味著一直等待直到它們退出)。
為了減少典型樣板,nsqd 使用以下裝飾器:
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()
有一個(gè)簡單的方式在多個(gè) child goroutine 中觸發(fā)一個(gè)事件是提供一個(gè) go-chane,當(dāng)你準(zhǔn)備好時(shí)關(guān)閉它。所有在那個(gè) go-chan 上掛起的 go-chan 都將會被激活,而不是向每個(gè) goroutine 中發(fā)送一個(gè)單獨(dú)的信號。
func work() {
exitChan := make(chan int)
go task1(exitChan)
go task2(exitChan)
time.Sleep(5 * time.Second)
close(exitChan)
}
func task1(exitChan chan int) {
<-exitChan
log.Printf("task1 exiting")
}
func task2(exitChan chan int) {
<-exitChan
log.Printf("task2 exiting")
}
實(shí)現(xiàn)一個(gè)可靠的,無死鎖的,所有傳遞中的消息的退出路徑是相當(dāng)困難的。一些提示:
理想的情況是負(fù)責(zé)發(fā)送到 go-chan 的 goroutine 中也應(yīng)負(fù)責(zé)關(guān)閉它。
如果 message 不能丟失,確保相關(guān)的 go-chan 被清空(尤其是無緩沖的!),以保證發(fā)送者可以取得進(jìn)展。
另外,如果消息是不重要的,發(fā)送給一個(gè)單一的 go-chan 應(yīng)轉(zhuǎn)換為一個(gè) select 附加一個(gè)退出信號(如上所述),以保證取得進(jìn)展。
一般的順序應(yīng)該是
WaitGroup 等待 goroutine 退出(如上文)最后,日志是您所獲得的記錄 goroutine 進(jìn)入和退出的重要工具!。這使得它相當(dāng)容易識別造成死鎖或泄漏的情況的罪魁禍?zhǔn)住?/p>
nsqd 日志行包括 goroutine 與他們的 siblings(and parent)的信息,如客戶端的遠(yuǎn)程地址或話題(topic)/通道(channel)名。
該日志是詳細(xì)的,但不是詳細(xì)的日志是壓倒性的。有一條細(xì)線,但 nsqd 傾向于發(fā)生故障時(shí)在日志中提供更多的信息,而不是試圖減少繁瑣的有效性為代價(jià)。