NSQ針對消費者采取消息推送的方式,因為NSQ本身基于內存和diskq,并不能容忍太大的消息的堆積,使用推模式也合情合理。
前一篇我們已經看到了針對一個發送到給定topic后,這個message被復制了多份,發送到了這個topic下的每一個channel中,存在在channel的memeoryMsgChan或者backend中。
消息的訂閱與推送
關于消息的推送最重要的是兩個文件:nsqd/protocol_v2.go和nsqd/client_v2.go。
當一個客戶端與nsqd進程建立了一個tcp鏈接時,代碼會調用protocolV2.IOLoop方法,并新建一個clientV2結構體對象。IOLoop方法會啟動一個協程執行messagePump方法。
對于每一個tcp連接,都會有兩個協程:運行IOLoop的協程用于接收客戶端的請求;運行messagePump的負責處理數據,把數據給客戶端clientV2推送給客戶端。
整個protocol_v2就是一個比較經典的tcp協議的實現。每當建立一個新的tcp連接,服務器都會建立一個client_v2對象,和啟動protocol_v2.messagePump協程,一個client只會訂閱一個channel。IOLoop用于接收客戶端傳來的指令,并進行回復,并通過各個channel和其它的組件通信(包括protocol_v2.messagePump)。詳情可以看源代碼:github.com/nsqio/nsq/nsqd/protocol_v2.go
我們想要關注的消息的推送可以看messagePump的實現,如下:
func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {
var err error
var buf bytes.Buffer
var memoryMsgChan chan *Message
var backendMsgChan chan []byte
var subChannel *Channel
// NOTE: `flusherChan` is used to bound message latency for
// the pathological case of a channel on a low volume topic
// with >1 clients having >1 RDY counts
var flusherChan <-chan time.Time
var sampleRate int32
subEventChan := client.SubEventChan
identifyEventChan := client.IdentifyEventChan
outputBufferTicker := time.NewTicker(client.OutputBufferTimeout)
heartbeatTicker := time.NewTicker(client.HeartbeatInterval)
heartbeatChan := heartbeatTicker.C
msgTimeout := client.MsgTimeout
// v2 opportunistically buffers data to clients to reduce write system calls
// we force flush in two cases:
// 1. when the client is not ready to receive messages
// 2. we're buffered and the channel has nothing left to send us
// (ie. we would block in this loop anyway)
//
flushed := true
// signal to the goroutine that started the messagePump
// that we've started up
close(startedChan)
for {
//IsReadyForMessages會檢查InFlightMessages的數目是否超過了客戶端設置的RDY,超過后,不再取消息推送,而是強制做flush。
if subChannel == nil || !client.IsReadyForMessages() {
// the client is not ready to receive messages...
memoryMsgChan = nil
backendMsgChan = nil
flusherChan = nil
// force flush
client.writeLock.Lock()
err = client.Flush()
client.writeLock.Unlock()
if err != nil {
goto exit
}
flushed = true
} else if flushed {
// last iteration we flushed...
// do not select on the flusher ticker channel
memoryMsgChan = subChannel.memoryMsgChan
backendMsgChan = subChannel.backend.ReadChan()
flusherChan = nil
} else {
// select on the flusher ticker channel, too
memoryMsgChan = subChannel.memoryMsgChan
backendMsgChan = subChannel.backend.ReadChan()
flusherChan = outputBufferTicker.C
}
select {
case <-flusherChan: //ticker chan,保證定期flush
client.writeLock.Lock()
err = client.Flush()
client.writeLock.Unlock()
if err != nil {
goto exit
}
flushed = true
case <-client.ReadyStateChan://continue to next iteration:check ready state
case subChannel = <-subEventChan://收到client的SUB的topic的channel后,更新內存中的subChannel開始推送;只會SUB一個channel
// you can't SUB anymore
subEventChan = nil
case identifyData := <-identifyEventChan:
//SKIP
case <-heartbeatChan://heartbeat check
err = p.Send(client, frameTypeResponse, heartbeatBytes)
if err != nil {
goto exit
}
case b := <-backendMsgChan:
if sampleRate > 0 && rand.Int31n(100) > sampleRate {
continue
}
msg, err := decodeMessage(b)
if err != nil {
p.ctx.nsqd.logf(LOG_ERROR, "failed to decode message - %s", err)
continue
}
msg.Attempts++
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage() //add mem count
err = p.SendMessage(client, msg, &buf)
if err != nil {
goto exit
}
flushed = false
case msg := <-memoryMsgChan:
if sampleRate > 0 && rand.Int31n(100) > sampleRate {
continue
}
msg.Attempts++
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage()
err = p.SendMessage(client, msg, &buf)
if err != nil {
goto exit
}
flushed = false
case <-client.ExitChan:
goto exit
}
}
exit:
p.ctx.nsqd.logf(LOG_INFO, "PROTOCOL(V2): [%s] exiting messagePump", client)
heartbeatTicker.Stop()
outputBufferTicker.Stop()
if err != nil {
p.ctx.nsqd.logf(LOG_ERROR, "PROTOCOL(V2): [%s] messagePump error - %s", client, err)
}
}
首先,客戶端發送一個SUB消息來訂閱一個topic下的Channel。protocol_v2.go/protocolV2.SUB中,會往clientV2.go/client.SubEventChan發送一個channel。這里的messagePump便更新了內存中的subChannel開始推送這個訂閱的channel的消息。
在循環的開頭,messageMsgChan和backendChan都會用這個subChannel對應的channel select它們的消息。每當有一個消息來的時候,首先(1)會調用channel的StartInFlightTimeout,channel會把這個消息加到InFlightPqueue里,這個是以timeout時間作為優先級的優先級隊列(最小堆),用于保存發送給客戶端但是還沒有被確認的消息。
(2)還有會更新client的一些counter信息,如InFlightMessageCount等,根據InFlightMessageCount和RDY比較決定是否繼續推送消息。
客戶端成功消費一條消息后,會發送一個FIN消息,帶上message ID,client會-1 InFlightMessageCount,從channel的InflightMessage中取出這個消息,并向ReadStateChan發送一個消息;如果服務端因為RDY限制停止推送消息,收到這個消息后,也會重新查看是否可以繼續推送消息。
或者客戶端如果消費失敗,也會發送一個REQ的請求,channel會把這個消息從channel的InflightMessage中取出這個消息,重新放入channel。
那如果客戶端沒有對消息做回復呢?
消息超時的設計與實現
在nsqd.go中,還有一部分重要的實現,queueScanLoop方法中,每隔QueueScanInterval的時間,會從方法cache的channels list中隨機選擇QueueScanSelectionCount個channel,然后去執行resizePool。這個實現參考了redis的probabilistic expiration algorithm.
參考《Redis設計與實現》9.6 Redis的過期鍵刪除策略,結合了兩種策略:
- 惰性刪除。每次客戶端對某個key讀寫時,會檢查它是否過期,如果過期,就把它刪掉。
- 定期刪除。定期刪除并不會遍歷整個DB,它會在規定時間內,分多次遍歷服務器中各個DB,從數據庫的expires字典中隨機檢查一部分鍵的過期時間,如果過期,則刪除。
對于nsqd的channel,它有兩個隊列需要定時檢查,一個是InFlightQueue,一個是DeferredQueue。任何一個有工作做,這個channel就被視為dirty的。
每隔default 100ms(QueueScanInterval),nsqd會隨機選擇20(QueueScanSelectionCount)個channel扔到workerCh chan之中。
每隔5s,queueScanLoop都會調用resizePool。resizePool可以看做是一個fixed pool size的協程池,idealPoolSize= min(AllChannelNum * 0.25, QueueScanWorkerPoolMax)。這么多的協程的工作就是,對于從workerCh收到的每一個channel,都會調用它的channel.go/channel.processInFlightQueue方法和channel.go/channel.processDeferredQueue方法,任何的變動都會把這次queueScan行為標記為dirty。
每次這20個channel全部都scan完畢后,會統計dirtyNum / QueueScanSelectionNum的比例,如果大于某個預設的閾值QueueScanDirtyPercent,將不會間隔時間,直接開始下一輪的QueueScan。
那么為什么每隔5s要重新調用resizePool呢?這是為了根據最新的allChannelNum給予機會去更新resizePool協程池的協程數。因為PoolSize是NSQD的數據域,是全局的狀態,每次調用并不會另外新建一個協程池,而是根據idealSize調整它的大小。這部分代碼實現也比較經典,可以學習一下“如何使用Golang實現一個協程池的經典實現,尤其是需要動態調整池大小的需求”。
// resizePool adjusts the size of the pool of queueScanWorker goroutines
//
// 1 <= pool <= min(num * 0.25, QueueScanWorkerPoolMax)
//
func (n *NSQD) resizePool(num int, workCh chan *Channel, responseCh chan bool, closeCh chan int) {
idealPoolSize := int(float64(num) * 0.25)
if idealPoolSize < 1 {
idealPoolSize = 1
} else if idealPoolSize > n.getOpts().QueueScanWorkerPoolMax {
idealPoolSize = n.getOpts().QueueScanWorkerPoolMax
}
for {
if idealPoolSize == n.poolSize {
break
} else if idealPoolSize < n.poolSize {
// contract
closeCh <- 1
n.poolSize--
} else {
// expand
n.waitGroup.Wrap(func() {
n.queueScanWorker(workCh, responseCh, closeCh)
})
n.poolSize++
}
}
}
// queueScanWorker receives work (in the form of a channel) from queueScanLoop
// and processes the deferred and in-flight queues
func (n *NSQD) queueScanWorker(workCh chan *Channel, responseCh chan bool, closeCh chan int) {
for {
select {
case c := <-workCh:
now := time.Now().UnixNano()
dirty := false
if c.processInFlightQueue(now) {
dirty = true
}
if c.processDeferredQueue(now) {
dirty = true
}
responseCh <- dirty
case <-closeCh:
return
}
}
}
// queueScanLoop runs in a single goroutine to process in-flight and deferred
// priority queues. It manages a pool of queueScanWorker (configurable max of
// QueueScanWorkerPoolMax (default: 4)) that process channels concurrently.
//
// It copies Redis's probabilistic expiration algorithm: it wakes up every
// QueueScanInterval (default: 100ms) to select a random QueueScanSelectionCount
// (default: 20) channels from a locally cached list (refreshed every
// QueueScanRefreshInterval (default: 5s)).
//
// If either of the queues had work to do the channel is considered "dirty".
//
// If QueueScanDirtyPercent (default: 25%) of the selected channels were dirty,
// the loop continues without sleep.
func (n *NSQD) queueScanLoop() {
workCh := make(chan *Channel, n.getOpts().QueueScanSelectionCount)
responseCh := make(chan bool, n.getOpts().QueueScanSelectionCount)
closeCh := make(chan int)
workTicker := time.NewTicker(n.getOpts().QueueScanInterval)
refreshTicker := time.NewTicker(n.getOpts().QueueScanRefreshInterval)
channels := n.channels()
n.resizePool(len(channels), workCh, responseCh, closeCh)
for {
select {
case <-workTicker.C:
if len(channels) == 0 {
continue
}
case <-refreshTicker.C:
channels = n.channels()
n.resizePool(len(channels), workCh, responseCh, closeCh)
continue
case <-n.exitChan:
goto exit
}
num := n.getOpts().QueueScanSelectionCount
if num > len(channels) {
num = len(channels)
}
loop:
for _, i := range util.UniqRands(num, len(channels)) {
workCh <- channels[i]
}
numDirty := 0
for i := 0; i < num; i++ {
if <-responseCh {
numDirty++
}
}
if float64(numDirty)/float64(num) > n.getOpts().QueueScanDirtyPercent {
goto loop
}
}
exit:
n.logf(LOG_INFO, "QUEUESCAN: closing")
close(closeCh)
workTicker.Stop()
refreshTicker.Stop()
}
channel.go/channel.processInFlightQueue的實現比較簡單,把channel的InflightPQueue中的message按照超時時間由早到晚把超時時間小于給定時間的消息依次取出,做一些一致性的數據操作后,重新放入channel之中(也會發送TryUpdateReadyState)。processDeferredQueue也是類似的。
這里通過了一定的概率加受控制的并發協程池,清理內存中timeout未被客戶端所確認的消息,重新放入隊列,保證了消息的可達性。(存在重復消費消息的可能)
經典的GO并發
我們其實可以發現,同一個channel,可能會有很多的client從它的memoryMsgChan和backendChan里select監聽消息,因為同一個消息對于Golang的channel來說只會被一個監聽者收到,所以,通過這樣的機制實現了一定程度上的消費者的負載均衡。
NSQ的代碼很適合用Golang的Goroutine, Channel, & Mutex并發的good practice來學習。