Projetando um leaderboard realtime para jogos com Redis sorted set

1. Visão geral

Tabelas de classificação (leaderboard) aparecem na maioria dos games online. O problema parece simples: classificar jogadores por pontos. Quando há milhões de jogadores e milhares de partidas terminando a cada segundo, ORDER BY score DESC em um banco de dados relacional não consegue acompanhar mais.

Este artigo passa por todo o design: requisitos, modelo de dados em Redis sorted set, APIs, fluxo de processamento e desafios como empates, atualizações duplicadas, consistência de dados e picos de carga. Após ler, você será capaz de construir um leaderboard funcional usando Spring Boot 3 e Redis, além de conhecer seus limites.

2. Requisitos do problema

2.1. Requisitos funcionais

  • Cada temporada tem sua própria tabela de classificação. A pontuação de um jogador é o maior ponto alcançado em uma partida.
  • Ver top N (geralmente 100).
  • Ver sua própria classificação, mesmo se estiver no 3º lugar 456 mil 789.
  • Ver “ao meu redor”: 5 pessoas acima e 5 abaixo.
  • Quando dois jogadores têm o mesmo ponto, quem alcançou esse ponto primeiro fica em primeiro lugar.

2.2. Escala e restrições

  • 10 milhões de jogadores por temporada.
  • Média de 2.000 resultados de partida por segundo. Nos horários de pico ou eventos, pode chegar a 20.000/segundo.
  • Leitura muito mais frequente que escrita:
  • a tela dos top 100 é chamada continuamente.
  • Latência de exibição aceitável: menos de 1-2 segundos após o término da partida.
  • Um resultado de partida pode ser reenviado várias vezes devido a tentativas de retry. Resultados duplicados não devem distorcer a tabela.

3. Por que escolher Redis sorted set

Sorted set (ZSET) armazena pares member → score e mantém a ordem por score. Internamente, usa skip list combinado com hash table, portanto, tem as seguintes características:

Operação Comando Complexidade
Atualizar pontuação ZADD O(log N)
Obter classificação ZREVRANK O(log N)
Obter top N / um intervalo ZREVRANGE O(log N + M)
Obter pontuação de uma pessoa ZSCORE O(1)

Com N = 10 milhões, log N é apenas cerca de 23 passos. Comparado com SQL: para saber a classificação de uma pessoa, você precisa contar as linhas com pontuação maior. Mesmo com índice, essa consulta ainda é cara em grande escala.

Em termos de memória, cada member custa cerca de 80-120 bytes, dependendo do comprimento do ID. 10 milhões de jogadores ocupam cerca de 1 GB, ainda cabendo perfeitamente em um node Redis.

4. Arquitetura geral

Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
                         │                                                  ▲
                         └──► Persist Consumer ──► PostgreSQL (source)      │
                                                                            │
Client ──► Leaderboard API (Spring Boot) ── cache top 100 in RAM ──────────┘

Existem três decisões principais:

  1. PostgreSQL é a fonte da verdade. Redis é apenas um índice para leitura rápida. Se perder Redis, pode reconstruir a partir do banco de dados.
  2. Kafka fica no meio para absorver picos de carga. O servidor de jogo não fica lento quando Redis está ocupado.
  3. API de leitura é separada do fluxo de escrita. Top 100 é armazenado em cache de curto prazo na RAM de cada instância.

Tabela para armazenar resultados de partida em 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);

A chave primária (match_id, player_id) torna a escrita repetida inofensiva: basta INSERT ... ON CONFLICT DO NOTHING.

5. Modelo de dados no Redis

5.1. Chaves e membros

lb:{s12}            → ZSET, member = playerId, score = pontuação codificada
lb:{s12}:dedup:<matchId>:<playerId> → STRING, marca resultado já processado (TTL 1 dia)

A parte {s12} é uma hash tag. No Redis Cluster, todas as chaves com a mesma hash tag ficam no mesmo slot. Isso é obrigatório se você quiser usá-las juntas em um script Lua.

