Danh sách bài viết

Bài 24: Sorted Set — Leaderboard, Ranking & Delayed Queue

Sorted Set (ZSet) kết hợp hai đặc tính của Set và sorted array: members luôn unique như Set, nhưng mỗi member gắn thêm một score kiểu float và Redis tự giữ thứ tự theo score đó. Nhờ cấu trúc nội bộ là skiplist + hashtable, ZSet có thể trả về rank, range theo score, hoặc pop phần tử nhỏ/lớn nhất — tất cả với O(log N). Bài này đào sâu tập lệnh ZSet, sau đó đi qua 5 use case quan trọng: leaderboard, ranking around me, delayed queue, sliding window rate limit và priority queue. Mỗi use case có code Python chạy được và phân tích khi nào pattern đó phù hợp.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Giải thích được Sorted Set là gì, khác gì Set thông thường, và tại sao cần trường score.
  • Dùng thành thạo tập lệnh ZSet: ZADD, ZSCORE, ZRANK/ZREVRANK, ZRANGE/ZREVRANGE, ZRANGEBYSCORE, ZINCRBY, ZREM, ZPOPMIN/ZPOPMAX.
  • Sử dụng ZADD options GT/NX/XX (Redis 6.2+) đúng mục đích.
  • Triển khai leaderboard và "ranking around me" bằng ZSet.
  • Hiểu pattern delayed queue: score = Unix timestamp, worker dùng ZRANGEBYSCORE poll job đến hạn.
  • Nhận biết khi nào ZSet phù hợp cho sliding window rate limit và priority queue (ở mức giới thiệu; deep-dive ở Module 3 và 5).
  • Giải thích encoding listpack vs skiplist và cấu hình liên quan.
  • Nhận diện các anti-pattern phổ biến: ZRANGE toàn bộ, ZSet không TTL, polling quá dày.
2

Sorted Set Là Gì & Tại Sao Cần Score

Bài 23 về Set cho thấy Set rất tốt khi cần unique members và set operations (union, intersection). Nhưng Set không có thứ tự — nếu bạn cần biết ai đứng đầu bảng xếp hạng, Set không giúp được gì mà không scan toàn bộ.

Sorted Set thêm vào một trường score kiểu double precision float (64-bit) cho mỗi member. Redis tự đảm bảo:

  • Members luôn unique (như Set); score có thể trùng nhau.
  • Tập hợp luôn được giữ theo thứ tự score tăng dần.
  • Khi hai member có score bằng nhau, Redis sắp xếp theo lexicographic order của member string.

Điều này cho phép các lệnh như "trả về 10 member có score cao nhất", "trả về tất cả member có score trong khoảng [100, 500]", hay "rank của member X là bao nhiêu" — tất cả với độ phức tạp O(log N) nhờ cấu trúc nội bộ skiplist + hashtable.

Skiplist cho phép tìm kiếm/insert/delete O(log N) và duyệt range hiệu quả. Hashtable đi kèm để tra score của một member cụ thể trong O(1) mà không cần tìm trên skiplist.

Score -inf+inf là hợp lệ, thường dùng trong các lệnh range để chỉ "không giới hạn phía dưới/trên".

3

Tập Lệnh Cốt Lõi

Các lệnh cần nắm vững, nhóm theo mục đích:

Ghi và cập nhật

# ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
ZADD leaderboard 1000 "player:1" 2500 "player:2" 1800 "player:3"
# Trả về số member MỚI được thêm (không tính update)

ZINCRBY leaderboard 500 "player:1"   # atomic +500 → trả về score mới (1500)
ZREM leaderboard "player:3"          # xóa 1 member

Đọc score và rank

ZSCORE leaderboard "player:2"      # → "2500" (nil nếu không tồn tại)
ZRANK leaderboard "player:1"       # → rank 0-based, thứ tự score TĂNG dần
ZREVRANK leaderboard "player:2"    # → rank 0-based, thứ tự score GIẢM dần (cao nhất = 0)
ZCARD leaderboard                  # → số member trong ZSet

