Thiết kế bảng xếp hạng realtime cho game bằng Redis sorted set

1. Tổng quan

Bảng xếp hạng (leaderboard) xuất hiện trong hầu hết các game online. Bài toán nghe đơn giản: sắp xếp người chơi theo điểm. Khi có hàng triệu người chơi và hàng nghìn trận kết thúc mỗi giây, ORDER BY score DESC trên database quan hệ không còn đáp ứng được.

Bài này đi qua toàn bộ thiết kế: yêu cầu, mô hình dữ liệu trên Redis sorted set, API, luồng xử lý, và các điểm khó như điểm hoà, cập nhật trùng lặp, nhất quán dữ liệu và tải đột biến. Đọc xong, bạn có thể dựng một leaderboard chạy được bằng Spring Boot 3 và Redis, đồng thời biết các giới hạn của nó.

2. Yêu cầu bài toán

2.1. Yêu cầu chức năng

  • Mỗi mùa giải (season) có một bảng xếp hạng riêng. Điểm của người chơi là điểm cao nhất đạt được trong một trận.
  • Xem top N (thường là 100).
  • Xem thứ hạng của chính mình, kể cả khi đứng hạng 3.456.789.
  • Xem “xung quanh tôi”: 5 người trên và 5 người dưới.
  • Khi hai người bằng điểm, ai đạt điểm đó trước thì xếp trên.

2.2. Quy mô và ràng buộc

  • 10 triệu người chơi mỗi mùa.
  • Trung bình 2.000 kết quả trận mỗi giây. Lúc giờ cao điểm hoặc sự kiện có thể lên 20.000/giây.
  • Đọc nhiều hơn ghi rất nhiều: màn hình top 100 bị gọi liên tục.
  • Độ trễ hiển thị chấp nhận được: dưới 1–2 giây sau khi trận kết thúc.
  • Một kết quả trận có thể được gửi lại nhiều lần do retry. Kết quả trùng không được làm sai bảng.

3. Vì sao chọn Redis sorted set

Sorted set (ZSET) lưu các cặp member → score và luôn giữ thứ tự theo score. Bên trong nó dùng skip list kết hợp hash table, nên có các đặc điểm sau:

Thao tác Lệnh Độ phức tạp
Cập nhật điểm ZADD O(log N)
Lấy thứ hạng ZREVRANK O(log N)
Lấy top N / một khoảng ZREVRANGE O(log N + M)
Lấy điểm của một người ZSCORE O(1)

Với N = 10 triệu, log N chỉ khoảng 23 bước. So sánh với SQL: muốn biết hạng của một người, bạn phải đếm số dòng có điểm lớn hơn. Ngay cả khi có index, truy vấn này vẫn tốn kém ở quy mô lớn.

Về bộ nhớ, mỗi member tốn khoảng 80–120 byte, tuỳ độ dài ID. 10 triệu người chơi vào khoảng 1 GB, vẫn nằm gọn trong một node Redis.

4. Kiến trúc tổng thể

Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
                         │                                                  ▲
                         └──► Persist Consumer ──► PostgreSQL (nguồn gốc)   │
                                                                            │
Client ──► Leaderboard API (Spring Boot) ── cache top 100 trong RAM ────────┘

Có ba quyết định chính:

  1. PostgreSQL là nguồn sự thật. Redis chỉ là chỉ mục để đọc nhanh. Nếu mất Redis, bạn dựng lại được từ database.
  2. Kafka đứng giữa để hấp thụ tải đột biến. Game server không bị chậm khi Redis bận.
  3. API đọc tách khỏi luồng ghi. Top 100 được cache ngắn hạn trong RAM của từng instance.

Bảng lưu kết quả trận trong PostgreSQL:

CREATE TABLE match_results (
    match_id     UUID        NOT NULL,
    player_id    BIGINT      NOT NULL,
    season_id    INT         NOT NULL,
    points       INT         NOT NULL CHECK (points >= 0),
    achieved_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (match_id, player_id)
);
CREATE INDEX idx_season_player ON match_results (season_id, player_id);

Khoá chính (match_id, player_id) giúp việc ghi lặp lại trở nên vô hại: chỉ cần INSERT ... ON CONFLICT DO NOTHING.

5. Mô hình dữ liệu trên Redis

5.1. Key và member

lb:{s12}            → ZSET, member = playerId, score = điểm đã mã hoá
lb:{s12}:dedup:<matchId>:<playerId> → STRING, đánh dấu kết quả đã xử lý (TTL 1 ngày)

