Redis sorted set: leaderboard, fila de prioridade e sliding window

1. Visão geral

Sorted set (ZSET) é a estrutura de dados mais flexível do Redis. Cada elemento (member) é associado a um score (pontuação), e Redis mantém todos ordenados por score. Este artigo usa ZSET para resolver três problemas comuns: leaderboard, fila de prioridade/agendada e rate limiter com sliding window. Tudo escrito em Java 21 com Spring Boot 3.x. Depois de ler, você saberá como escolher o score para cada problema e evitará as race conditions mais comuns.

2. Como sorted set funciona

Um ZSET tem as seguintes propriedades:

  • Member é único. ZADD de um member já existente apenas atualiza seu score.
  • Score é um número real double. Members com mesmo score são ordenados lexicograficamente pelo nome do member.
  • Adicionar, remover, atualizar e obter rank têm complexidade O(log N). Obter um intervalo de elementos custa O(log N + M), onde M é o número de elementos retornados.

Você pode testar rapidamente com redis-cli:

ZADD scores 120 alice 95 bob 150 carol
ZREVRANGE scores 0 1 WITHSCORES
ZREVRANK scores bob
1) "carol"
2) "150"
3) "alice"
4) "120"
(integer) 2

ZREVRANGE retorna elementos em ordem decrescente de score. ZREVRANK mostra a posição de bob (contando a partir de 0) nessa ordem.

2.1. Configurando o projeto

Adicione a dependência Spring Data Redis. As duas dependências do Testcontainers são usadas para os testes na seção 6:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Configure a conexão:

spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot cria automaticamente o bean StringRedisTemplate. Todas as operações ZSET passam por opsForZSet().

3. Leaderboard

Problema: um jogo adiciona pontos aos jogadores, você precisa obter o top N e a classificação de qualquer jogador. Aqui o score é a pontuação e o member é o ID do jogador.

public record PlayerScore(String player, double score) {}

@Service
public class LeaderboardService {

    private static final String KEY = "leaderboard:game:2048";
    private final ZSetOperations<String, String> zSet;

    public LeaderboardService(StringRedisTemplate redis) {
        this.zSet = redis.opsForZSet();
    }

    public double addPoints(String player, double points) {
        return zSet.incrementScore(KEY, player, points);
    }

    public List<PlayerScore> top(int n) {
        Set<ZSetOperations.TypedTuple<String>> tuples =
                zSet.reverseRangeWithScores(KEY, 0, n - 1);
        if (tuples == null) {
            return List.of();
        }
        return tuples.stream()
                .map(t -> new PlayerScore(t.getValue(), t.getScore()))
                .toList();
    }

    public Optional<Long> rankOf(String player) {
        Long rank = zSet.reverseRank(KEY, player);
        return Optional.ofNullable(rank).map(r -> r + 1);
    }
}

Explicação de cada parte:

  • incrementScore corresponde ao comando ZINCRBY. Este comando é atômico. Se o member não existir, Redis considera o score inicial como 0. Você não precisa ler o score anterior, somar e reescrever, então não há race condition quando múltiplas requisições somam pontos ao mesmo jogador.
  • reverseRangeWithScores(KEY, 0, n - 1) corresponde a ZREVRANGE ... WITHSCORES. O índice final é inclusivo, portanto o top 10 é 0..9.
  • reverseRank retorna null se o jogador não está no leaderboard. Adicionamos 1 para que a classificação comece em 1, o que é mais amigável ao usuário.

Tente assim:

leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("carol", 150);
leaderboard.addPoints("bob", 60);

System.out.println(leaderboard.top(3));
System.out.println(leaderboard.rankOf("alice"));
[PlayerScore[player=bob, score=155.0], PlayerScore[player=carol, score=150.0], PlayerScore[player=alice, score=120.0]]
Optional[3]

3.1. Tratando empates

Quando dois jogadores têm a mesma pontuação, Redis ordena lexicograficamente pelo member. Com ZREVRANGE, a ordem lexicográfica é invertida, então zoe vem antes de anna. Este resultado raramente corresponde à intenção do negócio. Se você quiser que quem atingir a pontuação primeiro seja classificado acima, pode codificar o tempo na parte decimal do score:

public double addPointsWithTieBreak(String player, long points) {
    long now = System.currentTimeMillis();
    double tieBreak = 1.0 - (now / 1e13);
    double score = points + tieBreak;
    zSet.add(KEY, player, score);
    return score;
}

Quem atingiu a pontuação mais cedo tem now menor, portanto tieBreak maior e é classificado acima. Ao exibir, você tira Math.floor(score) para obter a pontuação real.

Aviso: score é double, representando com precisão números inteiros até 2^53 (cerca de 9×10^15). Quando você combina múltiplos valores em um score, precisa calcular a magnitude com antecedência para não perder precisão. Com a abordagem acima, points deve ser inferior a alguns milhões. Além disso, este método não funciona com incrementScore, pois cada soma acumularia também a parte decimal.

