NSQ源碼分析(2)- nsqd消息的推送與訂閱

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的過期鍵刪除策略,結合了兩種策略:

  1. 惰性刪除。每次客戶端對某個key讀寫時,會檢查它是否過期,如果過期,就把它刪掉。
  2. 定期刪除。定期刪除并不會遍歷整個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來學習。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,908評論 6 541
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,324評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,018評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,675評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,417評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,783評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,779評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,960評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,522評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,267評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,471評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,009評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,698評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,099評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,386評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,204評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,436評論 2 378

推薦閱讀更多精彩內容