如何讓訊息佇列達到最大吞吐量?

kevwan發表於2021-05-12

你在使用訊息佇列的時候關注過吞吐量嗎?

思考過吞吐量的影響因素嗎?

考慮過怎麼提高嗎?

總結過最佳實踐嗎?

本文帶你一起探討下訊息佇列消費端高吞吐的 Go 框架實現。Let’s go!

關於吞吐量的一些思考

  • 寫入訊息佇列吞吐量取決於以下兩個方面

    • 網路頻寬
    • 訊息佇列(比如Kafka)寫入速度

    最佳吞吐量是讓其中之一打滿,而一般情況下內網頻寬都會非常高,不太可能被打滿,所以自然就是講訊息佇列的寫入速度打滿,這就就有兩個點需要平衡

    • 批量寫入的訊息量大小或者位元組數多少
    • 延遲多久寫入

    go-zero 的 PeriodicalExecutorChunkExecutor 就是為了這種情況設計的

  • 從訊息佇列裡消費訊息的吞吐量取決於以下兩個方面

    • 訊息佇列的讀取速度,一般情況下訊息佇列本身的讀取速度相比於處理訊息的速度都是足夠快的
    • 處理速度,這個依賴於業務

    這裡有個核心問題是不能不考慮業務處理速度,而讀取過多的訊息到記憶體裡,否則可能會引起兩個問題:

    • 記憶體佔用過高,甚至出現OOM,pod 也是有 memory limit
    • 停止 pod 時堆積的訊息來不及處理而導致訊息丟失

解決方案和實現

借用一下 Rob Pike 的一張圖,這個跟佇列消費異曲同工。左邊4個 gopher 從佇列裡取,右邊4個 gopher 接過去處理。比較理想的結果是左邊和右邊速率基本一致,沒有誰浪費,沒有誰等待,中間交換處也沒有堆積。

我們來看看 go-zero 是怎麼實現的:

  • Producer
    for {
        select {
        case <-q.quit:
            logx.Info("Quitting producer")
            return
        default:
            if v, ok := q.produceOne(producer); ok {
                q.channel <- v
            }
        }
    }

沒有退出事件就會通過 produceOne 去讀取一個訊息,成功後寫入 channel。利用 chan 就可以很好的解決讀取和消費的銜接問題。

  • Consumer
    for {
        select {
        case message, ok := <-q.channel:
            if ok {
                q.consumeOne(consumer, message)
            } else {
                logx.Info("Task channel was closed, quitting consumer...")
                return
            }
        case event := <-eventChan:
            consumer.OnEvent(event)
        }
    }

這裡如果拿到訊息就去處理,當 okfalse 的時候表示 channel 已被關閉,可以退出整個處理迴圈了。同時我們還在 redis queue 上支援了 pause/resume,我們原來在社交場景裡大量使用這樣的佇列,可以通知 consumer 暫停和繼續。

  • 啟動 queue,有了這些我們就可以通過控制 producer/consumer 的數量來達到吞吐量的調優了
func (q *Queue) Start() {
    q.startProducers(q.producerCount)
    q.startConsumers(q.consumerCount)

    q.producerRoutineGroup.Wait()
    close(q.channel)
    q.consumerRoutineGroup.Wait()
}

這裡需要注意的是,先要停掉 producer,再去等 consumer 處理完。

到這裡核心控制程式碼基本就講完了,其實看起來還是挺簡單的,也可以到 github.com/tal-tech/go-zero/tree/m... 去看完整實現。

使用

基本的使用流程:

  1. 建立 producerconsumer
  2. 啟動 queue
  3. 生產訊息 / 消費訊息

對應到 queue 中,大致如下:

建立 queue

// 生產者建立工廠
producer := newMockedProducer()
// 消費者建立工廠
consumer := newMockedConsumer()
// 將生產者以及消費者的建立工廠函式傳遞給 NewQueue()
q := queue.NewQueue(func() (Producer, error) {
  return producer, nil
}, func() (Consumer, error) {
  return consumer, nil
})

我們看看 NewQueue 需要什麼引數:

  1. producer 工廠方法
  2. consumer 工廠方法

producer & consumer 的工廠函式傳遞 queue ,由它去負責建立。框架提供了 ProducerConsumer 的介面以及工廠方法定義,然後整個流程的控制 queue 實現會自動完成。

生產 message

我們通過自定義一個 mockedProducer 來模擬:

type mockedProducer struct {
    total int32
    count int32
  // 使用waitgroup來模擬任務的完成
    wait  sync.WaitGroup
}
// 實現 Producer interface 的方法:Produce()
func (p *mockedProducer) Produce() (string, bool) {
    if atomic.AddInt32(&p.count, 1) <= p.total {
        p.wait.Done()
        return "item", true
    }
    time.Sleep(time.Second)
    return "", false
}

queue 中的生產者編寫都必須實現:

  • Produce():由開發者編寫生產訊息的邏輯
  • AddListener():新增事件 listener

消費 message

我們通過自定義一個 mockedConsumer 來模擬:

type mockedConsumer struct {
    count  int32
}

func (c *mockedConsumer) Consume(string) error {
    atomic.AddInt32(&c.count, 1)
    return nil
}

啟動 queue

啟動,然後驗證我們上述的生產者和消費者之間的資料是否傳輸成功:

func main() {
    // 建立 queue
    q := NewQueue(func() (Producer, error) {
        return newMockedProducer(), nil
    }, func() (Consumer, error) {
        return newMockedConsumer(), nil
    })
  // 啟動panic了也可以確保stop被執行以清理資源
  defer q.Stop()
    // 啟動
    q.Start()
}

以上就是 queue 最簡易的實現示例。我們通過這個 core/queue 框架實現了基於 rediskafka 等的訊息佇列服務,在不同業務場景中經過了充分的實踐檢驗。你也可以根據自己的業務實際情況,實現自己的訊息佇列服務。

整體設計

如何讓訊息佇列達到最大吞吐量?

整體流程如上圖:

  1. 全體的通訊都由 channel 進行
  2. ProducerConsumer 的數量可以設定以匹配不同業務需求
  3. ProduceConsume 具體實現由開發者定義,queue 負責整體流程

總結

本篇文章講解了如何通過 channel 來平衡從佇列中讀取和處理訊息的速度,以及如何實現一個通用的訊息佇列處理框架,並通過 mock 示例簡單展示瞭如何基於 core/queue 實現一個訊息佇列處理服務。你可以通過類似的方式實現一個基於 rocketmq 等的訊息佇列處理服務。

關於 go-zero 更多的設計和實現文章,可以關注『微服務實踐』公眾號。

專案地址

github.com/tal-tech/go-zero

歡迎使用 go-zero 並 star 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 進群 獲取社群群二維碼。

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

相關文章