Phần {s12} là hash tag. Trên Redis Cluster, mọi key có cùng hash tag sẽ nằm cùng một slot. Điều này bắt buộc nếu bạn muốn dùng chúng chung trong một Lua script.

5.2. Xử lý điểm hoà bằng điểm tổng hợp

Redis sắp các member cùng score theo thứ tự từ điển của member. Kiểu sắp này không có ý nghĩa với game. Ta cần “ai đạt trước xếp trên”, nên phải nhét thời gian vào score.

Score của Redis là double, biểu diễn chính xác mọi số nguyên đến 2^53. Ta chia 53 bit như sau:

  • 21 bit cao: điểm (tối đa khoảng 2 triệu).
  • 32 bit thấp: MAX_TIME - số giây kể từ đầu mùa. Ai đạt sớm hơn sẽ có giá trị lớn hơn, nên đứng trên khi sắp giảm dần.
public final class ScoreCodec {

    private static final int TIME_BITS = 32;
    private static final long MAX_TIME = (1L << TIME_BITS) - 1;
    public static final long MAX_POINTS = (1L << 21) - 1;

    private ScoreCodec() {}

    public static long encode(long points, long secondsSinceSeasonStart) {
        if (points < 0 || points > MAX_POINTS) {
            throw new IllegalArgumentException("points out of range: " + points);
        }
        if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
            throw new IllegalArgumentException("time out of range");
        }
        return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
    }

    public static long points(double encoded) {
        return ((long) encoded) >>> TIME_BITS;
    }
}

Hàm encode trả về long. Bạn sẽ gửi giá trị này lên Redis dưới dạng chuỗi số nguyên để tránh mọi sai lệch khi định dạng double. Khi đọc về, points() dịch phải 32 bit để lấy lại điểm gốc. Phép chuyển (long) encoded là chính xác vì giá trị không vượt quá 2^53.

Lưu ý: Nếu game của bạn có điểm lớn hơn 2 triệu, hãy giảm độ phân giải thời gian, ví dụ tính theo phút thay vì giây, để có thêm bit cho điểm. Đừng bao giờ vượt 53 bit. Vượt quá thì double làm tròn, và hai người khác điểm có thể nhận cùng score.

Test cho phần mã hoá, vì đây là chỗ dễ sai nhất:

class ScoreCodecTest {

    @Test
    void higherPointsAlwaysRankAbove() {
        long late = ScoreCodec.encode(1001, 3_000_000);
        long early = ScoreCodec.encode(1000, 0);
        assertThat(late).isGreaterThan(early);
    }

    @Test
    void samePointsEarlierAchieverRanksAbove() {
        long alice = ScoreCodec.encode(500, 100);
        long bob = ScoreCodec.encode(500, 200);
        assertThat(alice).isGreaterThan(bob);
    }

    @Test
    void decodeSurvivesDoubleRoundTrip() {
        long encoded = ScoreCodec.encode(ScoreCodec.MAX_POINTS, 12_345);
        double fromRedis = (double) encoded;
        assertThat(ScoreCodec.points(fromRedis)).isEqualTo(ScoreCodec.MAX_POINTS);
    }
}

Test thứ ba mô phỏng việc Redis trả score về dưới dạng double. Test này kiểm tra rằng ở giá trị biên lớn nhất, ta vẫn giải mã đúng.

6. Cập nhật điểm: nguyên tử và idempotent

Dependency cần có:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

Cập nhật điểm có hai yêu cầu phải thoả cùng lúc:

  1. Chỉ ghi khi điểm mới cao hơn điểm hiện tại.
  2. Bỏ qua kết quả trận đã xử lý.

Nếu làm bằng hai lệnh riêng như ZSCORE rồi ZADD, hai consumer có thể chen vào giữa nhau. Khi đó điểm thấp hơn có thể ghi đè điểm cao hơn. Ta gộp tất cả vào một Lua script. Redis chạy script đơn luồng và nguyên tử.

@Service
public class LeaderboardWriter {

