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.
ZADDde 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 custaO(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:
incrementScorecorresponde ao comandoZINCRBY. 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 aZREVRANGE ... WITHSCORES. O índice final é inclusivo, portanto o top 10 é0..9.reverseRankretornanullse 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,pointsdeve ser inferior a alguns milhões. Além disso, este método não funciona comincrementScore, 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 + millisresulta 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.popMincorresponde aZPOPMIN. 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 aBZPOPMIN. 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:
BZPOPMINmanté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 (adicionarcommons-pool2e configurarspring.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:
- Remove requisições mais antigas que
now - window(ZREMRANGEBYSCORE). - Conta as requisições restantes (
ZCARD). - 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.
PEXPIREajuda Redis a limpar chaves de clientes inativos.- Você precisa declarar um bean
Clock(por exemploClock.systemUTC()). InjetarClocktorna 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 usandoredis.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.
doubletem 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.