Bài 5: Set & Sorted Set (ZSet) - Cách xây dựng hệ thống Bảng xếp hạng (Leaderboard) theo thời gian thực.
Mục tiêu bài học:
-
Hiểu đặc tính loại bỏ trùng lặp (Unique) của Set và cách tìm giao tập (Mutual).
-
Sử dụng Sorted Set (ZSet) để xây dựng hệ thống Bảng xếp hạng (Leaderboard) theo thời gian thực (Real-time) thay thế cho câu lệnh
ORDER BYchậm chạp trong SQL.
1. Redis Set: Tập hợp dữ liệu duy nhất
Set là một danh sách không có thứ tự, trong đó mỗi phần tử chỉ xuất hiện duy nhất một lần. Cấu trúc này cực kỳ hoàn hảo cho các bài toán: đếm số IP truy cập duy nhất, lưu danh sách thẻ (tags) của bài viết, hoặc tìm bạn chung (Mutual Friends).
Thao tác cơ bản với Set:
Go
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
func workWithSet(rdb *redis.Client) {
keyUser1 := "friends:user_1"
keyUser2 := "friends:user_2"
// 1. Thêm phần tử vào Set (SADD)
// Nếu phần tử đã tồn tại, Redis tự động bỏ qua
rdb.SAdd(ctx, keyUser1, "user_A", "user_B", "user_C")
rdb.SAdd(ctx, keyUser2, "user_B", "user_C", "user_D")
// 2. Kiểm tra phần tử có nằm trong Set không (SISMEMBER) - O(1)
isFriend, _ := rdb.SIsMember(ctx, keyUser1, "user_A").Result()
fmt.Printf("user_A có phải bạn của user_1 không? %v\n", isFriend) // true
// 3. Tìm giao tập: Các phần tử xuất hiện ở cả 2 Set (SINTER)
// Ứng dụng: Tìm bạn chung (Mutual Friends)
mutualFriends, _ := rdb.SInter(ctx, keyUser1, keyUser2).Result()
fmt.Printf("Bạn chung của user_1 và user_2: %v\n", mutualFriends) // [user_B, user_C]
}
2. Sorted Set (ZSet): Động cơ của Leaderboard
ZSet giống hệt Set ở chỗ các phần tử là duy nhất. Tuy nhiên, mỗi phần tử trong ZSet được gán kèm một con số gọi là Score (Điểm số, kiểu float64). Redis sẽ tự động sắp xếp các phần tử dựa trên Score này.
Khi một user ghi thêm điểm, bạn chỉ cần cập nhật Score. Redis sẽ tự động đẩy user đó lên vị trí tương ứng với độ phức tạp . Việc lấy Top 10 hoặc Top 100 từ hàng triệu user diễn ra ngay lập tức.
Thao tác xây dựng Bảng xếp hạng:
Go
func workWithZSet(rdb *redis.Client) {
leaderboardKey := "leaderboard:season_1"
// 1. Thêm mới hoặc Cập nhật điểm của user (ZADD)
// Sử dụng struct redis.Z để định nghĩa Score và Member
rdb.ZAdd(ctx, leaderboardKey,
redis.Z{Score: 1500, Member: "player_A"},
redis.Z{Score: 1200, Member: "player_B"},
redis.Z{Score: 1800, Member: "player_C"},
)
// 2. Tăng thêm điểm cho user (ZINCRBY)
// Ví dụ: player_B vừa thắng 1 trận được cộng thêm 400 điểm (1200 + 400 = 1600)
newScore, _ := rdb.ZIncrBy(ctx, leaderboardKey, 400, "player_B").Result()
fmt.Printf("Điểm mới của player_B: %.0f\n", newScore)
// 3. Lấy Top 3 người cao điểm nhất (ZREVRANGEWITHSCORES)
// ZREVRANGE lấy từ cao xuống thấp (Desc). Lấy từ index 0 đến 2 là 3 người đầu tiên.
topPlayers, err := rdb.ZRevRangeWithScores(ctx, leaderboardKey, 0, 2).Result()
if err == nil {
fmt.Println("--- BẢNG XẾP HẠNG TOP 3 ---")
for i, p := range topPlayers {
fmt.Printf("Top %d: %s (%.0f điểm)\n", i+1, p.Member, p.Score)
}
}
// 4. Xem thứ hạng của một user cụ thể (ZREVRANK)
// Lưu ý: Rank trong Redis bắt đầu từ 0 (người cao điểm nhất rank 0)
// Nên khi hiển thị cho người dùng thực tế, cần cộng thêm 1
rank, err := rdb.ZRevRank(ctx, leaderboardKey, "player_A").Result()
if err == redis.Nil {
fmt.Println("player_A chưa tham gia bảng xếp hạng")
} else if err == nil {
fmt.Printf("Thứ hạng hiện tại của player_A: %d\n", rank+1)
}
}
3. Kinh nghiệm Production cho Leaderboard
-
Xử lý trùng điểm (Ties): Nếu hai người có cùng Score, Redis sẽ tự động sắp xếp theo thứ tự bảng chữ cái (Lexicographical) của Member. Nếu bạn muốn người nào đạt điểm trước xếp trên, một kỹ thuật phổ biến là sử dụng Timestamp gộp vào phần thập phân của Score. (Ví dụ: Điểm thực là 1500. Timestamp là 1690000000. Score lưu vào Redis =
1500.0001690000000- nghịch đảo timestamp để sắp xếp đúng). -
Giới hạn kích thước: Nếu game của bạn có hàng chục triệu user nhưng bạn chỉ quan tâm Top 10.000, hãy dùng lệnh
ZREMRANGEBYRANKchạy định kỳ (hoặc qua cron job) để cắt bỏ phần đuôi bảng xếp hạng, giúp tiết kiệm bộ nhớ RAM đắt đỏ của Redis.
All rights reserved