如何用 Golang 的 channel 實現訊息的批次處理

jeremy1127發表於2019-09-26

話說,有這樣一個場景,就是客戶送不斷髮送訊息,需要服務端非同步處理。

一個一個的處理未免有些浪費資源,更好的方法是批次處理。

當訊息量特別大時,使用kafka之類的message queue自然是首選,但更多的時候,我們想用更加輕量的方案來解決這個問題。

下面來詳細分析一下技術需求,這個方案需要實現以下幾點:

  • 訊息聚合後處理(最大條數為BatchSize)
  • 延遲處理(延遲時間為LingerTime)
  • 自定義錯誤處理
  • 併發處理

基於這樣的需求,我快速的實現了第一步,訊息聚合後處理。

var (
    eventQueue     = make(chan interface{}, 4)
    batchSize      = 8
    workers        = 2
    batchProcessor = func(messages []interface{}) {
        fmt.Printf("%+v \n", messages)
    }
)

for i := 0; i < workers; i++ {
    go func() {
        var batch []interface{}
        for {
            msg := <-eventQueue
            batch = append(batch, msg)
            if len(batch) == batchSize {
                batchProcessor(batch)
                batch = make([]interface{}, 0)
            }
        }
    }()
}

for i := 0; i < 100; i++ {
    eventQueue <- i
}

程式碼雖然簡單,但是核心已經有了。

  • 帶buffer的channel相當於一個FIFO的佇列
  • 多個常駐的goroutine來提高併發
  • goroutine之間是並行的,但每個goroutine內是序列的,所以對batch操作是不用加鎖的。

下一步就是新增延遲處理,和錯誤處理了。

var (
    eventQueue     = make(chan interface{}, 4)
    batchSize      = 8
    workers        = 2
    lingerTime     = 14 * time.Millisecond
    batchProcessor = func(batch []interface{}) error {
        fmt.Printf("%+v \n", batch)
        return nil
    }
    errHandler = func(err error, batch []interface{}) {
        fmt.Println("some error happens")
    }
)

for i := 0; i < workers; i++ {
    go func() {
        var batch []interface{}
        lingerTimer := time.NewTimer(0)
        if !lingerTimer.Stop() {
            <-lingerTimer.C
        }
        defer lingerTimer.Stop()

        for {
            select {
            case msg := <-eventQueue:
                batch = append(batch, msg)
                if len(batch) != batchSize {
                    if len(batch) == 1 {
                        lingerTimer.Reset(lingerTime)
                    }
                    break
                }

                if err := batchProcessor(batch); err != nil {
                    errHandler(err, batch)
                }

                if !lingerTimer.Stop() {
                    <-lingerTimer.C
                }

                batch = make([]interface{}, 0)
            case <-lingerTimer.C:
                if err := batchProcessor(batch); err != nil {
                    errHandler(err, batch)
                }

                batch = make([]interface{}, 0)
            }
        }
    }()
}

for i := 0; i < 100; i++ {
    eventQueue <- i
    time.Sleep(1 * time.Millisecond)
}

雖然只多加了兩個點,程式碼明顯複雜了許多,這其實也是很多庫的成長過程吧。

一開始專注解決核心問題時,程式碼還很清晰,當功能逐漸擴充套件後,程式碼行數快速增加。

這時,如果抓不住核心,很容易迷失在程式碼中。關於這一點,相信大家在加入一個新的專案,或者看一些成熟專案的原始碼時都有同感。(這也是為什麼我把不同階段的程式碼都列出來的原因,不知各位看官意下如何)

言歸正傳,關於程式碼中為什麼使用time.Timer而不是time.After,是因為time.After在for select中使用時,會發生記憶體洩露。
具體分析,請檢視golang time.After記憶體洩露問題分析GOLANG中time.After釋放的問題

所以說呀,程式碼寫的越多,越容易出bug,但是功能不完善,程式碼還是要寫的。

實現到這裡,當個原型是綽綽有餘了,但是要作為一個通用的庫,還有很多功能要做,比如說:自定義配置。

最終版的程式碼,不多不少,正好200行,就不貼過來。有興趣的同學,請點選Aggregator.go檢視。

最後,Aggregator收錄在我開源的channelx倉庫中,這個庫目的是使用channel實現各種好用的輕量級工具。如果有你喜歡用的工具,歡迎點個贊或者star :)

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章