Redis sorted set: leaderboard, cola de prioridad y ventana deslizante
1. Visión general
Sorted set (ZSET) es la estructura de datos más versátil de Redis. Cada elemento (member) está asociado a una puntuación (score), y Redis siempre los mantiene ordenados por score. Este artículo usa ZSET para resolver tres problemas comunes: tabla de clasificación (leaderboard), colas de prioridad/agendadas y rate limiter con ventana deslizante. Todo está escrito en Java 21 con Spring Boot 3.x. Después de leerlo, sabrás cómo elegir el score adecuado para cada problema y evitar las race conditions frecuentes.
2. ¿Cómo funciona sorted set?
Un ZSET tiene las siguientes propiedades:
- Member es único.
ZADDde un member existente solo actualiza su score. - Score es un número real
double. Los members con el mismo score se ordenan lexicográficamente. - Agregar, eliminar, actualizar y obtener rango tienen complejidad
O(log N). Obtener un rango de elementos cuestaO(log N + M), donde M es el número de elementos devueltos.
Puedes probar rápidamente con 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 devuelve elementos ordenados por score descendente. ZREVRANK indica la posición de bob (contando desde 0) en ese orden.
2.1. Configuración del proyecto
Agrega la dependencia Spring Data Redis. Las dos dependencias de Testcontainers se usan para las pruebas en la sección 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>
Configuración de la conexión:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot crea automáticamente el bean StringRedisTemplate. Todas las operaciones ZSET pasan por opsForZSet().
3. Tabla de clasificación (Leaderboard)
El problema: un juego suma puntos a los jugadores, necesitas obtener el top N y el rango de cualquier persona. Aquí, score es la puntuación y member es el ID del jugador.
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);
}
}
Explicación de cada parte:
incrementScorecorresponde al comandoZINCRBY. Este comando es atómico. Si el member no existe, Redis considera la puntuación inicial como 0. No necesitas leer la puntuación antigua, sumarla y escribirla de nuevo, por lo que no hay race condition cuando múltiples solicitudes suman puntos al mismo jugador.reverseRangeWithScores(KEY, 0, n - 1)corresponde aZREVRANGE ... WITHSCORES. El índice final es inclusivo, por lo que los 10 primeros son0..9.reverseRankdevuelvenullsi el jugador no está en la tabla. Agregamos 1 para que el rango comience en 1 y sea más amigable con el usuario.
Prueba:
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. Manejo de empates
Cuando dos personas tienen la misma puntuación, Redis ordena por nombre del member. Con ZREVRANGE, el orden lexicográfico se invierte, por lo que zoe viene antes que anna. Este resultado raramente se alinea con la intención del negocio. Si quieres que quién alcance la puntuación primero quede en la cima, puedes codificar la hora en la parte decimal del 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;
}
Quien alcanza la puntuación antes tiene un now más pequeño, por lo que tieBreak es mayor y se clasifica más arriba. Al mostrar, tomas Math.floor(score) para obtener la puntuación real.
Nota: score es
double, solo representa números enteros precisamente hasta 2^53 (aproximadamente 9×10^15). Cuando combinas múltiples valores en un score, debes calcular previamente la magnitud para no perder precisión. Con el método anterior,pointsdebe ser menor que unos pocos millones. Además, este método no funciona conincrementScore, porque cada suma acumula también la parte decimal.
4. Cola de prioridad y cola con retraso
4.1. Cola de prioridad
Esta vez score es la prioridad: cuanto más pequeño el número, mayor la prioridad. Para que los trabajos con el mismo nivel de prioridad sigan en orden FIFO, combinamos además la marca de tiempo en el 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("la prioridad debe estar entre 0 y 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);
}
}
Los puntos clave:
priority * 1e13 + millisda un valor máximo de aproximadamente 9.2×10^13, aún muy por debajo del umbral 2^53. Un trabajo priority 0 siempre sale antes que priority 1, incluso si se agrega después.popMincorresponde aZPOPMIN. Este comando obtiene y elimina el elemento en un único paso atómico. Dos workers nunca reciben el mismo trabajo.popMin(key, timeout, unit)corresponde aBZPOPMIN. Este comando bloquea la conexión hasta que hay un trabajo o se agota el timeout, por lo que evitas el tipo de polling continuo que consume CPU.
Nota:
BZPOPMINmantiene una conexión durante todo el tiempo de espera. Con Lettuce usando la conexión compartida por defecto, los comandos de bloqueo también bloquearán otros comandos. Debes habilitar el pool de conexiones (agregarcommons-pool2y configurarspring.data.redis.lettuce.pool.*) o usar una connection factory separada para el worker.
4.2. Cola con retraso
¿Quieres enviar un email de recordatorio de pago después de 30 minutos? Usa score como el momento en que debe ejecutarse (epoch millis). El worker periódicamente obtiene los trabajos que tienen score menor o igual al momento actual.
La siguiente forma de escribirlo es incorrecta: llamar a rangeByScore y luego remove. Dos workers pueden leer el mismo trabajo antes de poder borrarlo. Combinamos los dos pasos en un script Lua para que Redis lo ejecute de forma atómica:
@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);
}
}
El script obtiene como máximo un trabajo que ya es debido y luego lo elimina. En Lua, return false se convierte en nil, por lo que el lado de Java recibe null. El worker puede ser un @Scheduled(fixedDelay = 500) que llama a pollDue en un bucle hasta que esté vacío.
Nota: Después de hacer pop, el trabajo sale de Redis. Si el worker muere a mitad de camino, el trabajo se pierde. Cuando necesitas garantizar at-least-once, cambias el trabajo a un ZSET “processing” con score como la fecha límite, y lo eliminas cuando termines de procesarlo. Si los requisitos son aún más complejos, considera Redis Streams o un message broker real.
5. Rate limiter con ventana deslizante
Fixed window (contar solicitudes por minuto) tiene un punto débil en los límites. Por ejemplo: 100 solicitudes a las 00:59 y 100 solicitudes a las 01:00, ambas pasan, es decir, 200 solicitudes en 2 segundos. Sliding window lo arregla guardando la marca de tiempo de cada solicitud en ZSET:
- Elimina las solicitudes más antiguas que
now - window(ZREMRANGEBYSCORE). - Cuenta las solicitudes restantes (
ZCARD). - Si no ha excedido el límite, agrega la solicitud actual con score como
now.
Los tres pasos deben ejecutarse de forma atómica, por lo que volvemos a usar Lua:
@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);
}
}
Explicación:
- Member es un UUID aleatorio. Si usas timestamp como member, dos solicitudes en el mismo milisegundo se sobrescribirán mutuamente y se contarán como una.
PEXPIREayuda a Redis a limpiar automáticamente la clave de clientes que ya no están activos.- Necesitas declarar un bean
Clock(por ejemploClock.systemUTC()). InyectarClockfacilita las pruebas. Sin embargo, si hay múltiples instancias de la aplicación, sus relojes deben estar sincronizados, o puedes obtener la hora directamente en el script usandoredis.call('TIME').
Nota: Cada solicitud consume un elemento en ZSET. Con un límite grande, por ejemplo 100.000 solicitudes/hora para cada cliente, la memoria aumenta rápidamente. En ese caso, debes usar sliding window counter (estimar desde dos ventanas fijas) o token bucket.
En cluster mode, el script solo puede tocar claves en el mismo hash slot. Los scripts en este artículo cada vez usan solo una clave, por lo que no hay problema.
6. Pruebas con Testcontainers
La siguiente prueba se ejecuta con Redis real en Docker. @ServiceConnection configura automáticamente host y port 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();
}
}
La prueba final verifica dos cosas: la cuarta solicitud de c-42 es rechazada, y el límite se calcula por separado para cada cliente. Para probar que la ventana “se desliza”, inyectas un Clock ajustable (por ejemplo una clase MutableClock que escribes), avanzas el tiempo otros 10 segundos y llamas nuevamente a tryAcquire.
7. Cuándo no debes usar sorted set
- Necesitas durabilidad como una message queue: ZSET no tiene acknowledgement, consumer group o retry. Redis Streams, RabbitMQ o Kafka son más apropiados.
- Un ZSET demasiado grande: decenas de millones de elementos en una clave causa un “big key”. Las operaciones DEL, migrar slot o replicación son todas lentas. Debes dividir la clave por temporada, por región, o por día (
leaderboard:2026-10). - Score necesita precisión absoluta: por ejemplo, moneda con muchos decimales.
doubletiene error, debes guardar en la unidad más pequeña (céntimo) como número entero. - Una estimación es suficiente: si solo necesitas saber “aproximadamente cuántos usuarios únicos”, HyperLogLog usa mucho menos memoria.
8. Conclusión
Sorted set resuelve muchos problemas simplemente eligiendo el score adecuado. Con leaderboard, score es la puntuación y usas ZINCRBY, ZREVRANGE, ZREVRANK. Con priority queue, score es la prioridad combinada con timestamp, combinado con ZPOPMIN/BZPOPMIN. Con delayed queue, score es el momento en que debe ejecutarse. Con rate limiter, score es el momento de cada solicitud.
Hay dos cosas que recordar. Primero, toda secuencia de operaciones “leer y luego escribir” debe envolverse en un comando atómico o script Lua. En segundo lugar, siempre verifica el límite de precisión de double cuando combinas múltiples valores en un score. Dominando estas dos cosas, puedes usar ZSET con confianza en un sistema de producción.