Lưu ý: ZRANK trả về vị trí khi sắp xếp từ thấp đến cao (player:1 score 1500 đứng cuối trong ví dụ trên vì thấp nhất sau khi player:3 bị xóa). ZREVRANK đảo ngược: score cao nhất = rank 0.

Lấy range

# ZRANGE: lấy theo chỉ số (score tăng dần)
ZRANGE leaderboard 0 -1 WITHSCORES    # tất cả, kèm score

# ZREVRANGE: lấy theo chỉ số (score giảm dần) — dùng cho leaderboard
ZREVRANGE leaderboard 0 9 WITHSCORES  # top 10

# ZRANGEBYSCORE: lọc theo khoảng score
ZRANGEBYSCORE leaderboard 1000 2000 WITHSCORES
ZRANGEBYSCORE leaderboard -inf +inf LIMIT 0 10   # 10 đầu (score thấp nhất)

# ZREVRANGEBYSCORE: score giảm dần với khoảng
ZREVRANGEBYSCORE leaderboard +inf 1000 WITHSCORES LIMIT 0 10

# ZRANGEBYLEX: lọc theo alphabet (chỉ hợp lệ khi tất cả score bằng nhau)
ZRANGEBYLEX myset "[b" "[e"    # member từ "b" tới "e"

Đếm và pop

ZCOUNT leaderboard 1000 2000    # đếm member có score trong [1000, 2000]
ZPOPMIN leaderboard 1           # pop 1 member có score thấp nhất (atomic)
ZPOPMAX leaderboard 1           # pop 1 member có score cao nhất (atomic)

Từ Redis 6.2+, ZRANGE được mở rộng với cú pháp mới hỗ trợ REV, BYSCORE, BYLEX, LIMIT trong một lệnh duy nhất, thay thế cho ZREVRANGE/ZRANGEBYSCORE. Cú pháp cũ vẫn còn nhưng đã deprecated trong docs Redis 7.x.

4

ZADD Options: NX, XX, GT, LT, CH, INCR

ZADD chấp nhận các option để kiểm soát điều kiện ghi. Options GT và LT được thêm từ Redis 6.2.

  • NX: chỉ thêm member mới, không update member đã có.
  • XX: chỉ update member đã có, không thêm member mới.
  • GT (Greater Than): chỉ update nếu score mới lớn hơn score hiện tại. Ngầm không thêm member mới khi không có member đó (trừ khi kết hợp với logic thêm thông thường — thực ra GT chỉ update, không add).
  • LT (Less Than): chỉ update nếu score mới nhỏ hơn score hiện tại.
  • CH: thay đổi return value từ "số member mới thêm" thành "số member được thêm hoặc có score thay đổi".
  • INCR: ZADD hoạt động như ZINCRBY — cộng score thay vì ghi đè.

GT và XX không thể dùng cùng nhau; GT và LT không thể dùng cùng nhau.

# Leaderboard "high score" — chỉ ghi lại nếu người chơi đạt điểm mới cao hơn
ZADD leaderboard GT 9000 "player:alice"
# Nếu alice đang có 9500, lệnh này không làm gì (9000 < 9500)
# Nếu alice đang có 8000, lệnh này update lên 9000

# Chỉ update player đã có trong leaderboard, từ chối thêm mới
ZADD leaderboard XX 5000 "player:unknown"

# Thêm member mới nhưng không ghi đè score nếu đã tồn tại
ZADD leaderboard NX 1000 "player:newbie"

Cờ GT đặc biệt hữu ích cho high-score leaderboard: nếu người chơi gửi điểm thấp hơn kỷ lục hiện tại (do replay, cheat detection, hay timing issue), Redis bỏ qua mà không cần application kiểm tra trước.

5

Use Case 1 — Leaderboard

Leaderboard là use case kinh điển nhất của Sorted Set. Yêu cầu điển hình:

  • Cập nhật điểm người chơi (real-time hoặc batch).
  • Lấy top-N người dùng.
  • Lấy rank và điểm của một người dùng cụ thể.
-- Khởi tạo / cập nhật điểm
ZADD game:leaderboard 9500 "player:alice"
ZADD game:leaderboard 8700 "player:bob"
ZADD game:leaderboard 11200 "player:carol"

