Diseña un sistema de leaderboard/ranking en tiempo real para millones de usuarios
Los sorted sets de Redis convierten el rango, el top-N y las actualizaciones de puntaje en operaciones logarítmicas en vez de escaneos completos de tabla.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña un sistema de leaderboard/ranking en tiempo real para millones de usuarios
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué esto no es solo "SELECT ... ORDER BY score DESC"
Un leaderboard suena a problema resuelto hasta que escribes los requisitos reales: obtener el rango de un usuario entre millones de jugadores en milisegundos, obtener el top 100 en milisegundos, obtener "los 5 jugadores a mi alrededor" en milisegundos, y aceptar miles de actualizaciones de puntaje por segundo, todo mientras el ranking cambia continuamente. Una base de datos relacional puede responder cualquiera de estos con un índice, pero ORDER BY score DESC LIMIT 100 OFFSET 4500000 para encontrar dónde está un usuario específico es un escaneo completo de todo lo que va antes que él, y hacer eso en cada actualización de puntaje, para cada jugador, no escala.
La estructura de datos correcta es un sorted set: una colección donde cada miembro tiene un puntaje, la estructura se mantiene ordenada por puntaje sola, y rango/inserción/actualización son todos logarítmicos. Redis trae esto como un tipo de primera clase (ZSET), respaldado internamente por una skip list más una tabla hash, que es exactamente por qué es la respuesta estándar a esta pregunta.
#Las operaciones centrales
import Redis from "ioredis";const redis = new Redis(process.env.REDIS_URL);const LEADERBOARD_KEY = "leaderboard:global:season-2024";// Fija (o sobrescribe) el puntaje de un jugador.export async function submitScore(userId: string, score: number): Promise<void> {await redis.zadd(LEADERBOARD_KEY, score, userId);}// Acumula puntos en vez de sobrescribir (p. ej. "+10 por esta partida").export async function incrementScore(userId: string, delta: number): Promise<number> {return redis.zincrby(LEADERBOARD_KEY, delta, userId);}// Rango en base 1, mayor puntaje primero.export async function getRank(userId: string): Promise<number | null> {const rank = await redis.zrevrank(LEADERBOARD_KEY, userId); // base 0, descendentereturn rank === null ? null : rank + 1;}// Los N mejores jugadores con sus puntajes.export async function getTopN(n: number) {const raw = await redis.zrevrange(LEADERBOARD_KEY, 0, n - 1, "WITHSCORES");const results: Array<{ userId: string; score: number; rank: number }> = [];for (let i = 0; i < raw.length; i += 2) {results.push({ userId: raw[i], score: Number(raw[i + 1]), rank: i / 2 + 1 });}return results;}// "Jugadores a mi alrededor" — una ventana centrada en el rango del usuario que pregunta.export async function getAroundMe(userId: string, radius: number) {const rank = await redis.zrevrank(LEADERBOARD_KEY, userId);if (rank === null) return [];const start = Math.max(0, rank - radius);const stop = rank + radius;const raw = await redis.zrevrange(LEADERBOARD_KEY, start, stop, "WITHSCORES");const results: Array<{ userId: string; score: number; rank: number }> = [];for (let i = 0; i < raw.length; i += 2) {results.push({ userId: raw[i], score: Number(raw[i + 1]), rank: start + i / 2 + 1 });}return results;}
ZADD, ZINCRBY, ZRANK/ZREVRANK, y ZRANGE/ZREVRANGE son todos O(log N), que es lo que hace esto viable a "millones de usuarios": una actualización de puntaje para un jugador nunca toca las otras 4,999,999 filas.
#Hacer atómico "actualizar puntaje, luego leer el rango"
Un bug sutil de corrección: si tu API actualiza el puntaje y luego hace un segundo viaje de ida y vuelta para leer el nuevo rango, el cambio de puntaje de otro jugador puede colarse en el medio, y terminarás reportando un rango que ya estaba obsoleto en el momento de enviarlo. Empuja ambas operaciones a un solo script de Lua, que Redis ejecuta de forma atómica:
const INCR_AND_RANK = `redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])local rank = redis.call('ZREVRANK', KEYS[1], ARGV[2])local score = redis.call('ZSCORE', KEYS[1], ARGV[2])return {score, rank}`;export async function incrementAndGetRank(userId: string, delta: number) {const [score, rank] = (await redis.eval(INCR_AND_RANK,1,LEADERBOARD_KEY,delta,userId)) as [string, number];return { score: Number(score), rank: rank + 1 };}
#Los empates, y por qué importan más de lo que parece
Dos jugadores con puntaje 1000 quedarán ordenados de forma arbitraria por Redis (orden de inserción dentro de la skip list), lo cual se ve como un bug para los usuarios: "¿por qué está por encima de mí si estamos empatados?" La solución estándar es codificar un desempate dentro del puntaje mismo. Si el logro más temprano debe ganar los empates, guarda un puntaje compuesto: score * 10^13 + (MAX_TIMESTAMP - achieved_at), de forma que entre puntajes iguales, el que se logró antes ordene primero, mientras que el puntaje real y visible sigue siendo recuperable con una división entera. Este truco —empaquetar una clave de orden secundaria dentro de la primaria— aparece constantemente en entrevistas una vez que lo mencionas, porque evita un segundo campo y un merge sort del lado del cliente.
#Escalar más allá de una sola clave de Redis
Un solo sorted set maneja bien decenas de millones de miembros en hardware moderno; la memoria es la restricción real, no el costo de la operación, ya que cada miembro cuesta aproximadamente 100+ bytes incluyendo los punteros de la skip list. Los problemas de escala reales aparecen de otra forma:
- Muchos leaderboards independientes (por juego, por temporada, por región, por gremio) se shardean naturalmente: cada uno tiene su propia clave, y Redis Cluster distribuye las claves entre nodos por hash slot automáticamente. Este es el caso común y no necesita diseño especial.
- Un único leaderboard verdaderamente global y extremadamente caliente —toda la plataforma rankeada en una sola clave— se convierte en un problema de clave caliente: cada escritura y cada lectura golpean el mismo shard. La solución habitual es un patrón write-behind: cada servidor de aplicación agrega/agrupa localmente los deltas de puntaje durante una ventana corta (digamos 1 segundo) y envía un solo
ZINCRBYpor usuario por ventana en vez de uno por evento, reduciendo el volumen de escritura en órdenes de magnitud. - Persistencia: los
ZSETviven en memoria. Activa la persistencia de Redis (AOF o snapshots RDB) para que un reinicio no borre el leaderboard, y trata un almacén durable separado (una tabla de base de datos actualizada de forma asíncrona) como la fuente de verdad si perder el leaderboard por completo sería inaceptable y no solo incómodo.