Saltar al contenido

Estructuras de datos de Redis más allá de clave-valor

Estructuras de Redis más allá de las cadenas: sorted sets para rankings, streams para eventos, HyperLogLog para cardinalidad y bitmaps para flags.

5 min de lectura
Diagrama de estructuras de datos de Redis que muestra sorted sets, streams, HyperLogLog y operaciones con hashes

La mayoría de los desarrolladores usa Redis como una caché clave-valor: se guarda una cadena, se recupera una cadena, se le añade un TTL. Eso cubre quizás el 30 % de lo que Redis puede hacer. El resto de sus estructuras de datos resuelve problemas que resultan incómodos de modelar en bases de datos relacionales y prohibitivamente costosos de calcular en el momento de la solicitud.

Sorted sets, streams, HyperLogLog y bitmaps están diseñados específicamente para patrones que aparecen constantemente en aplicaciones web: tablas de clasificación, feeds de actividad, conteos de visitantes únicos y feature flags. Elegir la estructura de datos correcta convierte un problema complejo en un único comando de Redis.

Sorted Sets para tablas de clasificación

Los sorted sets almacenan miembros junto con un score y los mantienen ordenados automáticamente. Las operaciones de inserción, eliminación y ranking son todas O(log n).

tstypescript
import Redis from 'ioredis';
const redis = new Redis();
 
// ❌ Leaderboard with SQL — expensive query on every page view
// SELECT user_id, score FROM scores ORDER BY score DESC LIMIT 10;
// Gets slower as the table grows. Needs an index. Still scans rows.
tstypescript
// ✅ Sorted set — O(log n) insert, O(log n + m) range query
async function updateScore(userId: string, points: number): Promise<void> {
  await redis.zincrby('leaderboard:weekly', points, userId);
}
 
async function getTopPlayers(count: number): Promise<Array<{ userId: string; score: number }>> {
  // ZREVRANGE returns members in descending score order
  const results = await redis.zrevrange('leaderboard:weekly', 0, count - 1, 'WITHSCORES');
 
  const players: Array<{ userId: string; score: number }> = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      userId: results[i],
      score: parseFloat(results[i + 1]),
    });
  }
  return players;
}
 
async function getPlayerRank(userId: string): Promise<number | null> {
  // ZREVRANK returns 0-based rank (0 = highest score)
  const rank = await redis.zrevrank('leaderboard:weekly', userId);
  return rank !== null ? rank + 1 : null;
}
 
// Usage:
await updateScore('user:alice', 150);
await updateScore('user:bob', 320);
await updateScore('user:carol', 275);
 
const top3 = await getTopPlayers(3);
// [{ userId: 'user:bob', score: 320 }, { userId: 'user:carol', score: 275 }, ...]
 
const aliceRank = await getPlayerRank('user:alice');
// 3

Las tablas de clasificación semanales se reinician eliminando la clave o usando nombres de clave con el número de semana: leaderboard:2021-W34. Sin SQL complejo, sin ordenamiento en la aplicación, sin invalidación de caché.

Streams para registros de eventos

Los streams de Redis son una estructura de registro de solo adición (append-only) con consumer groups, similares a Kafka pero integrados en el propio Redis. Se usan para feeds de actividad, registros de auditoría y procesamiento de eventos en tiempo real.

tstypescript
// Producer: append events to a stream
async function logActivity(event: {
  userId: string;
  action: string;
  resource: string;
}): Promise<string> {
  const entryId = await redis.xadd(
    'activity:stream',
    '*',   // Auto-generate ID (timestamp-based)
    'userId', event.userId,
    'action', event.action,
    'resource', event.resource,
    'timestamp', new Date().toISOString()
  );
  return entryId;
}
 
// Consumer: read recent events
async function getRecentActivity(count: number): Promise<Array<Record<string, string>>> {
  const entries = await redis.xrevrange('activity:stream', '+', '-', 'COUNT', count);
  return entries.map(([id, fields]) => {
    const obj: Record<string, string> = { id };
    for (let i = 0; i < fields.length; i += 2) {
      obj[fields[i]] = fields[i + 1];
    }
    return obj;
  });
}
tstypescript
// Consumer group: distribute processing across workers
async function setupConsumerGroup(): Promise<void> {
  try {
    await redis.xgroup('CREATE', 'activity:stream', 'processors', '0', 'MKSTREAM');
  } catch {
    // Group already exists — safe to ignore
  }
}
 
async function processEvents(consumerId: string): Promise<void> {
  while (true) {
    const results = await redis.xreadgroup(
      'GROUP', 'processors', consumerId,
      'COUNT', 10,
      'BLOCK', 5000,    // Block for 5s waiting for new events
      'STREAMS', 'activity:stream', '>'
    );
 
    if (!results) continue;
 
    for (const [, entries] of results) {
      for (const [entryId, fields] of entries) {
        // Process the event
        await handleEvent(entryId, fields);
 
        // Acknowledge processing is complete
        await redis.xack('activity:stream', 'processors', entryId);
      }
    }
  }
}

Los consumer groups llevan el control de qué eventos ha procesado cada consumidor. Si un worker falla, los eventos no confirmados se reenvían a otro worker. Esto proporciona un procesamiento at-least-once sin la sobrecarga operativa de Kafka.

HyperLogLog para conteos únicos