-- Thêm điểm atomic (+100 điểm mỗi lần đạt milestone)
ZINCRBY game:leaderboard 100 "player:alice"   -- trả về 9600

-- Top 10 (score giảm dần)
ZREVRANGE game:leaderboard 0 9 WITHSCORES

-- Rank của alice (1-based cho UI, vì ZREVRANK trả về 0-based)
ZREVRANK game:leaderboard "player:alice"   -- 1 (carol=0, alice=1, bob=2)
-- UI hiển thị: rank + 1

-- Score của alice
ZSCORE game:leaderboard "player:alice"    -- 9600

-- Tổng số người chơi
ZCARD game:leaderboard

Code Python (redis-py)

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

LEADERBOARD = "game:leaderboard"

def update_score(player_id: str, score: float) -> None:
    """Ghi điểm (dùng GT: chỉ update nếu cao hơn hiện tại)."""
    r.zadd(LEADERBOARD, {player_id: score}, gt=True)

def add_score(player_id: str, delta: float) -> float:
    """Cộng thêm delta điểm, trả về score mới."""
    return r.zincrby(LEADERBOARD, delta, player_id)

def get_top(n: int = 10) -> list[tuple[str, float]]:
    """Top-N players, trả về list (player_id, score)."""
    return r.zrevrange(LEADERBOARD, 0, n - 1, withscores=True)

def get_player_info(player_id: str) -> dict | None:
    """Rank (1-based) và score của một player."""
    score = r.zscore(LEADERBOARD, player_id)
    if score is None:
        return None
    rank = r.zrevrank(LEADERBOARD, player_id)
    return {"rank": rank + 1, "score": score}


# Ví dụ sử dụng
update_score("player:alice", 9500)
update_score("player:bob", 8700)
update_score("player:carol", 11200)
add_score("player:alice", 100)   # alice = 9600

print(get_top(3))
# [('player:carol', 11200.0), ('player:alice', 9600.0), ('player:bob', 8700.0)]

print(get_player_info("player:alice"))
# {'rank': 2, 'score': 9600.0}

Độ phức tạp và scale

ZADD/ZINCRBY/ZSCORE/ZRANK: O(log N). ZREVRANGE top-10: O(log N + 10). Với N = 1 triệu player, truy vấn top-10 vẫn hoàn thành trong vài chục microsecond trên hardware trung bình.

Leaderboard theo phân khúc (theo server, theo mùa, theo ngày) chỉ cần dùng key riêng: game:leaderboard:season:2026-q2, game:leaderboard:server:asia. Mỗi key là một ZSet độc lập.

Deep-dive về leaderboard ở quy mô lớn (sharding, global vs regional) sẽ ở Module 8.

6

Use Case 2 — Ranking Around Me

Nhiều game và ứng dụng cần hiển thị không chỉ "bạn đứng thứ #1234" mà còn "đây là những người ngay trên và ngay dưới bạn trong bảng xếp hạng" — tính năng thường gọi là "ranking around me" hay "neighborhood ranking".

def get_ranking_around(player_id: str, window: int = 5) -> list[tuple[str, float]] | None:
    """
    Trả về 'window' người xếp trên và 'window' người xếp dưới player_id.
    Ví dụ window=5: 5 người trên + player + 5 người dưới = tối đa 11 kết quả.
    """
    rank = r.zrevrank(LEADERBOARD, player_id)
    if rank is None:
        return None

    start = max(0, rank - window)
    stop = rank + window

    return r.zrevrange(LEADERBOARD, start, stop, withscores=True)
result = get_ranking_around("player:alice", window=2)
# Nếu alice ở rank 1 (0-based), start=0, stop=3
# → Trả về: carol(rank 0), alice(rank 1), bob(rank 2), ... (tối đa 4 entry)

Lưu ý: khi player đứng gần đầu bảng, max(0, rank - window) đảm bảo không truy vấn index âm. Khi player đứng gần cuối, stop có thể vượt quá số member thực tế — ZREVRANGE tự cắt, không lỗi.