4. Fila de prioridade e fila agendada

4.1. Priority queue

Desta vez o score é a prioridade: números menores têm maior prioridade. Para que jobs com a mesma prioridade ainda saiam em ordem FIFO, combinamos um timestamp com o score:

@Service
public class JobQueue {

    private static final String KEY = "queue:email-jobs";
    private final ZSetOperations<String, String> zSet;

    public JobQueue(StringRedisTemplate redis) {
        this.zSet = redis.opsForZSet();
    }

    public void enqueue(String jobId, int priority) {
        if (priority < 0 || priority > 9) {
            throw new IllegalArgumentException("a prioridade deve ser 0..9");
        }
        double score = priority * 1e13 + System.currentTimeMillis();
        zSet.add(KEY, jobId, score);
    }

    public Optional<String> poll() {
        var tuple = zSet.popMin(KEY);
        return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
    }

    public Optional<String> take(Duration timeout) {
        var tuple = zSet.popMin(KEY, timeout.toSeconds(), TimeUnit.SECONDS);
        return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
    }
}

Os pontos principais:

  • priority * 1e13 + millis resulta em um valor máximo de cerca de 9.2×10^13, ainda bem abaixo do limite 2^53. Um job de prioridade 0 sempre sai antes de um de prioridade 1, mesmo que seja adicionado depois.
  • popMin corresponde a ZPOPMIN. Este comando pega e remove um elemento em uma única operação atômica. Dois workers nunca receberão o mesmo job.
  • popMin(key, timeout, unit) corresponde a BZPOPMIN. Este comando bloqueia a conexão até que haja um job ou o timeout expire, evitando o padrão de polling contínuo que desperdiça CPU.

Aviso: BZPOPMIN mantém uma conexão durante todo o tempo de espera. Com Lettuce usando uma conexão compartilhada por padrão, comandos bloqueantes bloquearão todos os outros comandos. Você deve ativar um pool de conexões (adicionar commons-pool2 e configurar spring.data.redis.lettuce.pool.*) ou usar uma connection factory separada para workers.

4.2. Delayed queue

Quer enviar um email de lembrete de pagamento após 30 minutos? Use o score como o momento em que precisa rodar (epoch millis). Os workers buscam periodicamente jobs com score menor ou igual ao momento atual.

A implementação abaixo está errada: chamar rangeByScore e depois remove. Dois workers podem ler o mesmo job antes de conseguir removê-lo. Você deve combinar os dois passos em um script Lua para que Redis os execute atomicamente:

@Service
public class DelayedJobQueue {

    private static final String KEY = "queue:delayed";
    private static final RedisScript<String> POP_DUE = new DefaultRedisScript<>("""
            local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
            if #jobs == 0 then return false end
            redis.call('ZREM', KEYS[1], jobs[1])
            return jobs[1]
            """, String.class);

    private final StringRedisTemplate redis;

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

    public void schedule(String jobId, Instant runAt) {
        redis.opsForZSet().add(KEY, jobId, runAt.toEpochMilli());
    }

    public Optional<String> pollDue(Instant now) {
        String job = redis.execute(POP_DUE, List.of(KEY), String.valueOf(now.toEpochMilli()));
        return Optional.ofNullable(job);
    }
}

O script pega no máximo um job que chegou ao prazo e o remove. Em Lua, return false é convertido para nil, então Java recebe null. O worker pode ser um @Scheduled(fixedDelay = 500) que chama pollDue em um loop até ficar vazio.

Aviso: Depois de pop, o job saiu do Redis. Se o worker morrer no meio do caminho, o job é perdido. Quando você precisa garantir at-least-once, mude o job para um ZSET “processing” com score como deadline, depois remova quando terminar. Se o requisito for ainda mais complexo, considere Redis Streams ou um message broker real.

5. Rate limiter com sliding window

Fixed window (contar requisições por minuto) tem uma fraqueza nas bordas. Por exemplo: 100 requisições às 00:59 e 100 requisições às 01:00 passam ambas, totalizando 200 requisições em 2 segundos. Sliding window evita isso mantendo o timestamp de cada requisição em um ZSET:

  1. Remove requisições mais antigas que now - window (ZREMRANGEBYSCORE).
  2. Conta as requisições restantes (ZCARD).
  3. Se ainda não excedeu o limite, adiciona a requisição atual com score now.

Os três passos devem rodar atomicamente, então usamos Lua novamente:

@Service
public class SlidingWindowRateLimiter {