    private static final RedisScript<Long> SUBMIT = RedisScript.of("""
            if not redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3]) then
              return -1
            end
            local cur = redis.call('ZSCORE', KEYS[1], ARGV[2])
            if cur and tonumber(cur) >= tonumber(ARGV[1]) then
              return 0
            end
            redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

    public LeaderboardWriter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public long submit(MatchResult r) {
        String board = "lb:{s" + r.seasonId() + "}";
        String dedup = board + ":dedup:" + r.matchId() + ":" + r.playerId();
        long score = ScoreCodec.encode(r.points(), r.secondsSinceSeasonStart());
        return redis.execute(SUBMIT, List.of(board, dedup),
                Long.toString(score), Long.toString(r.playerId()), "86400");
    }
}

public record MatchResult(UUID matchId, long playerId, int seasonId,
                          int points, long secondsSinceSeasonStart) {}

Script trả về ba giá trị:

  • -1: trùng lặp. SET NX thất bại vì kết quả trận này đã được xử lý.
  • 0: không phải kỷ lục mới, bảng không đổi.
  • 1: đã cập nhật kỷ lục.

Bạn có thể dùng mã trả về này để đếm metric hoặc gửi thông báo “Bạn vừa lên hạng!”.

Lưu ý: Redis 6.2+ có ZADD key GT score member để chỉ ghi khi điểm lớn hơn. Nếu không cần dedup, bạn có thể dùng lệnh đó thay cho script. Ở đây vẫn cần script vì phải gộp bước kiểm tra trùng vào cùng một thao tác nguyên tử.

Nếu luật game là cộng dồn điểm (ZINCRBY) thay vì lấy điểm cao nhất, idempotency trở nên bắt buộc. Lý do: cộng hai lần là sai ngay. Khi đó, phần thời gian trong điểm tổng hợp cũng phải cập nhật lại mỗi lần cộng, nên script sẽ phức tạp hơn.

7. API đọc

@RestController
@RequestMapping("/seasons/{seasonId}/leaderboard")
public class LeaderboardController {

    private final StringRedisTemplate redis;

    public LeaderboardController(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public record Entry(long rank, long playerId, long points) {}

    @GetMapping("/players/{playerId}/around")
    public List<Entry> around(@PathVariable int seasonId, @PathVariable long playerId,
                              @RequestParam(defaultValue = "5") int radius) {
        String key = "lb:{s" + seasonId + "}";
        Long rank = redis.opsForZSet().reverseRank(key, Long.toString(playerId));
        if (rank == null) {
            return List.of();
        }
        long start = Math.max(0, rank - radius);
        return range(key, start, rank + radius);
    }

    List<Entry> range(String key, long start, long end) {
        var tuples = redis.opsForZSet().reverseRangeWithScores(key, start, end);
        List<Entry> result = new ArrayList<>();
        long rank = start + 1;
        for (var t : tuples) {
            result.add(new Entry(rank++, Long.parseLong(t.getValue()),
                    ScoreCodec.points(t.getScore())));
        }
        return result;
    }
}

reverseRank trả về hạng tính từ 0 theo thứ tự giảm dần, nên hạng hiển thị bằng rank + 1. Nếu người chơi chưa có điểm trong mùa, hàm trả về null và API trả danh sách rỗng. Gọi /seasons/12/leaderboard/players/42/around sẽ cho kết quả như sau:

[{"rank":3456784,"playerId":901,"points":1520},
 ...
 {"rank":3456789,"playerId":42,"points":1518},
 ...
 {"rank":3456794,"playerId":77,"points":1515}]

Hai lệnh reverseRank và reverseRangeWithScores không nguyên tử với nhau. Giữa hai lệnh, bảng có thể thay đổi một chút. Với màn hình xếp hạng, sai lệch vài trăm mili-giây là chấp nhận được. Nếu bạn cần chính xác tuyệt đối, hãy gộp chúng vào một Lua script.

8. Các điểm khó và cách giải

8.1. Hot key ở top 100

Mọi client đều gọi top 100, và tất cả đều đổ vào một key trên một node Redis. Thêm replica giúp được một phần. Tuy vậy, cách hiệu quả nhất là cache trong RAM của mỗi instance API và làm mới mỗi giây:

@Component
public class TopCache {

    private final LeaderboardController reader;
    private volatile List<LeaderboardController.Entry> top100 = List.of();

    public TopCache(LeaderboardController reader) {
        this.reader = reader;
    }

    @Scheduled(fixedRate = 1000)
    void refresh() {
        top100 = reader.range("lb:{s12}", 0, 99);
    }

    public List<LeaderboardController.Entry> get() {
        return top100;
    }
}

Nhớ bật @EnableScheduling. Với 50 instance API, Redis chỉ nhận 50 request top 100 mỗi giây, bất kể có bao nhiêu triệu client. Trong ví dụ, season đang được hardcode cho gọn. Thực tế bạn nên lấy season hiện tại từ cấu hình.

8.2. Tải ghi đột biến

Khi một sự kiện kết thúc, hàng chục nghìn trận có thể đóng cùng lúc. Kafka đóng vai trò bộ đệm. Consumer xử lý theo batch và gửi các lệnh qua pipeline để giảm số round-trip mạng:

@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
    results.forEach(writer::submit);
}

Nếu cần nhanh hơn nữa, bạn có thể gom các execute vào redis.executePipelined(...). Ta cũng partition topic theo playerId. Nhờ vậy, mọi kết quả của một người chơi được xử lý tuần tự trên cùng một partition, giảm tranh chấp. Lua script vẫn giữ đúng đắn kể cả khi có tranh chấp.

Khi Redis chậm, consumer lag tăng lên, nhưng game server vẫn không bị ảnh hưởng. Người chơi chỉ thấy bảng cập nhật trễ vài giây.

8.3. Nhất quán giữa Redis và database

Redis có thể mất dữ liệu nếu node chết trước khi kịp fsync. Vì PostgreSQL là nguồn sự thật, bạn cần một job dựng lại bảng:

SELECT player_id,
       MAX(points) AS best,
       MIN(achieved_at) FILTER (WHERE points = best_points) AS first_at
FROM (
    SELECT player_id, points, achieved_at,
           MAX(points) OVER (PARTITION BY player_id) AS best_points
    FROM match_results
    WHERE season_id = 12
) t
GROUP BY player_id;

Job ghi kết quả vào một key tạm như lb:{s12}:rebuild, sau đó dùng RENAME để thay thế key thật một cách nguyên tử. Người dùng không bao giờ thấy bảng trống giữa chừng.

Job này cũng dùng định kỳ để đối soát. Nếu số member trong Redis lệch so với database, bạn sẽ phát hiện được.

8.4. Bảng theo ngày, tuần và dọn dẹp

Bảng ngày dùng key lb:{s12}:d:2026-10-12 kèm EXPIRE 3 ngày. Bảng tuần có thể tổng hợp từ các bảng ngày bằng ZUNIONSTORE ... AGGREGATE MAX. Kết thúc mùa, bạn chụp lại top 1000 vào database để trao thưởng, sau đó xoá key lớn bằng UNLINK thay vì DEL để tránh chặn Redis.

8.5. Bảng xếp hạng bạn bè

Với danh sách khoảng 200 bạn, dùng ZMSCORE lb:{s12} id1 id2 ... để lấy điểm của tất cả trong một lệnh, rồi sắp xếp trong Java. Không cần tạo ZSET riêng cho từng người.

9. Khi nào KHÔNG nên dùng thiết kế này

  • Quy mô vượt một node. Một ZSET không thể chia trên nhiều node Cluster. Nếu có hàng trăm triệu người chơi trong cùng một bảng, bạn phải chia theo khoảng điểm hoặc theo khu vực, và chấp nhận hạng toàn cục chỉ là xấp xỉ.
  • Tiêu chí xếp hạng nhiều chiều phức tạp. Ví dụ điểm, sau đó tỉ lệ thắng, sau đó số trận. Khi đó 53 bit không đủ để mã hoá tất cả.
  • Cần hạng chính xác cho mục đích tài chính. Trao thưởng bằng tiền thật nên tính lại từ database tại thời điểm chốt. Đừng lấy trực tiếp từ Redis.
  • Ít người chơi. Vài chục nghìn dòng thì PostgreSQL với index (season_id, points DESC) là đủ, và bạn bớt một hệ thống phải vận hành.

10. Kết luận

Redis sorted set cho phép cập nhật, lấy hạng và lấy top N với chi phí O(log N). Đó là nền tảng tự nhiên cho leaderboard realtime.

Phần khó nằm ở các chi tiết xung quanh:

  • Mã hoá điểm hoà vào score mà không vượt 53 bit.
  • Dùng Lua script để vừa chỉ ghi kỷ lục vừa idempotent.
  • Đặt Kafka phía trước để hấp thụ tải đột biến.
  • Cache top 100 trong RAM để tránh hot key.
  • Giữ PostgreSQL làm nguồn sự thật để có thể dựng lại Redis bất cứ lúc nào.

Khi áp dụng, bạn nên viết test cho ScoreCodec trước tiên. Một lỗi tràn bit ở đây sẽ làm sai lệch toàn bộ bảng mà không có thông báo lỗi nào.

redissystem-designspring-bootleaderboardkafka