Toàn bộ pattern chỉ tốn 2 lệnh Redis: 1 ZREVRANK và 1 ZREVRANGE. Latency tổng thường dưới 1ms.

7

Use Case 3 — Delayed Queue

ZSet có thể làm scheduler đơn giản bằng cách dùng Unix timestamp làm score. Ý tưởng: mỗi job được thêm vào ZSet với score = thời điểm muốn chạy; worker định kỳ lấy các job có score ≤ thời điểm hiện tại.

-- Thêm job chạy lúc 14:00:00 ngày 01/01/2026 (Unix: 1735722000)
ZADD delayed_jobs 1735722000 "job:send-email:user:123"
ZADD delayed_jobs 1735722300 "job:push-notification:user:456"

-- Worker: lấy job đến hạn (score <= now)
-- ZRANGEBYSCORE delayed_jobs -inf  LIMIT 0 10
import time

DELAYED_QUEUE = "delayed_jobs"

def enqueue_delayed(job_id: str, run_at: float) -> None:
    """Thêm job vào delayed queue với thời điểm chạy."""
    r.zadd(DELAYED_QUEUE, {job_id: run_at})

def poll_due_jobs(batch_size: int = 10) -> list[str]:
    """Lấy các job đến hạn, xóa khỏi queue ngay lập tức."""
    now = time.time()
    # ZRANGEBYSCORE lấy danh sách job
    due_jobs = r.zrangebyscore(DELAYED_QUEUE, "-inf", now, start=0, num=batch_size)
    if not due_jobs:
        return []
    # Xóa các job đã lấy (cần dùng pipeline hoặc Lua để atomic)
    pipe = r.pipeline()
    for job in due_jobs:
        pipe.zrem(DELAYED_QUEUE, job)
    pipe.execute()
    return due_jobs


# Ví dụ
enqueue_delayed("job:send-email:123", time.time() + 60)   # chạy sau 60s
enqueue_delayed("job:cleanup:456", time.time() + 3600)    # chạy sau 1h

# Worker loop
import threading, time as t

def worker_loop():
    while True:
        jobs = poll_due_jobs(batch_size=10)
        for job in jobs:
            print(f"Processing: {job}")
            # ... thực thi job ...
        t.sleep(5)   # poll mỗi 5 giây

Vấn đề của pattern này: ZRANGEBYSCOREZREM không atomic nếu dùng riêng lẻ. Nếu worker crash sau ZRANGEBYSCORE nhưng trước ZREM, job bị mất. Giải pháp đúng (dùng Lua script hoặc ZPOPMIN theo thứ tự score, kết hợp với pending list) sẽ được phân tích chi tiết ở bài về Reliable Queue trong Module 5.

Bài này chỉ trình bày pattern cơ bản để hiểu ZSet làm scheduler. Dùng trong production cần thêm lớp đảm bảo at-least-once.

8

Use Case 4 — Sliding Window Rate Limit

ZSet có thể triển khai sliding window log chính xác cho rate limiting: mỗi request được ghi vào ZSet với score = timestamp; khi cần kiểm tra limit, xóa các entry cũ ngoài window rồi đếm entry còn lại.

