使用 Go - Redis 構建高性能排名系統

構建排名系統(排行榜)在各類應用中都具有實用價值:無論是遊戲中的玩家排名、電商平臺的熱銷商品展示。構建這類系統需要高速的讀寫操作、精準的排序能力以及可擴展的後端支持。Redis 的有序集合(Sorted Sets)正是實現這一需求的理想數據結構。

現在我們開始構建一個實時排名系統,滿足以下核心需求:分數越高排名越靠前;同分情況下先達到該分數的用戶排名更高。

構建複合分數

  1. 實際分數越高,排名越靠前。

  2. 實際分數相同時,時間戳越小 (越早),排名越靠前。

爲了滿足這兩個條件,我們可以這樣構造複合分數:

CompositeScore = (ActualScore × Factor) + (MaxTimestamp − CurrentTimestamp)

其中:

我們將使用 go-redis/redis (https://github.com/redis/go-redis) 客戶端庫。

生成複合分數

const scoreFactor = 100_000_000_000_000    // 定義一個足夠大的因子,確保分數能夠主導排序
const maxMilliSeconds = 9_999_999_999_999 // 模擬一個最大的時間戳,用於將遞增的時間戳轉換爲遞減值

// generateCompositeScore 複合分數
func generateCompositeScore(actualScore float64) float64 {
 currentTimeMillis := float64(time.Now().UnixMilli())
 timeComponent := maxMilliSeconds - currentTimeMillis
 compositeScore := actualScore*scoreFactor + timeComponent
 return compositeScore
}

// extractActualScore 實際分數
func extractActualScore(compositeScore float64) float64 {
 actualScore := float64(int64(compositeScore / scoreFactor))
 return actualScore
}

添加 / 更新用戶分數

// UpdateUserScore 將用戶分數添加到排行榜
func UpdateUserScore(userID string, actualScore float64) error {
 compositeScore := generateCompositeScore(actualScore)
 // 使用 ZADD 更新或添加成員。如果成員已存在,其分數會被更新。
 _, err := rdb.ZAdd(ctx, leaderboardKey, redis.Z{
  Score:  compositeScore,
  Member: userID,
 }).Result()
 return err
}

獲取排行榜數據

// TopPlayers 獲取前 N 名玩家
func TopPlayers(n int64) ([]RankEntry, error) {
 results, err := rdb.ZRevRangeWithScores(ctx, leaderboardKey, 0, n-1).Result()
 if err != nil {
  return nil, err
 }
 entries := make([]RankEntry, 0, len(results))
 for i, z := range results {
  entries = append(entries, RankEntry{
   UserID:      z.Member.(string),
   ActualScore: extractActualScore(z.Score),
   Rank:        int64(i + 1),
  })
 }
 return entries, nil
}

type RankEntry struct {
 UserID      string  `json:"user_id"`
 ActualScore float64 `json:"actual_score"`
 Rank        int64   `json:"rank"`
}

獲取指定用戶的排名和分數

// GetUserRank 獲取指定用戶的排名
func GetUserRank(userID string) (int64, float64, error) {
 rank, err := rdb.ZRevRank(ctx, leaderboardKey, userID).Result()
 if errors.Is(err, redis.Nil) {
  return -1, 0, nil// 用戶不在排行榜中
 } else if err != nil {
  return -1, 0, err
 }
 score, err := rdb.ZScore(ctx, leaderboardKey, userID).Result()
 if errors.Is(err, redis.Nil) {
  return -1, 0, nil// 用戶不在排行榜中
 } else if err != nil {
  return -1, 0, err
 }
 return rank + 1, extractActualScore(score), nil
}

實際中,如果 userID 是唯一的,那麼即使在同一毫秒或微秒內發生,Redis Sorted Set 的 ZADD 操作在分數完全相同的情況下,會根據 member(即 userID)的字典順序進行二次排序。 這通常也是可以接受的 "最終打破平局" 的規則。


特殊場景:降分用戶與同分用戶比較時的優先級處理。

現在我們新增一個需求:比如當 A 玩家降分後與 B 玩家同分時,A 玩家排在 B 玩家之前。

這是一個很有趣且常見的需求,它引入了同分但有不同行爲導致的優先級問題。這意味着 A 玩家的 "降分到達" 狀態比 B 玩家的 "正常到達" 狀態具有更高的優先級。

解決這個問題,我們仍然可以利用複合分數機制,但需要引入一個新的優先級因子。

引入優先級標誌位

我們需要在分數相同的情況下,優先區分 "降分" 和 "正常" 狀態。我們可以通過在複合分數中引入一個行爲優先級標誌位來實現這一點。

需要這樣設計:

  1. 實際分數越高,排名越靠前。

  2. 實際分數相同時,降分的用戶排在前面。

  3. 實際分數相同且都爲降分用戶或都爲正常用戶時,時間戳越小 (越早),排名越靠前。

新的複合分數計算公式:

CompositeScore = (ActualScore × Factor) + (ActionPriority × PriorityFactor) + (MaxTimestamp − CurrentTimestamp)

其中:

修改複合分數計算函數

const (
 scoreFactor     = 100_000_000_000_000 // 定義一個足夠大的因子,確保分數能夠主導排序
 priorityFactor  = 10_000_000_000_000  // 行爲優先級分量的因子
 maxMilliSeconds = 9_999_999_999_999   // 模擬一個最大的時間戳,用於將遞增的時間戳轉換爲遞減值

 actionPriorityDowngrade float64 = 1.0// 降分,值更高,排名優先
 actionPriorityNormal    float64 = 0.0// 正常,值較低
)

// generateCompositeScore 複合分數
func generateCompositeScore(actualScore float64, isDowngrade bool) float64 {
 currentTimeMillis := float64(time.Now().UnixMilli())
 timeComponent := maxMilliSeconds - currentTimeMillis

 actionComponent := actionPriorityNormal
 if isDowngrade {
  actionComponent = actionPriorityDowngrade
 }
 actionComponent = actionComponent * priorityFactor

 compositeScore := actualScore*scoreFactor + actionComponent + timeComponent
 return compositeScore
}

// extractActualScore 實際分數
func extractActualScore(compositeScore float64) float64 {
 actualScore := float64(int64(compositeScore / scoreFactor))
 return actualScore
}

修改添加 / 更新用戶分數函數

// UpdateUserScore 將用戶分數添加到排行榜
func UpdateUserScore(userID string, actualScore float64, isDowngrade bool) error {
 compositeScore := generateCompositeScore(actualScore, isDowngrade)
 // 使用 ZADD 更新或添加成員。如果成員已存在,其分數會被更新。
 _, err := rdb.ZAdd(ctx, leaderboardKey, redis.Z{
  Score:  compositeScore,
  Member: userID,
 }).Result()
 return err
}

主函數測試示例

func main() {
 // 清空排行榜,方便測試
 rdb.Del(ctx, leaderboardKey).Result()

 fmt.Println("--- 模擬用戶更新分數 ---")
 // Scenario 1: A 降分到 100
 UpdateUserScore("user:A", 100.0, true) // A 降分
 time.Sleep(50 * time.Millisecond)

 // Scenario 2: B 正常達到 100
 UpdateUserScore("user:B", 100.0, false) // B 正常更新
 time.Sleep(50 * time.Millisecond)

 // Scenario 3: C 正常達到 120
 UpdateUserScore("user:C", 120.0, false)
 time.Sleep(50 * time.Millisecond)

 // Scenario 4: D 降分到 120 (應該在 C 之前)
 UpdateUserScore("user:D", 120.0, true) // D 降分
 time.Sleep(50 * time.Millisecond)

 // Scenario 5: E 正常達到 100 (應該在 B 之後)
 UpdateUserScore("user:E", 100.0, false)
 time.Sleep(50 * time.Millisecond)

 // Scenario 6: F 降分到 100 (應該在 A 之後)
 UpdateUserScore("user:F", 100.0, true)
 time.Sleep(50 * time.Millisecond)

 fmt.Println("\n--- 獲取排行榜前 10 名 ---")
 topEntries, err := TopPlayers(10)
 if err != nil {
  log.Fatal(err)
 }
 for _, entry := range topEntries {
  fmt.Printf("Rank %d: UserID: %s, Score: %.0f\n",
   entry.Rank, entry.UserID, entry.ActualScore)
 }
 // 預期結果分析:
 // 1. D (120, 降分) 應該排在 C (120, 正常) 之前。
 // 2. A (100, 降分) 應該排在 B (100, 正常) 之前。
 // 3. B (100, 正常) 應該排在 E (100, 正常) 之前(因爲 B 時間早)。
 // 4. A (100, 降分) 應該排在 F (100, 降分) 之前(因爲 A 時間早)。

 fmt.Println("\n--- 查詢指定用戶排名 ---")
 targetUserID := "user:A"
 rank, score, err := GetUserRank(targetUserID)
 if err != nil {
  log.Fatal(err)
 }
 if rank != -1 {
  fmt.Printf("用戶 %s 的排名是: %d, 實際分數: %.0f\n", targetUserID, rank, score)
 } else {
  fmt.Printf("用戶 %s 不在排行榜中。\n", targetUserID)
 }
}

最佳實踐

  1. 週期性排行榜:
  1. 限制排行榜大小:
 const limit = 10
 total, _ := rdb.ZCard(ctx, leaderboardKey).Result()
 if total > limit {
  rdb.ZRemRangeByRank(ctx, leaderboardKey, 0, total-limit-1).Result()
 }
  1. 定時更新排行榜:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/iYv26M0YiAVZPbqZfw4fPQ