從 Discord 的做法中學習 — 使用 Golang 進行請求合併
正如你可能之前看到的,Discord 去年發佈了一篇有價值的文章,討論了他們成功存儲了數萬億條消息。雖然有很多關於這篇文章的 YouTube 視頻和文章,但我認爲這篇文章中一個名爲 “數據服務爲數據服務” 的部分沒有得到足夠的關注。在這篇文章中,我們將討論 Discord 對數據服務的方法,並探討如何利用 Golang 的併發特性來減少特定情況下的數據庫負載。
數據服務拯救熱分區
如你所知,消息和頻道是 Discord 中最常用的組件。讓我們想象一個場景:一個擁有 50 萬成員的頻道的管理員提到 @everyone。會發生什麼?成千上萬個同時的請求直接指向那個數據庫分區,所有請求的目標都是檢索相同的消息。這種模式重複發生,直到該分區無法迴應其他請求。
Discord 引入了一個位於 Python API 和數據庫集羣之間的中間服務 — 他們稱之爲數據服務。這個服務大致包含每個查詢一個 gRPC 端點,沒有任何業務邏輯。對 Discord 來說,這個服務的重要特性就是請求合併。
請求合併
正如我們之前討論過的,每當在一個龐大的頻道中有提及時,就會有大量類似的請求直接指向數據庫分區。通過合併這些請求,如果多個用戶請求相同的數據庫行,我們可以將這些請求合併成一個選擇查詢,並執行該查詢。
通過使用數據服務而不是直接連接到數據庫,我們可以實現許多令人興奮的功能,比如批量查詢,這些功能可以顯著減少數據庫開銷,並改善查詢的平均值,特別是第 99 百分位數。
使用 Golang 實現簡單的請求合併
與許多其他公司一樣,Discord 使用 Python 作爲其主要的後端語言。無論是微服務還是單體架構,後端服務通常直接連接到數據源進行查詢。雖然 Python 確實是一種多功能語言,但在併發性方面存在一些不足。使用 Python 實現併發和高吞吐量的服務可能有些挑戰,而性能與用 C++、Rust 和 Golang 等編譯語言編寫的類似服務相比,往往會較低。
在進行任何操作之前,讓我們模擬一下提到的情況。假設服務總共收到了 5,000 個請求,其中併發數爲 1,000。
-
• 總請求數: 5,000
-
• 併發數: 1,000
-
• 需要檢索的唯一消息數: 100
type Message struct {
gorm.Model
Text string
User string // some random properties that a message row may have
}
func generateRandomData(db *gorm.DB) {
for i := 0; i < 100; i++ {
msg := &messages.Message{Text: fmt.Sprintf("Message #%d", i)}
db.Save(msg)
}
}
我使用 Gorm 構建了一個簡單的數據庫模型來表示 **Message(消息)**表,然後向表中填充了 100 條虛擬消息。
e := echo.New()
e.GET("/randomMessage", func(c echo.Context) error {
randomMessageID := rand.Intn(100)
var msg messages.Message
if err := db.Where("id=?", randomMessageID).First(&msg).Error; err != nil {
return err
}
return c.JSON(200, msg)
})
e.Logger.Fatal(e.Start(":1323"))
我創建了一個簡單的端點來模擬對 0 到 100 之間的隨機 ID 進行 SELECT 查詢。現在我們可以對這個端點進行基準測試,模擬在這種情況下會發生什麼。
-
• 平均每秒請求數 (RPS): 300
-
• 平均響應時間: 3.2 秒
-
• 50% 響應時間: 546 毫秒
-
• 99% 響應時間: 14.7 秒
如果我們有 10 秒的超時策略,大約有 2% 的請求將收不到響應。現在讓我們改變代碼。Golang 有一個名爲 “single flight” 的內置包。這個包提供了重複函數調用抑制機制。一般來說,你給它一個鍵和一個函數,而不是多次運行該函數,SingleFlight 會暫時保持其他調用,直到第一次調用完成其請求並以相同的結果作出響應。
var g = singleflight.Group{}
e.GET("/randomMessage", func(c echo.Context) error {
randomMessageID := rand.Intn(100)
msg, err, _ := g.Do(fmt.Sprint(randomMessageID), func() (interface{}, error) {
var msg messages.Message
if err := db.Where("id=?", randomMessageID).First(&msg).Error; err != nil {
return nil, err
}
return &msg, nil
})
if err != nil {
return err
}
return c.JSON(200, msg)
})
*func (g Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
Do 執行並返回給定函數的結果,確保同一時間針對給定鍵只有一個執行過程。如果出現重複,重複的調用者會等待原始調用完成並接收相同的結果。返回值 shared 表示是否將 v 給了多個調用者。
現在讓我們重新運行模擬並比較結果。
-
• 平均每秒請求數 (RPS): 2309
-
• 平均響應時間: 433 毫秒
-
• 50% 響應時間: 389 毫秒
-
• 99% 響應時間: 777 毫秒
正如你所看到的,僅使用了一個簡單的技術就將第 99 百分位數減少了 14 秒,新方法支持的每秒請求次數提高了 7.6 倍。
結論
從那時起我們就注意到,通過優化數據庫查詢,可以大大提高應用程序的整體性能。雖然我們討論的方法是情景性的,但 Discord 已經使用了一年多,對他們有很大幫助。
你應該知道,如果你使用數據服務,你將面臨其他的複雜情況。例如,你可能會有多個數據服務實例,而你的 Python API 必須有一種機制將類似的請求發送到同一個實例。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TfXNvFSzI7P9oDaXDAR_1Q