5.2. Tratando empates através de pontuação composta

Redis classifica membros com o mesmo score em ordem léxica do membro. Esse tipo de ordenação não faz sentido para o jogo. Precisamos de “quem alcançou primeiro fica em primeiro lugar”, então precisamos incorporar o tempo na pontuação.

A pontuação do Redis é um double, representando com precisão todo inteiro até 2^53. Dividimos 53 bits assim:

  • 21 bits altos: pontuação (máximo cerca de 2 milhões).
  • 32 bits baixos: MAX_TIME - número de segundos desde o início da temporada. Quem alcançou mais cedo terá um valor maior, então ficará em cima quando classificado em ordem decrescente.
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;
    }
}

A função encode retorna long. Você enviará esse valor para Redis como string de número inteiro para evitar qualquer desvio ao formatar double. Ao ler de volta, points() faz shift right 32 bits para recuperar a pontuação original. A conversão (long) encoded é exata porque o valor não ultrapassa 2^53.

Nota: Se seu jogo tiver pontuação maior que 2 milhões, reduza a resolução do tempo, por exemplo, calcule por minuto em vez de segundo, para ter mais bits para pontuação. Nunca ultrapasse 53 bits. Se ultrapassar, double fará arredondamento, e duas pessoas com pontuações diferentes podem receber o mesmo score.

Teste para a parte de codificação, pois este é o lugar mais propenso a erros:

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);
    }
}

O terceiro teste simula Redis retornando a pontuação como double. Este teste verifica que no valor limite máximo, ainda decodificamos corretamente.

6. Atualizar pontuação: atômico e idempotente

Dependências necessárias:

<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>

Atualizar a pontuação tem dois requisitos que devem ser atendidos simultaneamente:

  1. Escrever apenas quando a pontuação nova é maior que a atual.
  2. Ignorar resultados de partida já processados.

Se fazer com dois comandos separados como ZSCORE depois ZADD, dois consumers podem se intercalar. Quando isso acontece, pontuação mais baixa pode sobrescrever pontuação mais alta. Agrupamos tudo em um script Lua. Redis executa scripts em thread único e atomicamente.

@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) {}

O script retorna três valores:

  • -1: duplicado. SET NX falhou porque este resultado de partida já foi processado.
  • 0: não é um recorde novo, a tabela não muda.
  • 1: recorde atualizado.

Você pode usar este código de retorno para contar métricas ou enviar uma notificação “Você acabou de subir no ranking!”.

Nota: Redis 6.2+ tem ZADD key GT score member para escrever apenas quando a pontuação é maior. Se não precisar de dedup, você pode usar esse comando em vez do script. Aqui ainda precisamos do script porque devemos agrupar a verificação de duplicação em uma única operação atômica.

Se a regra do jogo for acumular pontos (ZINCRBY) em vez de pegar o ponto mais alto, idempotência se torna obrigatória. Razão: somar duas vezes é errado imediatamente. Nesse caso, a parte de tempo na pontuação composta também deve ser atualizada a cada soma, então o script será mais complexo.

7. API de leitura

@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 retorna o rank começando do 0 em ordem decrescente, então o rank exibido é rank + 1. Se o jogador ainda não tem pontos na temporada, a função retorna null e a API retorna uma lista vazia. Chamar /seasons/12/leaderboard/players/42/around dará o seguinte resultado:

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

Os dois comandos reverseRank e reverseRangeWithScores não são atômicos um com o outro. Entre os dois comandos, a tabela pode mudar um pouco. Para uma tela de leaderboard, um desvio de algumas centenas de milissegundos é aceitável. Se você precisa de precisão absoluta, agrupe-os em um script Lua.

8. Pontos difíceis e soluções

8.1. Hot key em top 100

Todo cliente chama top 100, e tudo flui para uma chave em um node Redis. Adicionar réplicas ajuda um pouco. No entanto, a maneira mais eficiente é armazenar em cache na RAM de cada instância da API e atualizar a cada segundo:

@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;
    }
}

Lembre-se de ativar @EnableScheduling. Com 50 instâncias de API, Redis recebe apenas 50 requisições de top 100 por segundo, não importa quantos milhões de clientes. No exemplo, a temporada é codificada hardcode para simplificar. Na prática, você deve obter a temporada atual da configuração.

8.2. Picos de carga de escrita

Quando um evento termina, dezenas de milhares de partidas podem ser fechadas simultaneamente. Kafka funciona como um buffer. O consumer processa em lote e envia comandos via pipeline para reduzir o número de round-trips de rede:

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

Se precisar ser mais rápido, você pode agrupar os execute em redis.executePipelined(...). Também particionamos o topic por playerId. Assim, todos os resultados de um jogador são processados sequencialmente na mesma partição, reduzindo contenção. O script Lua ainda permanece correto mesmo com contenção.

Quando Redis fica lento, o atraso do consumer aumenta, mas o servidor de jogo não é afetado. Os jogadores apenas veem a tabela atualizada alguns segundos depois.

8.3. Consistência entre Redis e banco de dados

Redis pode perder dados se um node morrer antes de sincronizar. Como PostgreSQL é a fonte da verdade, você precisa de um job para reconstruir a tabela:

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;

O job escreve resultados em uma chave temporária como lb:{s12}:rebuild, depois usa RENAME para substituir a chave real atomicamente. Os usuários nunca veem a tabela vazia no meio. Esse job também é executado periodicamente para reconciliação. Se o número de membros em Redis divergir do banco de dados, você detectará.

8.4. Leaderboards por dia, semana e limpeza

O leaderboard do dia usa a chave lb:{s12}:d:2026-10-12 com EXPIRE de 3 dias. O leaderboard da semana pode ser agregado a partir dos leaderboards do dia usando ZUNIONSTORE ... AGGREGATE MAX. No final da temporada, você tira um snapshot dos top 1000 no banco de dados para distribuir recompensas, depois exclui a chave grande usando UNLINK em vez de DEL para evitar bloquear Redis.

8.5. Leaderboard de amigos

Com uma lista de cerca de 200 amigos, use ZMSCORE lb:{s12} id1 id2 ... para obter as pontuações de todos em um comando, depois classifique em Java. Não é necessário criar ZSET separado para cada pessoa.

9. Quando NÃO usar este design

  • Escala que ultrapassa um node. Um ZSET não pode ser dividido em múltiplos nodes Cluster. Se você tiver centenas de milhões de jogadores na mesma tabela, deve dividir por faixa de pontos ou por região, e aceitar que o rank global é apenas uma aproximação.
  • Critérios de classificação multidimensionais complexos. Por exemplo, pontos, depois taxa de vitória, depois número de partidas. Nesse caso, 53 bits não são suficientes para codificar tudo.
  • Precisa de rank exato para fins financeiros. Prêmios em dinheiro real devem ser recalculados do banco de dados no momento do fechamento. Não pegue diretamente de Redis.
  • Poucos jogadores. Algumas dezenas de milhares de linhas, PostgreSQL com índice (season_id, points DESC) é suficiente, e você economiza um sistema para manter.

10. Conclusão

Redis sorted set permite atualizar, obter rank e obter top N com custo O(log N). É a base natural para um leaderboard realtime.

A parte difícil está nos detalhes em torno:

  • Codificar empates na pontuação sem ultrapassar 53 bits.
  • Usar script Lua para ser tanto escrita de recorde quanto idempotente.
  • Colocar Kafka na frente para absorver picos de carga.
  • Armazenar em cache top 100 na RAM para evitar hot key.
  • Manter PostgreSQL como fonte da verdade para poder reconstruir Redis a qualquer momento.

Ao implementar, você deve escrever testes para ScoreCodec primeiro. Um erro de overflow de bits aqui distorcerá toda a tabela sem nenhuma mensagem de erro.

redissystem-designspring-bootleaderboardkafka