你在使用訊息佇列的時候關注過吞吐量嗎?
思考過吞吐量的影響因素嗎?
考慮過怎麼提高嗎?
總結過最佳實踐嗎?
本文帶你一起探討下訊息佇列消費端高吞吐的 Go
框架實現。Let’s go!
關於吞吐量的一些思考
寫入訊息佇列吞吐量取決於以下兩個方面
- 網路頻寬
- 訊息佇列(比如Kafka)寫入速度
最佳吞吐量是讓其中之一打滿,而一般情況下內網頻寬都會非常高,不太可能被打滿,所以自然就是講訊息佇列的寫入速度打滿,這就就有兩個點需要平衡
- 批量寫入的訊息量大小或者位元組數多少
- 延遲多久寫入
go-zero 的
PeriodicalExecutor
和ChunkExecutor
就是為了這種情況設計的從訊息佇列裡消費訊息的吞吐量取決於以下兩個方面
- 訊息佇列的讀取速度,一般情況下訊息佇列本身的讀取速度相比於處理訊息的速度都是足夠快的
- 處理速度,這個依賴於業務
這裡有個核心問題是不能不考慮業務處理速度,而讀取過多的訊息到記憶體裡,否則可能會引起兩個問題:
- 記憶體佔用過高,甚至出現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)
}
}
這裡如果拿到訊息就去處理,當 ok
為 false
的時候表示 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... 去看完整實現。
使用
基本的使用流程:
- 建立
producer
或consumer
- 啟動
queue
- 生產訊息 / 消費訊息
對應到 queue
中,大致如下:
建立 queue
// 生產者建立工廠
producer := newMockedProducer()
// 消費者建立工廠
consumer := newMockedConsumer()
// 將生產者以及消費者的建立工廠函式傳遞給 NewQueue()
q := queue.NewQueue(func() (Producer, error) {
return producer, nil
}, func() (Consumer, error) {
return consumer, nil
})
我們看看 NewQueue
需要什麼引數:
producer
工廠方法consumer
工廠方法
將 producer & consumer
的工廠函式傳遞 queue
,由它去負責建立。框架提供了 Producer
和 Consumer
的介面以及工廠方法定義,然後整個流程的控制 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
框架實現了基於 redis
和 kafka
等的訊息佇列服務,在不同業務場景中經過了充分的實踐檢驗。你也可以根據自己的業務實際情況,實現自己的訊息佇列服務。
整體設計
整體流程如上圖:
- 全體的通訊都由
channel
進行 Producer
和Consumer
的數量可以設定以匹配不同業務需求Produce
和Consume
具體實現由開發者定義,queue
負責整體流程
總結
本篇文章講解了如何通過 channel
來平衡從佇列中讀取和處理訊息的速度,以及如何實現一個通用的訊息佇列處理框架,並通過 mock
示例簡單展示瞭如何基於 core/queue
實現一個訊息佇列處理服務。你可以通過類似的方式實現一個基於 rocketmq
等的訊息佇列處理服務。
關於 go-zero
更多的設計和實現文章,可以關注『微服務實踐』公眾號。
專案地址
歡迎使用 go-zero 並 star 支援我們!
微信交流群
關注『微服務實踐』公眾號並點選 進群 獲取社群群二維碼。
本作品採用《CC 協議》,轉載必須註明作者和本文連結