Mục lục
- Mục Tiêu Bài Học
- Bài Toán Leaderboard
- Pattern Cơ Bản: ZADD, ZINCRBY, ZREVRANGE, ZREVRANK
- Pagination & Neighbors
- Time-bound Leaderboard
- Aggregate Multi-period Với ZUNIONSTORE
- Tie-break & Floating-point Precision
- Personal Best Với ZADD GT
- Scale & Memory
- Sharding Cho Throughput Cao
- Cluster Mode & Hash Tag
- Realtime UI & Cache Top N
- Anti-patterns & Best Practices
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Triển khai được leaderboard cơ bản: set/increment score, lấy top N, lấy rank của user cụ thể.
- Phân trang leaderboard an toàn (ZREVRANGE với offset/count) và lấy danh sách neighbors xung quanh rank.
- Xây dựng time-bound leaderboard (daily/weekly) bằng key-per-period kết hợp TTL.
- Aggregate nhiều period thành all-time bằng ZUNIONSTORE, hiểu constraint cùng slot trong Cluster.
- Xử lý tie-break, floating-point precision, và ghi nhận personal best bằng ZADD GT (Redis 6.2+).
- Ước lượng memory và biết khi nào cần sharding hoặc Redis Cluster.
- Nhận diện các anti-patterns phổ biến và áp dụng best practices cho production.
Bài Toán Leaderboard
Leaderboard xuất hiện trong nhiều loại sản phẩm: bảng xếp hạng game theo điểm số, ranking học viên theo bài tập hoàn thành, bảng doanh số nhân viên bán hàng, top contributor trong cộng đồng. Dù domain khác nhau, yêu cầu kỹ thuật đều hội tụ về cùng một tập vấn đề:
- Top N theo score: trả về 10 hay 100 player đứng đầu, theo thứ tự giảm dần, realtime sau mỗi lần cập nhật.
- Rank của user: user A đang đứng thứ mấy trong số toàn bộ người chơi?
- Cập nhật realtime: mỗi khi user kiếm thêm điểm, rank toàn bộ danh sách cần phản ánh ngay.
- Time-bound: leaderboard tuần/tháng reset theo kỳ; leaderboard all-time tích lũy mãi.
- Scale: 1 triệu người dùng cùng tranh hạng, với write throughput có thể lên đến hàng trăm nghìn update/giây.
Cách tiếp cận database quan hệ — SELECT ... ORDER BY score DESC LIMIT 10 — hoạt động được nhưng có giới hạn rõ ràng: mỗi lần tính rank cần full scan hoặc re-sort, không phù hợp cho write/read realtime tần suất cao. Redis Sorted Set giải quyết bài toán này tự nhiên hơn vì cấu trúc nội bộ (skiplist + hashtable) duy trì thứ tự theo score sau mỗi ZADD với độ phức tạp O(log N).
Pattern Cơ Bản: ZADD, ZINCRBY, ZREVRANGE, ZREVRANK
Bốn lệnh cốt lõi và Big-O của chúng:
ZADD key score member— O(log N): thêm hoặc cập nhật member với score mới.ZINCRBY key increment member— O(log N): tăng score của member một lượng cụ thể (cộng dồn).ZREVRANGE key start stop [WITHSCORES]— O(log N + M): trả M member theo thứ tự score giảm dần.ZREVRANK key member— O(log N): trả 0-indexed rank của member (rank 0 = score cao nhất).
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
KEY = "leaderboard:global"
def set_score(user_id: str, score: float) -> None:
"""Gán score tuyệt đối cho user (ghi đè nếu đã tồn tại)."""
r.zadd(KEY, {user_id: score})
def increment_score(user_id: str, by: float) -> float:
"""Cộng dồn score; trả về score mới sau khi tăng."""
return r.zincrby(KEY, by, user_id)
def top_n(n: int = 10) -> list[tuple[str, float]]:
"""Trả danh sách (user_id, score) của N player đứng đầu."""
return r.zrevrange(KEY, 0, n - 1, withscores=True)
def get_rank(user_id: str) -> int | None:
"""Trả 1-indexed rank. None nếu user chưa có trong leaderboard."""
rank = r.zrevrank(KEY, user_id)
return rank + 1 if rank is not None else None
def get_score(user_id: str) -> float | None:
"""Trả score hiện tại của user."""
return r.zscore(KEY, user_id)
Một điểm cần lưu ý: ZREVRANK trả về None nếu member không tồn tại trong key, không phải 0. Luôn kiểm tra None trước khi cộng 1 để convert sang 1-indexed.
ZINCRBY phù hợp hơn ZADD khi score là tích lũy (game event, thao tác học tập). ZADD phù hợp khi score là snapshot tuyệt đối (quiz cuối kỳ cập nhật lại tổng điểm).
Pagination & Neighbors
Pagination
ZREVRANGE nhận start và stop theo index (0-based). Để load trang thứ page với per_page item mỗi trang:
def get_page(page: int, per_page: int = 20) -> list[tuple[str, float]]:
"""
Trả rank từ (page * per_page) đến (page * per_page + per_page - 1).
page=0 → rank 1..20, page=1 → rank 21..40, ...
"""
if per_page > 100:
raise ValueError("per_page không được vượt quá 100")
start = page * per_page
stop = start + per_page - 1
return r.zrevrange(KEY, start, stop, withscores=True)
Giới hạn cứng per_page ≤ 100 là cần thiết. ZREVRANGE với stop rất lớn (ví dụ 0..999999) trả về toàn bộ tập và chiếm băng thông lẫn memory đột biến. Độ phức tạp là O(log N + M) — M phần tử trả về — nên M nhỏ thì nhanh, M lớn thì tốn kém.
Neighbors (xung quanh rank của user)
UI game thường hiển thị "5 người ngay trước và 5 người ngay sau bạn trong bảng xếp hạng":
def get_neighbors(user_id: str, window: int = 5) -> list[tuple[str, float]]:
"""
Trả window*2 + 1 player xung quanh user_id.
Nếu user ở đầu/cuối, list ngắn hơn tự nhiên vì max(0, rank - window).
"""
rank = r.zrevrank(KEY, user_id)
if rank is None:
return []
start = max(0, rank - window)
stop = rank + window
return r.zrevrange(KEY, start, stop, withscores=True)
max(0, rank - window) xử lý trường hợp user đang ở top đầu (rank 0..4) — tránh index âm. Redis không lỗi với index âm nhưng kết quả sẽ không đúng ý định.
Time-bound Leaderboard
Leaderboard tuần/tháng yêu cầu reset theo kỳ. Pattern đơn giản và hiệu quả nhất là key-per-period: mỗi kỳ dùng một key riêng, không cần lệnh reset hay DEL thủ công.
from datetime import date, timedelta
def daily_key(d: date | None = None) -> str:
"""leaderboard:day:2026-06-01"""
d = d or date.today()
return f"leaderboard:day:{d.isoformat()}"
def weekly_key(d: date | None = None) -> str:
"""leaderboard:week:2026-W22"""
d = d or date.today()
iso = d.isocalendar() # (year, week, weekday)
return f"leaderboard:week:{iso.year}-W{iso.week:02d}"
def monthly_key(d: date | None = None) -> str:
"""leaderboard:month:2026-06"""
d = d or date.today()
return f"leaderboard:month:{d.year}-{d.month:02d}"
# TTL: giữ N kỳ gần nhất để phục vụ history
TTL_DAILY_S = 7 * 86400 # giữ 7 ngày
TTL_WEEKLY_S = 8 * 7 * 86400 # giữ 8 tuần
TTL_MONTHLY_S = 3 * 30 * 86400 # giữ 3 tháng
def increment_all_periods(user_id: str, by: float) -> None:
"""Cộng score vào leaderboard ngày, tuần, tháng hiện tại."""
pipe = r.pipeline(transaction=False)
for key, ttl in [
(daily_key(), TTL_DAILY_S),
(weekly_key(), TTL_WEEKLY_S),
(monthly_key(), TTL_MONTHLY_S),
]:
pipe.zincrby(key, by, user_id)
pipe.expire(key, ttl, nx=True) # chỉ set TTL lần đầu (nx=True, Redis 7.0+)
pipe.execute()
Dùng expire key ttl NX (Redis 7.0+) để chỉ set TTL lần đầu tiên key được tạo trong kỳ đó. Nếu Redis < 7.0, có thể dùng Lua hoặc chỉ EXPIRE bình thường (sẽ refresh TTL mỗi lần nhưng không gây lỗi logic).
Không cần cron job để reset: key hết TTL tự biến mất. Kỳ mới → key mới → ZINCRBY tự tạo key từ đầu.
Aggregate Multi-period Với ZUNIONSTORE
ZUNIONSTORE destination numkeys key [key...] [WEIGHTS w...] [AGGREGATE SUM|MIN|MAX] gộp nhiều Sorted Set thành một. Dùng để tổng hợp leaderboard all-time từ daily:
def compute_weekly_from_daily(target_week: date) -> str:
"""
Tổng hợp leaderboard tuần từ 7 key daily.
Trả về key của leaderboard tuần vừa tạo.
"""
monday = target_week - timedelta(days=target_week.weekday())
days = [monday + timedelta(days=i) for i in range(7)]
keys = [daily_key(d) for d in days]
dest = weekly_key(monday)
# Chỉ aggregate những key đang tồn tại
existing = [k for k in keys if r.exists(k)]
if not existing:
return dest
r.zunionstore(dest, existing, aggregate="SUM")
r.expire(dest, TTL_WEEKLY_S)
return dest
ZUNIONSTORE với aggregate SUM cộng score của member xuất hiện ở nhiều key. Với aggregate MAX chỉ giữ score cao nhất trong các kỳ (hữu ích cho "peak performance").
Lưu ý quan trọng: ZUNIONSTORE ghi đè key đích và đặt TTL mới. Nên gọi sau mỗi kỳ (cron cuối ngày/tuần) thay vì gọi realtime mỗi request — nó là O(N log N) trên tổng số member.
Vấn đề với Redis Cluster
ZUNIONSTORE yêu cầu tất cả key nguồn và key đích nằm cùng slot. Với Cluster, key leaderboard:day:2026-06-01 và leaderboard:week:2026-W22 có thể hash vào slot khác nhau → lệnh thất bại với CROSSSLOT error. Giải pháp ở mục 11.
Tie-break & Floating-point Precision
Tie-break mặc định
Khi hai member có cùng score, Redis Sorted Set sắp xếp theo thứ tự lexicographic của member string. Ví dụ: user_id "alice" < "bob" → alice đứng trước dù cùng điểm. Điều này thường không phải hành vi mong muốn trong game.
Tie-break bằng timestamp: user nào đạt điểm sớm hơn đứng trên
Pattern: encode score thực và timestamp vào một float duy nhất:
import time
MAX_TIMESTAMP = 2_000_000_000.0 # timestamp Unix năm 2033, đủ dùng
def score_with_tiebreak(real_score: int, achieved_at: float | None = None) -> float:
"""
Ghép real_score và timestamp thành một float duy nhất.
- real_score tăng → composite tăng (player giỏi hơn đứng trên).
- Cùng real_score: achieved_at nhỏ hơn (sớm hơn) → tiebreak lớn hơn → đứng trên.
Giả định: real_score là integer, MAX_TIMESTAMP đủ lớn hơn mọi timestamp thực tế.
"""
t = achieved_at or time.time()
# Phần thập phân: (1 - timestamp / MAX_TIMESTAMP) nằm trong (0, 1)
tiebreak_fraction = 1.0 - (t / MAX_TIMESTAMP)
return float(real_score) + tiebreak_fraction
def set_score_with_tiebreak(user_id: str, new_score: int) -> None:
composite = score_with_tiebreak(new_score)
r.zadd(KEY, {user_id: composite})
Cách tiếp cận này chỉ đúng khi real_score là integer và không cần phần thập phân. Nếu real_score đã có thập phân, dùng hai Sorted Set riêng: một cho score chính, một cho timestamp làm tiebreaker và so sánh bằng Lua script.
Floating-point precision
Score trong Redis Sorted Set được lưu dưới dạng IEEE 754 double (64-bit). Giới hạn cần biết:
- Integer đến 253 (≈ 9 × 1015) được biểu diễn chính xác tuyệt đối.
- Trên 253, hai integer liền kề có thể ra cùng một giá trị float → thứ tự sai.
- Score kiểu tiền/điểm có 2 chữ số thập phân: nhân 100 lưu dưới dạng integer (1234.56 → 123456). Tránh so sánh
zscore == xvới float tùy tiện.
# Không nên: lưu 9_999_999_999_999_999 + 1 vào float
# Nên: nếu score có thể vượt 2^53, dùng string member + separate hash cho score thật
# Nếu score là tiền (VND), nhân 100 thành xu:
SCORE_MULTIPLIER = 100 # 1 đồng = 100 xu
def set_money_score(user_id: str, amount_vnd: float) -> None:
r.zadd(KEY, {user_id: int(amount_vnd * SCORE_MULTIPLIER)})
Personal Best Với ZADD GT
Một số use case cần lưu song song hai leaderboard: current (điểm hiện tại của lượt chơi gần nhất) và best (kỷ lục cá nhân). Kỷ lục chỉ được cập nhật khi score mới lớn hơn score cũ.
Redis 6.2 thêm option GT cho ZADD: chỉ cập nhật nếu score mới lớn hơn (Greater Than) score hiện có. Atomic, không cần Lua:
CURRENT_KEY = "leaderboard:current"
BEST_KEY = "leaderboard:best"
def record_score(user_id: str, score: float) -> dict:
"""
Ghi nhận score lượt chơi mới.
- current luôn được cập nhật.
- best chỉ được cập nhật nếu score mới > best hiện tại (ZADD GT).
Trả dict gồm current và best sau khi cập nhật.
"""
pipe = r.pipeline(transaction=False)
pipe.zadd(CURRENT_KEY, {user_id: score}) # luôn ghi đè
pipe.zadd(BEST_KEY, {user_id: score}, gt=True) # chỉ ghi nếu lớn hơn
pipe.zscore(CURRENT_KEY, user_id)
pipe.zscore(BEST_KEY, user_id)
results = pipe.execute()
return {"current": results[2], "best": results[3]}
Option gt=True trong redis-py tương ứng với ZADD key GT score member. Đối ngược là lt=True (chỉ cập nhật nếu nhỏ hơn) — hữu ích cho "best time" trong racing game (thời gian càng thấp càng tốt).
GT/LT không hoạt động cùng với NX. Nếu member chưa tồn tại, GT vẫn thêm mới (không có giá trị cũ để so sánh nên coi như đúng điều kiện). Hành vi này là đúng ý cho leaderboard best score.
Scale & Memory
Memory encoding
Redis tự chọn encoding tùy kích thước Sorted Set:
- listpack (còn gọi ziplist trong Redis < 7.0): dùng khi số member <
zset-max-listpack-entries(default 128) và mọi value <zset-max-listpack-value(default 64 byte). Compact, lưu liền kề trong memory, tốt cho read nhưng update O(N). - skiplist + hashtable: khi vượt ngưỡng trên. Update O(log N), memory cao hơn (~80–150 byte/member tùy length của member string).
Leaderboard với user_id dạng UUID (36 ký tự) sẽ vượt ngưỡng 64 byte → luôn dùng skiplist. Nên dùng numeric user_id ngắn để tiết kiệm memory.
Ước tính memory
# Giả định: user_id = 8-digit integer string ("12345678")
# Memory per member (skiplist): ~80 byte (overhead) + length(member) + 8 byte score
# ≈ 80 + 8 + 8 = ~96 byte/member
# 1M member → 1_000_000 × 96B ≈ 92 MB → bình thường
# 10M member → 10_000_000 × 96B ≈ 916 MB → cần monitor
# 100M member→ 100_000_000 × 96B ≈ 9 GB → cần Cluster hoặc shard
Latency
ZADD và ZREVRANK trên Sorted Set 100M member: ~0.5–2 ms (O(log N ≈ 27)). ZREVRANGE 0..9 trên 100M: tương tự, M = 10 rất nhỏ. Bottleneck thường không phải latency per lệnh mà là throughput — một Redis node đơn xử lý được ~100k–200k ops/giây tùy phần cứng.
Sharding Cho Throughput Cao
Khi write throughput vượt khả năng một node (ví dụ > 200k ZADD/giây), giải pháp là shard leaderboard: chia nhỏ thành N sorted set trên N node, routing theo hash của user_id.
NUM_SHARDS = 8
def shard_key(user_id: str, base_key: str = "leaderboard:global") -> str:
shard_idx = int(user_id) % NUM_SHARDS
return f"{base_key}:shard:{shard_idx}"
def sharded_increment(user_id: str, by: float) -> None:
key = shard_key(user_id)
r.zincrby(key, by, user_id)
def sharded_top_n(n: int = 10) -> list[tuple[str, float]]:
"""
Lấy top N từ tất cả shard, merge và sắp xếp.
Mỗi shard đọc top N; merge lấy top N từ N*N candidates.
"""
candidates: list[tuple[str, float]] = []
for i in range(NUM_SHARDS):
key = f"leaderboard:global:shard:{i}"
candidates.extend(r.zrevrange(key, 0, n - 1, withscores=True))
candidates.sort(key=lambda x: x[1], reverse=True)
return candidates[:n]
Tradeoff khi sharding:
- Write: đơn giản hơn — mỗi update chỉ đụng 1 shard.
- Top N: phải đọc tất cả N shard và merge. Với N = 8 shard, đọc 8 lần thay vì 1. Nếu đọc thường xuyên, cache kết quả top N (xem mục 12).
- Rank của user: ZREVRANK trong shard của user trả rank trong shard đó, không phải rank toàn cục. Tính rank toàn cục cần đọc semua shard — phức tạp hơn và thường không worth it. Nếu cần rank chính xác toàn cục ở throughput cao, xem xét approximate rank hoặc dedicated global leaderboard refresh theo batch.
Cluster Mode & Hash Tag
Redis Cluster chia 16384 slot cho các node. Lệnh multi-key như ZUNIONSTORE yêu cầu tất cả key nằm cùng một slot. Nếu không, Redis trả lỗi:
CROSSSLOT Keys in request don't hash to the same slot
Hash tag giải quyết vấn đề này: phần trong cặp {} đầu tiên trong tên key được dùng để tính slot, phần còn lại bị bỏ qua. Tất cả key có cùng hash tag sẽ hash vào cùng slot:
# Không dùng hash tag → slot khác nhau → ZUNIONSTORE lỗi
"leaderboard:day:2026-06-01"
"leaderboard:week:2026-W22"
"leaderboard:all"
# Dùng hash tag {global} → tất cả hash theo "global" → cùng slot
"leaderboard:{global}:day:2026-06-01"
"leaderboard:{global}:week:2026-W22"
"leaderboard:{global}:all"
def daily_key_cluster(d: date | None = None) -> str:
d = d or date.today()
return f"leaderboard:{{global}}:day:{d.isoformat()}"
def weekly_key_cluster(d: date | None = None) -> str:
d = d or date.today()
iso = d.isocalendar()
return f"leaderboard:{{global}}:week:{iso.year}-W{iso.week:02d}"
def compute_weekly_cluster(target_week: date) -> str:
monday = target_week - timedelta(days=target_week.weekday())
days = [monday + timedelta(days=i) for i in range(7)]
keys = [daily_key_cluster(d) for d in days]
dest = weekly_key_cluster(monday)
existing = [k for k in keys if r.exists(k)]
if existing:
r.zunionstore(dest, existing, aggregate="SUM")
r.expire(dest, TTL_WEEKLY_S)
return dest
Lưu ý: hash tag buộc toàn bộ leaderboard vào cùng một node trong Cluster → không tận dụng được horizontal scaling cho key group đó. Nếu cần scale write trên Cluster, kết hợp với sharding ở mục 10 (hash tag theo shard: leaderboard:{shard:0}:day:...).
Realtime UI & Cache Top N
Cache top N cho hot dashboard
Dashboard hiển thị top 100 và auto-refresh mỗi 5 giây: mỗi client gọi một request, 1000 concurrent user = 200 ZREVRANGE/giây cho top 100. Không cần thiết — dùng String cache với TTL ngắn:
import json
TOP_CACHE_KEY = "leaderboard:top100:cache"
TOP_CACHE_TTL = 5 # giây
def get_top100_cached() -> list[tuple[str, float]]:
cached = r.get(TOP_CACHE_KEY)
if cached:
return json.loads(cached)
data = r.zrevrange(KEY, 0, 99, withscores=True)
r.set(TOP_CACHE_KEY, json.dumps(data), ex=TOP_CACHE_TTL)
return data
Trade-off: top 100 stale tối đa 5 giây. Chấp nhận được cho hầu hết leaderboard game. Điều chỉnh TTL theo SLA của sản phẩm.
Realtime update cho UI cụ thể
Khi cần push rank update ngay lập tức đến client cụ thể (ví dụ: user vừa tăng rank → popup hiển thị), kết hợp với Pub/Sub:
RANK_CHANNEL = "leaderboard:rank_update"
def score_and_notify(user_id: str, by: float) -> None:
new_score = r.zincrby(KEY, by, user_id)
new_rank = r.zrevrank(KEY, user_id)
if new_rank is not None:
payload = json.dumps({
"user_id": user_id,
"score": new_score,
"rank": new_rank + 1,
})
r.publish(RANK_CHANNEL, payload)
Frontend subscribe channel qua WebSocket proxy (bài 67 đã đề cập pattern scale-websocket-multi-instance). Chỉ publish cho user cụ thể — không broadcast toàn bộ leaderboard mỗi lần có thay đổi.
Anti-patterns & Best Practices
Anti-patterns
- ZREVRANGE 0 1000000: một lệnh trả 1M entry chiếm hàng trăm MB response, block event loop. Luôn giới hạn count.
- Không cap per_page: API không validate
limit→ attacker gửilimit=99999→ DDoS nhẹ. - ZADD mỗi micro-event không batch: game event mỗi 10ms × 100k user = 10M ZADD/giây → quá tải. Batch bằng pipeline hoặc tích lũy phía ứng dụng, flush theo khoảng thời gian.
- Float score lớn: tích lũy score qua nhiều kỳ không reset → vượt 253 → precision lỗi thứ tự.
- Cluster không hash tag khi dùng ZUNIONSTORE: lỗi CROSSSLOT, silent failure nếu không handle exception.
- Không reset key cũ: time-bound leaderboard không set TTL → key tích lũy vô hạn → memory growth không kiểm soát được.
- Score không capped: anti-cheat cần giới hạn trên cho mỗi user. ZADD score vô hạn → memory OK (vẫn 1 member) nhưng outlier phá UX của người chơi khác.
Best practices
- Dùng integer score (nhân 100 cho decimal thay vì lưu float).
- Key-per-period + TTL cho time-bound; không dùng DEL thủ công.
- Hash tag cho Cluster khi có ZUNIONSTORE.
- Cache top N trong String/TTL ngắn cho hot read path.
- Giới hạn cứng count trong mọi pagination API (ví dụ: max 100).
- ZADD GT (Redis 6.2+) cho personal best — atomic, không cần Lua.
- Audit log score change bằng Redis Stream nếu cần rollback hoặc cheat detection.
- Batch ZINCRBY trong pipeline khi nhiều event nhỏ xảy ra liên tiếp.
Tổng Kết & Quiz
Tổng kết
- Sorted Set phù hợp tự nhiên với leaderboard: ZADD/ZINCRBY cập nhật score O(log N), ZREVRANGE trả top N O(log N + M), ZREVRANK cho rank O(log N).
- Time-bound leaderboard dùng key-per-period + TTL; không cần reset thủ công; ZUNIONSTORE gộp nhiều kỳ thành all-time.
- Tie-break mặc định là lexicographic member; có thể encode timestamp vào fractional part của score để user sớm đứng trên khi hòa.
- ZADD GT (Redis 6.2+) cập nhật personal best atomically; ZADD LT cho "best time".
- Memory ~96 byte/member (skiplist, numeric ID); 1M user ≈ 92 MB; 100M user cần Cluster.
- Cluster + ZUNIONSTORE cần hash tag để tránh CROSSSLOT error.
- Sharding tăng write throughput nhưng làm phức tạp global rank và top N.
- Cache top N bằng String TTL ngắn giảm tải ZREVRANGE cho hot read path.
Quiz
- ZREVRANK trả 0 hay 1 cho player đứng đầu? Trả gì nếu user chưa có trong leaderboard?
- Vì sao nên dùng ZINCRBY thay vì ZADD khi score là tích lũy từng event? Trường hợp nào nên dùng ZADD?
- Leaderboard tuần chạy trên Redis Cluster. ZUNIONSTORE 7 key daily vào key weekly bị lỗi CROSSSLOT. Sửa như thế nào?
- User A và user B cùng đạt 5000 điểm. A đạt trước B 10 phút. Mô tả cách encode score để A đứng trên B.
- ZADD GT hoạt động thế nào khi member chưa tồn tại? Kết quả là gì?
- Leaderboard 10M user, ZADD throughput đạt 300k/giây từ game server. Tại sao 1 Redis node không đủ và giải pháp là gì?
Đáp án gợi ý
- ZREVRANK trả 0 (0-indexed) cho player có score cao nhất. Trả
Nonenếu member không tồn tại trong key. - ZINCRBY cộng dồn giá trị vào score hiện tại — dùng khi mỗi event chỉ biết "thêm bao nhiêu" (pickup item, kill enemy). ZADD dùng khi biết tổng điểm mới chính xác (ghi lại điểm cuối quiz, score từ external system).
- Đổi tên key dùng hash tag
{global}:leaderboard:{global}:day:2026-06-01,leaderboard:{global}:week:2026-W22, ... Tất cả hash theo "global" → cùng slot → ZUNIONSTORE thành công. - Composite score =
real_score + (1 - timestamp / MAX_TIMESTAMP). A đạt sớm hơn → timestamp A nhỏ hơn →1 - t_A/MAXlớn hơn → composite A lớn hơn → A đứng trên. - Khi member chưa tồn tại, GT coi như không có giá trị cũ để so sánh → thêm mới bình thường. Hành vi này đúng ý cho leaderboard best score (lần đầu chơi luôn được ghi nhận).
- Một Redis node đơn xử lý ~100k–200k ops/giây; 300k ZADD/giây vượt giới hạn → latency tăng, queue tích lũy. Giải pháp: sharding — chia thành N sorted set (N = 4 hoặc 8), route theo hash(user_id) % N, mỗi node chịu 300k/N ZADD/giây. Top N merge từ tất cả shard + cache kết quả.
Bài tiếp theo
Bài 93 giới thiệu RedisBloom Bloom filter — probabilistic data structure cho membership check với memory O(1) và false positive rate có thể kiểm soát.