Contar visitantes únicos, búsquedas únicas o direcciones IP únicas de forma exacta requiere almacenar cada valor único. HyperLogLog estima la cardinalidad con un error de ~0,81 % usando solo 12 KB de memoria, sin importar la cantidad total.

tstypescript
// ❌ Exact unique count — memory grows with unique values
// SET: storing 10M unique visitor IDs = ~400MB
await redis.sadd('visitors:2021-08-25', visitorId);
const count = await redis.scard('visitors:2021-08-25');
tstypescript
// ✅ HyperLogLog — 12KB regardless of cardinality
await redis.pfadd('visitors:hll:2021-08-25', visitorId);
const estimated = await redis.pfcount('visitors:hll:2021-08-25');
// 10,000,000 unique visitors ≈ 9,919,000 estimated (0.81% error)
// Memory: 12KB vs ~400MB
 
// Merge multiple days for weekly unique count
await redis.pfmerge(
  'visitors:hll:week-34',
  'visitors:hll:2021-08-23',
  'visitors:hll:2021-08-24',
  'visitors:hll:2021-08-25'
);
const weeklyUniques = await redis.pfcount('visitors:hll:week-34');
// Union of unique visitors across 3 days — still 12KB

La contrapartida es clara: se obtiene una estimación, no un conteo exacto. Para paneles de analítica donde decir «aproximadamente 1,2 millones de visitantes únicos» resulta tan útil como decir «exactamente 1.203.847», HyperLogLog ahorra órdenes de magnitud en memoria.

Hashes para almacenamiento de objetos

Los hashes de Redis almacenan pares campo-valor dentro de una única clave. Úsalos en lugar de serializar objetos completos como una cadena: puedes leer y actualizar campos individuales sin recuperar el objeto entero.

tstypescript
// ❌ Storing objects as JSON strings
await redis.set('user:123', JSON.stringify({
  name: 'Alice',
  email: 'alice@example.com',
  loginCount: 42,
  lastLogin: '2021-08-25',
}));
 
// To increment loginCount: read, parse, modify, serialize, write
const user = JSON.parse(await redis.get('user:123') ?? '{}');
user.loginCount += 1;
await redis.set('user:123', JSON.stringify(user));
// Race condition if two requests do this simultaneously
tstypescript
// ✅ Hash fields — atomic field-level operations
await redis.hset('user:123', {
  name: 'Alice',
  email: 'alice@example.com',
  loginCount: '42',
  lastLogin: '2021-08-25',
});
 
// Atomic increment — no read-modify-write race condition
await redis.hincrby('user:123', 'loginCount', 1);
 
// Read only the fields you need
const [name, loginCount] = await redis.hmget('user:123', 'name', 'loginCount');
 
// Update a single field without touching others
await redis.hset('user:123', 'lastLogin', new Date().toISOString());

Los hashes son eficientes en memoria para objetos pequeños (Redis usa una codificación compacta para hashes de menos de 128 campos) y eliminan las condiciones de carrera de lectura-modificación-escritura gracias a operaciones atómicas por campo.

Bitmaps para feature flags

Los bitmaps usan bits individuales para representar estados booleanos. Una sola clave de tipo bitmap puede llevar el control de una bandera booleana para millones de usuarios en apenas unos pocos megabytes.

tstypescript
// Track which users have opted into a beta feature
// User IDs map directly to bit offsets
 
async function enableBetaFeature(userId: number): Promise<void> {
  await redis.setbit('feature:dark-mode-beta', userId, 1);
}
 
async function disableBetaFeature(userId: number): Promise<void> {
  await redis.setbit('feature:dark-mode-beta', userId, 0);
}
 
async function hasBetaFeature(userId: number): Promise<boolean> {
  const bit = await redis.getbit('feature:dark-mode-beta', userId);
  return bit === 1;
}
 
// Count how many users have the feature enabled
async function betaUserCount(): Promise<number> {
  return redis.bitcount('feature:dark-mode-beta');
}
 
// Bitwise operations across features
// Users who have BOTH dark-mode AND new-checkout enabled:
await redis.bitop('AND', 'feature:both',
  'feature:dark-mode-beta', 'feature:new-checkout-beta');
const bothCount = await redis.bitcount('feature:both');

10 millones de usuarios = ~1,25 MB por feature flag. Comprobar una bandera es O(1). Contar los usuarios habilitados entre millones es una única operación BITCOUNT. Las operaciones bit a bit AND/OR entre banderas responden a «¿qué usuarios tienen la feature A y la feature B?» en un solo comando.

Puntos clave

  1. Los sorted sets sustituyen a las consultas de ranking complejas — tablas de clasificación, listados top-N y consultas por rango en O(log n)
  2. Los streams ofrecen procesamiento de eventos al estilo Kafka con consumer groups, confirmaciones y reenvío
  3. HyperLogLog estima conteos únicos en 12 KB sin importar la cardinalidad — ~0,81 % de error
  4. Los hashes almacenan objetos con operaciones atómicas por campo — sin condiciones de carrera de lectura-modificación-escritura
  5. Los bitmaps controlan banderas booleanas para millones de entidades en pocos megabytes — operaciones bit a bit entre banderas
  6. Ajusta la estructura de datos al patrón de acceso — la estructura de Redis correcta elimina complejidad en la aplicación
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX