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. ZADD de 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 cuesta O(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:

  • incrementScore corresponde al comando ZINCRBY. 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 a ZREVRANGE ... WITHSCORES. El índice final es inclusivo, por lo que los 10 primeros son 0..9.
  • reverseRank devuelve null si 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, points debe ser menor que unos pocos millones. Además, este método no funciona con incrementScore, 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 + millis da 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.
  • popMin corresponde a ZPOPMIN. 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 a BZPOPMIN. 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: BZPOPMIN mantiene 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 (agregar commons-pool2 y configurar spring.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:

  1. Elimina las solicitudes más antiguas que now - window (ZREMRANGEBYSCORE).
  2. Cuenta las solicitudes restantes (ZCARD).
  3. 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.
  • PEXPIRE ayuda a Redis a limpiar automáticamente la clave de clientes que ya no están activos.
  • Necesitas declarar un bean Clock (por ejemplo Clock.systemUTC()). Inyectar Clock facilita 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 usando redis.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. double tiene 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.

redisspring-bootspring-data-redissorted-setjava