使用 Go - Redis 構建高性能排名系統
構建排名系統(排行榜)在各類應用中都具有實用價值:無論是遊戲中的玩家排名、電商平臺的熱銷商品展示。構建這類系統需要高速的讀寫操作、精準的排序能力以及可擴展的後端支持。Redis 的有序集合(Sorted Sets)正是實現這一需求的理想數據結構。
現在我們開始構建一個實時排名系統,滿足以下核心需求:分數越高排名越靠前;同分情況下先達到該分數的用戶排名更高。
構建複合分數
-
實際分數越高,排名越靠前。
-
實際分數相同時,時間戳越小 (越早),排名越靠前。
爲了滿足這兩個條件,我們可以這樣構造複合分數:
CompositeScore = (ActualScore × Factor) + (MaxTimestamp − CurrentTimestamp)
其中:
-
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 玩家的 "正常到達" 狀態具有更高的優先級。
解決這個問題,我們仍然可以利用複合分數機制,但需要引入一個新的優先級因子。
引入優先級標誌位
我們需要在分數相同的情況下,優先區分 "降分" 和 "正常" 狀態。我們可以通過在複合分數中引入一個行爲優先級標誌位來實現這一點。
需要這樣設計:
-
實際分數越高,排名越靠前。
-
實際分數相同時,降分的用戶排在前面。
-
實際分數相同且都爲降分用戶或都爲正常用戶時,時間戳越小 (越早),排名越靠前。
新的複合分數計算公式:
CompositeScore = (ActualScore × Factor) + (ActionPriority × PriorityFactor) + (MaxTimestamp − CurrentTimestamp)
其中:
-
ActualScore:用戶的實際得分。
-
Factor:一個足夠大的乘數,用於將實際分數提升到複合分數的最高位。
-
ActionPriority:行爲優先級標誌位。 例如,降分行爲設置爲一個較高的值(比如 1)。 正常(或升分)行爲設置爲一個較低的值(比如 0)。 這個值需要乘以 PriorityFactor,以確保其優先級高於時間戳部分,但低於實際分數部分。
-
PriorityFactor:一個乘數,確保 ActionPriority 對排序的影響介於 ActualScore 和 (MaxTimestamp - CurrentTimestamp) 之間。
-
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)
}
}
最佳實踐
- 週期性排行榜:
-
使用不同的 Redis Key 來存儲不同時間週期的排行榜,如 leaderboard:daily:20250612、leaderboard:weekly:2025W24。
-
使用 EXPIRE 設置過期時間或定期刪除不再活躍的 Key。
- 限制排行榜大小:
-
如果只需要保留前 N 名,可以考慮使用 ZREMRANGEBYRANK 命令來修剪刪除超出範圍的成員。
-
ZREMRANGEBYRANK key start stop 該命令用於移除有序集中指定排名範圍內的所有成員。如果你想保留前 N 個成員,那麼 start 應該是 0,stop 應該是 TotalMembers - N - 1。(ZREMRANGEBYRANK 是基於升序操作的,0 代表排名第一的成員分數最低。)
-
每次 ZADD 後立即修剪 / 定時任務修剪。
const limit = 10
total, _ := rdb.ZCard(ctx, leaderboardKey).Result()
if total > limit {
rdb.ZRemRangeByRank(ctx, leaderboardKey, 0, total-limit-1).Result()
}
- 定時更新排行榜:
- 定時任務每隔 X 秒讀取最新的排行榜數據,將數據存儲到另一個緩存層;外部請求查詢排行榜時,查詢這個緩存層。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/iYv26M0YiAVZPbqZfw4fPQ