Diseño de un ranking en tiempo real para juegos usando Redis sorted set
1. Descripción general
Las tablas de clasificación (leaderboard) aparecen en casi todos los juegos en línea. El problema suena simple: ordenar jugadores por puntuación. Cuando hay millones de jugadores y miles de partidas que terminan cada segundo, un simple ORDER BY score DESC en una base de datos relacional ya no es suficiente.
Este artículo repasa el diseño completo: requisitos, modelo de datos en Redis sorted set, API, flujos de procesamiento, y aspectos desafiantes como manejo de empates, actualizaciones duplicadas, consistencia de datos y picos de carga. Después de leerlo, podrás construir un leaderboard funcional con Spring Boot 3 y Redis, además de entender sus limitaciones.
2. Requisitos del problema
2.1. Requisitos funcionales
- Cada temporada tiene su propia tabla de clasificación. La puntuación de un jugador es la puntuación más alta lograda en una partida.
- Ver top N (normalmente 100).
- Ver tu propio rango, incluso si estás en la posición 3.456.789.
- Ver “alrededor mío”: 5 personas por encima y 5 personas por debajo.
- Cuando dos personas tienen la misma puntuación, quien la logró primero aparece arriba.
2.2. Escala y restricciones
- 10 millones de jugadores por temporada.
- Promedio de 2.000 resultados de partidas por segundo. Durante horas punta o eventos puede llegar a 20.000/segundo.
- Mucha más lectura que escritura: la pantalla del top 100 se solicita constantemente.
- Latencia aceptable de visualización: menos de 1–2 segundos después de que termine la partida.
- Un resultado de partida puede reenviarse múltiples veces por retry. Los duplicados no deben corromper la tabla.
3. Por qué elegir Redis sorted set
Un sorted set (ZSET) almacena pares member → score y siempre los mantiene ordenados por score. Internamente usa skip list combinado con hash table, lo que le da estas características:
| Operación | Comando | Complejidad |
|---|---|---|
| Actualizar puntuación | ZADD |
O(log N) |
| Obtener rango | ZREVRANK |
O(log N) |
| Obtener top N / un rango | ZREVRANGE |
O(log N + M) |
| Obtener puntuación de una persona | ZSCORE |
O(1) |
Con N = 10 millones, log N es solo unos 23 pasos. Compáralo con SQL: para saber el rango de una persona, tienes que contar cuántas filas tienen una puntuación más alta. Incluso con índices, esta consulta sigue siendo costosa a gran escala.
En cuanto a memoria, cada miembro consume aproximadamente 80–120 bytes, dependiendo de la longitud del ID. 10 millones de jugadores ocupan alrededor de 1 GB, que encaja cómodamente en un único nodo Redis.
4. Arquitectura general
Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
│ ▲
└──► Persist Consumer ──► PostgreSQL (fuente de verdad) │
│
Client ──► Leaderboard API (Spring Boot) ── caché top 100 en RAM ────────┘
Hay tres decisiones clave:
- PostgreSQL es la fuente de verdad. Redis es solo un índice para lecturas rápidas. Si pierdes Redis, puedes reconstruirlo desde la base de datos.
- Kafka se interpone para absorber picos de carga. El servidor de juego no se ralentiza cuando Redis está ocupado.
- La API de lectura está separada del flujo de escritura. El top 100 se cachea en RAM de cada instancia por poco tiempo.
Tabla para almacenar resultados de partidas en 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);
La clave primaria (match_id, player_id) hace que las escrituras repetidas sean inofensivas: simplemente usa INSERT ... ON CONFLICT DO NOTHING.
5. Modelo de datos en Redis
5.1. Claves y miembros
lb:{s12} → ZSET, miembro = playerId, score = puntuación codificada
lb:{s12}:dedup:<matchId>:<playerId> → STRING, marca resultado ya procesado (TTL 1 día)
La parte {s12} es una hash tag. En Redis Cluster, todas las claves con la misma hash tag caen en el mismo slot. Esto es obligatorio si quieres usarlas juntas en un script Lua.
5.2. Manejo de empates mediante score compuesto
Redis ordena miembros con el mismo score por orden léxico del miembro. Este tipo de ordenamiento no tiene sentido para un juego. Necesitamos “quien lo logró primero aparece arriba”, así que tenemos que incrustar el tiempo en el score.
El score de Redis es un double, que puede representar con precisión cualquier número entero hasta 2^53. Dividimos los 53 bits así:
- 21 bits altos: puntos (máximo aproximadamente 2 millones).
- 32 bits bajos:
MAX_TIME - segundos desde el inicio de la temporada. Quien lo logró antes tendrá un valor mayor, así aparecerá arriba cuando se ordena en descenso.
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("puntos fuera de rango: " + points);
}
if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
throw new IllegalArgumentException("tiempo fuera de rango");
}
return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
}
public static long points(double encoded) {
return ((long) encoded) >>> TIME_BITS;
}
}
La función encode devuelve un long. Lo enviarás a Redis como una cadena de números enteros para evitar cualquier imprecisión al formatear double. Al leerlo de vuelta, points() desplaza 32 bits a la derecha para recuperar el punto original. La conversión (long) encoded es precisa porque el valor no excede 2^53.
Nota: Si tu juego tiene puntos mayores que 2 millones, reduce la resolución de tiempo, por ejemplo usando minutos en lugar de segundos, para tener más bits para los puntos. Nunca excedas 53 bits. Si lo haces,
doubleredondea, y dos personas con puntos diferentes podrían recibir el mismo score.
Test para la parte de codificación, ya que es el lugar más propenso a errores:
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);
}
}
La tercera prueba simula a Redis devolviendo el score como double. Verifica que en el valor máximo, aún decodificas correctamente.
6. Actualizar puntuación: atómico e idempotente
Las dependencias necesarias:
<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>
Actualizar la puntuación tiene dos requisitos que deben cumplirse simultáneamente:
- Solo escribir si la puntuación nueva es más alta que la actual.
- Ignorar resultados de partidas ya procesados.
Si lo haces con dos comandos separados como ZSCORE seguido de ZADD, dos consumidores podrían intercalarse. Entonces una puntuación más baja podría sobrescribir una más alta. Fusionamos todo en un script Lua. Redis ejecuta scripts de un solo hilo y atómicamente.
@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) {}
El script devuelve tres valores posibles:
-1: duplicado.SET NXfalló porque este resultado de partida ya fue procesado.0: no es un nuevo récord, la tabla no cambia.1: se actualizó el récord.
Puedes usar este código de retorno para contar métricas o enviar notificaciones como “¡Acabas de subir de rango!”.
Nota: Redis 6.2+ tiene
ZADD key GT score memberpara escribir solo cuando el score es mayor. Si no necesitas dedup, puedes usar ese comando en lugar del script. Aquí seguimos necesitando el script para fusionar el paso de verificación de duplicados en una operación atómica.
Si las reglas del juego son sumar puntos (ZINCRBY) en lugar de tomar el máximo, la idempotencia se vuelve crítica. Razón: sumar dos veces es incorrecto. Entonces, la parte de tiempo en el score compuesto también debe actualizarse cada suma, haciendo el script más complejo.
7. API de lectura
@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 devuelve el rango contando desde 0 en orden descendente, así que el rango mostrado es rank + 1. Si el jugador aún no tiene puntos en la temporada, devuelve null y la API devuelve una lista vacía. Llamar a /seasons/12/leaderboard/players/42/around daría un resultado como:
[{"rank":3456784,"playerId":901,"points":1520},
...
{"rank":3456789,"playerId":42,"points":1518},
...
{"rank":3456794,"playerId":77,"points":1515}]
reverseRank y reverseRangeWithScores no son atómicas entre sí. Entre los dos comandos, la tabla puede cambiar un poco. Para una pantalla de ranking, una discrepancia de unos pocos milisegundos es aceptable. Si necesitas precisión absoluta, fusiona ambas en un script Lua.
8. Aspectos desafiantes y soluciones
8.1. Hot key en top 100
Todo cliente solicita el top 100, y todos convergen en una sola clave en un único nodo Redis. Agregar réplicas ayuda parcialmente. Aun así, la forma más eficiente es cachear en RAM de cada instancia API y refrescar 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;
}
}
Recuerda habilitar @EnableScheduling. Con 50 instancias API, Redis solo recibe 50 solicitudes de top 100 por segundo, sin importar cuántos clientes haya. En el ejemplo, la temporada está hardcoded por claridad. En producción, deberías obtener la temporada actual desde la configuración.
8.2. Picos de carga de escritura
Cuando un evento termina, decenas de miles de partidas pueden cerrarse simultáneamente. Kafka actúa como buffer. El consumidor procesa en lotes y envía comandos mediante pipeline para reducir viajes de ida y vuelta por la red:
@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
results.forEach(writer::submit);
}
Si necesitas ir aún más rápido, puedes agrupar las ejecuciones en redis.executePipelined(...). También particiona el topic por playerId. De esta forma, todos los resultados de un jugador se procesan secuencialmente en la misma partición, reduciendo contención. El script Lua sigue siendo correcto incluso con contención.
Cuando Redis está lento, el lag del consumidor aumenta, pero el servidor de juego no se ve afectado. Los jugadores solo ven la tabla actualizarse con unos segundos de retraso.
8.3. Consistencia entre Redis y database
Redis puede perder datos si un nodo muere antes de poder fsync. Como PostgreSQL es la fuente de verdad, necesitas un job para reconstruir la tabla:
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;
El job escribe resultados en una clave temporal como lb:{s12}:rebuild, luego usa RENAME para reemplazar la clave real atómicamente. Los usuarios nunca ven la tabla vacía en medio.
Este job también se ejecuta periódicamente para conciliación. Si el número de miembros en Redis difiere de la base de datos, lo detectarás.
8.4. Tablas por día, semana y limpieza
Una tabla diaria usa la clave lb:{s12}:d:2026-10-12 con EXPIRE de 3 días. Una tabla semanal puede consolidarse desde tablas diarias usando ZUNIONSTORE ... AGGREGATE MAX. Al final de la temporada, captura el top 1000 en la base de datos para recompensas, luego elimina la clave grande con UNLINK en lugar de DEL para evitar bloquear Redis.
8.5. Ranking de amigos
Con una lista de aproximadamente 200 amigos, usa ZMSCORE lb:{s12} id1 id2 ... para obtener los scores de todos en un comando, luego ordena en Java. No hay necesidad de crear un ZSET separado para cada persona.
9. Cuándo NO usar este diseño
- Escala que excede un nodo. Un ZSET no puede dividirse entre nodos de Cluster. Si tienes cientos de millones de jugadores en la misma tabla, debes particionar por rango de puntos o región, y aceptar que el rango global es solo aproximado.
- Criterios de ranking multidimensionales complejos. Por ejemplo, puntos, luego tasa de victoria, luego número de partidas. Cuando 53 bits no son suficientes para codificar todo.
- Rancos precisos para propósitos financieros. Recompensas con dinero real deberían recalcularse desde la base de datos en el momento de cierre. Nunca tomes directamente de Redis.
- Pocos jugadores. Decenas de miles de filas está bien con PostgreSQL con índice
(season_id, points DESC), y evitas operar otro sistema.
10. Conclusión
Redis sorted set permite actualizar, obtener rango y obtener top N con costo O(log N). Es el fundamento natural para un leaderboard en tiempo real.
La parte difícil reside en los detalles alrededor:
- Codificar empates en el score sin exceder 53 bits.
- Usar un script Lua para tanto escribir solo récords como ser idempotente.
- Poner Kafka delante para absorber picos de carga.
- Cachear top 100 en RAM para evitar hot keys.
- Mantener PostgreSQL como fuente de verdad para poder reconstruir Redis en cualquier momento.
Al implementar, debes escribir tests para ScoreCodec primero. Un error de desbordamiento de bits aquí corromperá toda la tabla sin ningún mensaje de error.