    private static final RedisScript<Long> SCRIPT = new DefaultRedisScript<>("""
            local key    = KEYS[1]
            local now    = tonumber(ARGV[1])
            local window = tonumber(ARGV[2])
            local limit  = tonumber(ARGV[3])
            redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
            if redis.call('ZCARD', key) < limit then
                redis.call('ZADD', key, now, ARGV[4])
                redis.call('PEXPIRE', key, window)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;
    private final Clock clock;

    public SlidingWindowRateLimiter(StringRedisTemplate redis, Clock clock) {
        this.redis = redis;
        this.clock = clock;
    }

    public boolean tryAcquire(String customerId, int limit, Duration window) {
        Long allowed = redis.execute(SCRIPT,
                List.of("rate:" + customerId),
                String.valueOf(clock.millis()),
                String.valueOf(window.toMillis()),
                String.valueOf(limit),
                UUID.randomUUID().toString());
        return Long.valueOf(1L).equals(allowed);
    }
}

Explicação:

  • Member é um UUID aleatório. Se você usar timestamp como member, duas requisições no mesmo milissegundo sobrescreveriam uma à outra e seriam contadas como uma.
  • PEXPIRE ajuda Redis a limpar chaves de clientes inativos.
  • Você precisa declarar um bean Clock (por exemplo Clock.systemUTC()). Injetar Clock torna os testes mais fáceis. Porém, se você tiver múltiplas instâncias da aplicação, seus relógios devem estar sincronizados, ou você obtém a hora dentro do script usando redis.call('TIME').

Aviso: Cada requisição consome um elemento no ZSET. Com limites altos, por exemplo 100.000 requisições/hora por cliente, a memória cresce rapidamente. Nesse caso, você deve usar sliding window counter (estimar a partir de duas janelas fixas) ou token bucket.

Em cluster mode, o script só pode tocar keys no mesmo hash slot. Os scripts neste artigo usam apenas uma key por vez, então não enfrentam esse problema.

6. Testando com Testcontainers

Os testes abaixo rodam com Redis real em Docker. @ServiceConnection configura automaticamente host e porta para Spring.

@SpringBootTest
@Testcontainers
class SortedSetFeaturesTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired LeaderboardService leaderboard;
    @Autowired JobQueue jobQueue;
    @Autowired SlidingWindowRateLimiter limiter;
    @Autowired StringRedisTemplate template;

    @BeforeEach
    void flush() {
        template.getConnectionFactory().getConnection().serverCommands().flushAll();
    }

    @Test
    void leaderboardOrdersByScoreDescending() {
        leaderboard.addPoints("alice", 120);
        leaderboard.addPoints("bob", 95);
        leaderboard.addPoints("bob", 60);

        assertThat(leaderboard.top(2))
                .extracting(PlayerScore::player)
                .containsExactly("bob", "alice");
        assertThat(leaderboard.rankOf("alice")).contains(2L);
        assertThat(leaderboard.rankOf("nobody")).isEmpty();
    }

    @Test
    void higherPriorityJobComesFirst() {
        jobQueue.enqueue("newsletter", 5);
        jobQueue.enqueue("password-reset", 0);

        assertThat(jobQueue.poll()).contains("password-reset");
        assertThat(jobQueue.poll()).contains("newsletter");
        assertThat(jobQueue.poll()).isEmpty();
    }

    @Test
    void rateLimiterRejectsAfterLimit() {
        Duration window = Duration.ofSeconds(10);

        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isFalse();
        assertThat(limiter.tryAcquire("c-99", 3, window)).isTrue();
    }
}

O último teste verifica duas coisas: a quarta requisição de c-42 é rejeitada, e o limite é calculado separadamente para cada cliente. Para testar a janela “deslizando”, você injeta um Clock ajustável (por exemplo uma classe MutableClock que você escreve), avança o tempo em 10 segundos, e chama tryAcquire novamente.

7. Quando não usar sorted set

  • Precisa de durabilidade como message queue: ZSET não tem acknowledgement, consumer groups ou retry. Redis Streams, RabbitMQ ou Kafka são mais apropriados.
  • Um ZSET muito grande: dezenas de milhões de elementos em uma chave causam “big key”. Operações DEL, migração de slot ou replicação ficam lentas. Você deve dividir a chave por temporada, região, ou dia (leaderboard:2026-10).
  • Score precisa de precisão absoluta: por exemplo, moeda com muitos dígitos decimais. double tem erro de arredondamento, então você deve armazenar a menor unidade (centavos, centésimos) como inteiro.
  • Contagem aproximada é suficiente: se você só precisa saber “cerca de quantos usuários únicos”, HyperLogLog usa muito menos memória.

8. Conclusão

Sorted set resolve muitos problemas apenas escolhendo o score apropriado. Para leaderboard, o score é a pontuação e você usa ZINCRBY, ZREVRANGE, ZREVRANK. Para priority queue, o score é a prioridade combinada com timestamp, junto com ZPOPMIN/BZPOPMIN. Para delayed queue, o score é o momento em que precisa rodar. Para rate limiter, o score é o timestamp de cada requisição.

Há duas coisas para lembrar. Primeira, toda sequência de operações “ler depois escrever” deve ser encapsulada em um comando atômico ou script Lua. Segunda, sempre verifique o limite de precisão de double ao combinar múltiplos valores em um score. Dominando essas duas coisas, você pode usar ZSET com confiança em produção.

redisspring-bootspring-data-redissorted-setjava