def check_rate_limit(user_id: str, limit: int, window_ms: int) -> bool:
    """
    Trả về True nếu request được phép, False nếu vượt giới hạn.
    window_ms: độ rộng cửa sổ tính bằng millisecond.
    """
    key = f"rate:{user_id}"
    now_ms = int(time.time() * 1000)
    window_start = now_ms - window_ms
    request_id = f"{now_ms}-{id(object())}"   # unique request id

    pipe = r.pipeline()
    pipe.zadd(key, {request_id: now_ms})              # ghi request hiện tại
    pipe.zremrangebyscore(key, "-inf", window_start)   # xóa request cũ
    pipe.zcard(key)                                    # đếm request trong window
    pipe.expire(key, (window_ms // 1000) + 1)         # TTL để tránh key zombie
    results = pipe.execute()

    count = results[2]
    return count <= limit

Pattern này cho kết quả chính xác tuyệt đối (không có approximation như Fixed Window hay Sliding Window Counter) vì mỗi request đều được ghi lại theo timestamp thực. Nhược điểm: tốn memory O(requests per window per user), phù hợp cho window nhỏ và limit không quá lớn.

Module 3 sẽ so sánh đầy đủ các thuật toán rate limiting (Fixed Window, Sliding Window Log, Sliding Window Counter, Token Bucket, Leaky Bucket/GCRA) cùng trade-off về accuracy, memory và atomicity.

9

Use Case 5 — Priority Queue

Khi score = độ ưu tiên, ZSet trở thành priority queue. ZPOPMAX lấy phần tử ưu tiên cao nhất (atomic pop + remove), ZPOPMIN lấy phần tử ưu tiên thấp nhất.

ZADD tasks 1 "low-priority-cleanup"
ZADD tasks 5 "normal-email-batch"
ZADD tasks 10 "critical-payment-retry"

ZPOPMAX tasks   -- → [("critical-payment-retry", 10)]
ZPOPMAX tasks   -- → [("normal-email-batch", 5)]
TASK_QUEUE = "tasks"

def enqueue(task_id: str, priority: int) -> None:
    r.zadd(TASK_QUEUE, {task_id: priority})

def dequeue_highest() -> tuple[str, float] | None:
    """Pop task có priority cao nhất, atomic."""
    result = r.zpopmax(TASK_QUEUE, count=1)
    return result[0] if result else None

def queue_size() -> int:
    return r.zcard(TASK_QUEUE)


enqueue("task:cleanup:1", priority=1)
enqueue("task:email:batch:2", priority=5)
enqueue("task:payment:retry:3", priority=10)

print(dequeue_highest())   # ('task:payment:retry:3', 10.0)
print(queue_size())        # 2

Cần lưu ý: ZPOPMIN/ZPOPMAX chỉ có từ Redis 5.0. Với Redis cũ hơn, phải dùng ZRANGE + ZREM (không atomic) hoặc Lua script. Redis 7.x trở đi có BZPOPMIN/BZPOPMAX là blocking variant — worker block wait thay vì polling liên tục, giảm CPU.

10

Encoding: Listpack vs Skiplist

Redis chọn encoding nội bộ cho ZSet theo kích thước thực tế:

  • listpack: ZSet nhỏ — số member < zset-max-listpack-entries (mặc định 128) tất cả member có độ dài < zset-max-listpack-value bytes (mặc định 64). Listpack là array liên tục trong memory, rất tiết kiệm bộ nhớ nhưng các thao tác là O(N).
  • skiplist: ZSet lớn — khi vượt ngưỡng trên. Skiplist cấp thêm overhead memory nhưng đảm bảo O(log N) cho mọi thao tác.
-- Kiểm tra encoding hiện tại
OBJECT ENCODING myzset
-- "listpack" hoặc "skiplist"

Chuyển đổi từ listpack sang skiplist là một chiều và xảy ra tự động khi vượt ngưỡng. Ngưỡng có thể điều chỉnh trong redis.conf:

zset-max-listpack-entries 128
zset-max-listpack-value 64

Nếu ZSet của bạn luôn nhỏ (ví dụ top-10 daily per user), giữ ngưỡng thấp để tiết kiệm memory. Nếu cần O(log N) nhất quán ngay từ đầu (leaderboard hàng triệu player), tăng ngưỡng cao hoặc thêm member giả lúc khởi tạo để ép skiplist sớm.

Trước Redis 7.0, encoding nhỏ gọi là ziplist; từ Redis 7.0 đổi sang listpack với cấu trúc hiệu quả hơn. Config key cũng đổi từ zset-max-ziplist-* sang zset-max-listpack-*.

11

Big-O Tham Chiếu

Các con số dưới đây áp dụng cho encoding skiplist (ZSet lớn). N = tổng số member; M = số member trả về trong range query.

LệnhĐộ phức tạpGhi chú
ZADDO(log N) × số member thêmMỗi member insert vào skiplist O(log N)
ZINCRBYO(log N)Xóa và re-insert vào vị trí mới
ZSCOREO(1)Hashtable lookup
ZRANK / ZREVRANKO(log N)Skiplist traversal đếm node
ZRANGE / ZREVRANGEO(log N + M)Tìm điểm bắt đầu O(log N), duyệt M phần tử
ZRANGEBYSCOREO(log N + M)Tương tự ZRANGE theo score
ZREMO(log N) × số member xóa
ZCARDO(1)Redis lưu sẵn count
ZCOUNTO(log N)Duyệt skiplist tìm range
ZPOPMIN / ZPOPMAXO(log N) × count
ZREMRANGEBYSCOREO(log N + M)M = số phần tử bị xóa

Hệ quả thực tế: leaderboard 1 triệu player — ZADD mất khoảng 5-10µs, ZREVRANGE top-10 khoảng 15-30µs trên Redis 7.x với hardware phổ thông. Đây là con số benchmark tham khảo; kết quả thực tế phụ thuộc phần cứng, network và kích thước value.

12

Anti-patterns & Best Practices

Anti-patterns

  • ZRANGE 0 -1 trên ZSet lớn: trả về toàn bộ ZSet vào response. Với leaderboard 100k member, đây là một lệnh O(N) chặn event loop Redis trong hàng chục ms. Luôn dùng LIMIT hoặc lấy theo range nhỏ.
  • Leaderboard không TTL, không cleanup: mỗi mùa game hay mỗi ngày tạo leaderboard mới nhưng không xóa cái cũ. ZSet cũ tích lũy trong memory. Với 1 triệu player mỗi mùa và 50 bytes per member, 10 mùa = ~500MB chỉ cho leaderboard cũ. Đây là nguồn gốc của sự cố OOM mà Module 8 sẽ phân tích.
  • Dùng ZSet khi không cần sorted: nếu chỉ cần unique members mà không truy vấn theo score hoặc rank, hãy dùng Set. ZSet tốn thêm overhead của skiplist (mỗi node skiplist có pointer tầng, bloom overhead). Benchmark trên Redis với 1M member: Set tốn ~60MB, ZSet tương đương tốn ~130MB.
  • Polling ZRANGEBYSCORE mỗi 10ms cho delayed queue: mỗi poll là 1 lệnh Redis. 10ms interval = 100 lệnh/s per worker, nhân với N worker là tải đáng kể nếu queue gần rỗng. Kéo interval về 1-5 giây cho hầu hết use case, hoặc dùng blocking variant (BZPOPMIN) nếu cần latency thấp.
  • ZADD không kiểm tra điều kiện trong code khi có GT/LT: đặt GT ở phía Redis thay vì đọc score, so sánh trong application rồi ZADD — không atomic, có thể bị race condition.

Best practices

  • Leaderboard high-score: dùng ZADD GT thay vì đọc-so sánh-ghi từ application.
  • Cập nhật điểm cộng thêm: dùng ZINCRBY (atomic) thay vì đọc + tính + ghi.
  • Leaderboard theo mùa/ngày: dùng key có thời gian trong tên (leaderboard:2026-q2) và đặt TTL hoặc có job cleanup định kỳ.
  • Pagination top-N: luôn chỉ định range cụ thể (offset, count) trong ZRANGE/ZREVRANGE, tránh 0 -1.
  • Delayed queue: tách ZRANGEBYSCORE và ZREM thành Lua script nếu cần atomic; nếu không, chấp nhận at-most-once và thiết kế job idempotent.
  • Đo bằng MEMORY USAGE keyOBJECT ENCODING key trên môi trường staging với dataset sát production trước khi rollout.
13

Tổng Kết & Quiz

Tổng kết

  • Sorted Set = Set + score (float). Members unique; ZSet tự sắp theo score. Cấu trúc nội bộ: skiplist + hashtable.
  • Các lệnh chủ chốt: ZADD (với options GT/NX/XX), ZINCRBY, ZSCORE, ZRANK/ZREVRANK, ZRANGE/ZREVRANGE, ZRANGEBYSCORE, ZPOPMIN/ZPOPMAX.
  • Leaderboard: ZADD GT cho high-score, ZINCRBY cho cộng điểm, ZREVRANGE cho top-N, ZREVRANK cho rank cá nhân.
  • Ranking around me: 2 lệnh (ZREVRANK + ZREVRANGE với window).
  • Delayed queue: score = Unix timestamp; worker poll bằng ZRANGEBYSCORE score ≤ now. Cần Lua cho atomic pop; deep-dive ở Module 5.
  • Sliding window rate limit: score = timestamp, ZREMRANGEBYSCORE xóa cũ, ZCARD đếm. Deep-dive ở Module 3.
  • Priority queue: score = priority, ZPOPMAX lấy priority cao nhất, BZPOPMAX cho blocking variant.
  • Encoding: listpack (ZSet nhỏ, O(N) nhưng tiết kiệm memory) → skiplist (ZSet lớn, O(log N)). Config: zset-max-listpack-entries, zset-max-listpack-value.
  • Anti-pattern quan trọng: ZRANGE toàn bộ, ZSet không TTL/cleanup, dùng ZSet khi không cần sorted, polling interval quá dày.

Quiz 5 câu

  1. Vì sao Sorted Set cần cả skiplist lẫn hashtable ở trong implementation? Mỗi cái phục vụ lệnh nào?
  2. Bạn muốn xây high-score leaderboard: nếu người chơi gửi điểm thấp hơn điểm hiện tại, hãy bỏ qua. Dùng lệnh nào và option gì? Vì sao không đọc score trước rồi so sánh trong code?
  3. Delayed queue dùng ZSet: score là gì? Worker lấy job đến hạn bằng lệnh nào? Vấn đề atomicity là gì và giải pháp là gì?
  4. ZREVRANK trả về 0 cho player có score cao nhất. Bạn muốn hiển thị "Rank #1" cho người dùng. Cần làm gì với giá trị trả về?
  5. Một ZSet leaderboard cho game mobile, mỗi ngày tạo một key mới. Sau 1 năm có bao nhiêu key nếu không cleanup? Rủi ro gì?

Đáp án gợi ý

  1. Skiplist: hỗ trợ các lệnh cần biết thứ tự và range — ZRANK, ZRANGE, ZRANGEBYSCORE (tất cả O(log N) nhờ duyệt skiplist). Hashtable: hỗ trợ ZSCORE O(1) — tra cứu score của một member bất kỳ trực tiếp mà không cần tìm kiếm trên skiplist.
  2. Dùng ZADD leaderboard GT <score> <member>. Không đọc score trước rồi so sánh trong code vì đó không atomic: giữa lần đọc score và lần ghi mới, một request khác có thể đã update score, gây race condition khiến giá trị cũ bị ghi đè lên giá trị mới.
  3. Score = Unix timestamp muốn chạy job. Worker dùng ZRANGEBYSCORE delayed_jobs -inf <now> LIMIT 0 N. Vấn đề: ZRANGEBYSCORE và ZREM không atomic — worker có thể crash sau khi lấy job nhưng trước khi ZREM, dẫn đến job không được xử lý. Giải pháp: Lua script gộp ZRANGEBYSCORE + ZREM thành một lệnh atomic, hoặc dùng pattern với pending list (Bài Module 5).
  4. Cộng thêm 1: display_rank = zrevrank + 1. Vì ZREVRANK đánh index 0-based (0 = cao nhất), người dùng thường kỳ vọng "Rank #1" thay vì "Rank #0".
  5. 365 key (1 key/ngày). Rủi ro: memory tích lũy (với 1 triệu player mỗi ngày × ~130 bytes/member × 365 ngày ≈ 47GB chỉ cho key cũ), và số lượng key lớn làm chậm các lệnh quản lý key (SCAN, DEBUG SLEEP). Nên đặt TTL hoặc có job ZREMRANGEBYSCORE/DEL key cũ định kỳ.

Bài tiếp theo

Bài 25 chuyển sang Bitmap & Bitfield: đếm daily active users và login streak với độ chính xác bit-level, chỉ tốn vài KB thay vì megabyte.

Tham khảo