← Cours BD08

🚀 Exercices BD08 — Redis Avancé & Patterns

5 exercices · Solutions masquées

Exercice 1 — Cache-Aside avec stale-while-revalidate

Implémentez un cache avancé avec deux TTL : freshTTL (données fraîches) et staleTTL (données périmées mais utilisables). Retourner les données périmées immédiatement et les rafraîchir en arrière-plan.

Voir la solution
async function getWithSWR(key, fetchFn, { freshTTL = 60, staleTTL = 300 } = {}) {
  const freshKey = `fresh:${key}`;
  const staleKey = `stale:${key}`;

  // Données fraîches
  const fresh = await redis.get(freshKey);
  if (fresh) return JSON.parse(fresh);

  // Données périmées (stale) — retourner immédiatement
  const stale = await redis.get(staleKey);
  if (stale) {
    // Rafraîchissement en arrière-plan (non bloquant)
    setImmediate(async () => {
      try {
        const lockKey = `lock:swr:${key}`;
        const locked = await redis.set(lockKey, '1', 'EX', freshTTL, 'NX');
        if (!locked) return; // quelqu'un d'autre rafraîchit déjà
        const data = await fetchFn();
        const multi = redis.multi();
        multi.set(freshKey, JSON.stringify(data), 'EX', freshTTL);
        multi.set(staleKey, JSON.stringify(data), 'EX', staleTTL);
        multi.del(lockKey);
        await multi.exec();
      } catch (err) {
        console.error('SWR background refresh failed:', err);
      }
    });
    return JSON.parse(stale);
  }

  // Cache miss complet
  const data = await fetchFn();
  const multi = redis.multi();
  multi.set(freshKey, JSON.stringify(data), 'EX', freshTTL);
  multi.set(staleKey, JSON.stringify(data), 'EX', staleTTL);
  await multi.exec();
  return data;
}

Exercice 2 — Rate Limiting sliding window

Implémentez un rate limiter à fenêtre glissante (sliding window) avec Redis Sorted Sets, plus précis que la fenêtre fixe. Permettre 100 requêtes par minute par IP, retourner les headers X-RateLimit-*.

Voir la solution
const RATE_LIMIT = 100;
const WINDOW_MS  = 60 * 1000; // 1 minute

async function slidingWindowRateLimit(identifier) {
  const key = `ratelimit:sw:${identifier}`;
  const now = Date.now();
  const windowStart = now - WINDOW_MS;

  const multi = redis.multi();
  multi.zremrangebyscore(key, 0, windowStart);  // supprimer les vieilles entrées
  multi.zadd(key, now, `${now}-${Math.random()}`); // ajouter la requête courante
  multi.zcard(key);                               // compter
  multi.expire(key, Math.ceil(WINDOW_MS / 1000) + 1);
  const results = await multi.exec();

  const count = results[2][1];
  const remaining = Math.max(0, RATE_LIMIT - count);
  const allowed = count <= RATE_LIMIT;

  return {
    allowed,
    headers: {
      'X-RateLimit-Limit':     RATE_LIMIT,
      'X-RateLimit-Remaining': remaining,
      'X-RateLimit-Reset':     Math.ceil((now + WINDOW_MS) / 1000),
      'Retry-After':           allowed ? undefined : Math.ceil(WINDOW_MS / 1000)
    }
  };
}

// Middleware Express
function rateLimitMiddleware(req, res, next) {
  const identifier = req.ip;
  slidingWindowRateLimit(identifier).then(({ allowed, headers }) => {
    Object.entries(headers).forEach(([k, v]) => { if (v !== undefined) res.set(k, v); });
    if (!allowed) return res.status(429).json({ error: 'Too Many Requests' });
    next();
  });
}

Exercice 3 — Pub/Sub chat temps réel

Créez un système de chat multi-salles avec Redis Pub/Sub. Chaque salle est un channel Redis. Implémentez : rejoindre une salle, envoyer un message, quitter, et historique des 50 derniers messages par salle (Redis List).

Voir la solution
const Redis = require('ioredis');
const publisher  = new Redis(process.env.REDIS_URL);
const subscriber = new Redis(process.env.REDIS_URL);

const HISTORY_PREFIX = 'chat:history:';
const MAX_HISTORY    = 50;

async function joinRoom(roomId, userId, io) {
  const channel = `chat:room:${roomId}`;
  await subscriber.subscribe(channel);

  subscriber.on('message', (ch, raw) => {
    if (ch !== channel) return;
    const msg = JSON.parse(raw);
    io.to(`room:${roomId}`).emit('message', msg);
  });

  // Envoyer l'historique
  const history = await redis.lrange(HISTORY_PREFIX + roomId, 0, MAX_HISTORY - 1);
  return history.map(m => JSON.parse(m)).reverse();
}

async function sendMessage(roomId, userId, userName, content) {
  const msg = {
    id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
    roomId, userId, userName, content,
    timestamp: Date.now()
  };

  // Stocker dans l'historique
  const histKey = HISTORY_PREFIX + roomId;
  await redis.lpush(histKey, JSON.stringify(msg));
  await redis.ltrim(histKey, 0, MAX_HISTORY - 1);
  await redis.expire(histKey, 7 * 86400); // 7 jours

  // Publier
  await publisher.publish(`chat:room:${roomId}`, JSON.stringify(msg));
  return msg;
}

async function leaveRoom(roomId) {
  await subscriber.unsubscribe(`chat:room:${roomId}`);
}

async function getHistory(roomId) {
  const raw = await redis.lrange(HISTORY_PREFIX + roomId, 0, MAX_HISTORY - 1);
  return raw.map(m => JSON.parse(m)).reverse();
}

Exercice 4 — Redis Streams : event sourcing

Créez un système d'event sourcing avec Redis Streams pour tracer les événements d'un e-commerce (order.created, order.paid, order.shipped). Implémentez un consumer group avec au moins 2 workers et un mécanisme de reprise des messages non acquittés.

Voir la solution
const STREAM_KEY   = 'events:ecommerce';
const GROUP_NAME   = 'order-processors';
const PENDING_TIMEOUT = 60000; // 60s

// Initialisation
async function initConsumerGroup() {
  try {
    await redis.xgroup('CREATE', STREAM_KEY, GROUP_NAME, '$', 'MKSTREAM');
  } catch (err) {
    if (!err.message.includes('BUSYGROUP')) throw err;
  }
}

// Producteur
async function emitEvent(type, payload) {
  return redis.xadd(STREAM_KEY, '*',
    'type', type,
    'payload', JSON.stringify(payload),
    'timestamp', Date.now()
  );
}

// Consommateur
async function startWorker(workerId) {
  await initConsumerGroup();
  console.log(`Worker ${workerId} started`);

  while (true) {
    try {
      // Lire les nouveaux messages
      const msgs = await redis.xreadgroup(
        'GROUP', GROUP_NAME, workerId,
        'COUNT', 5, 'BLOCK', 2000,
        'STREAMS', STREAM_KEY, '>'
      );

      if (msgs) {
        for (const [, messages] of msgs) {
          for (const [id, fields] of messages) {
            await processEvent(id, fields, workerId);
          }
        }
      }

      // Reprendre les messages pending (timeout dépassé)
      const pending = await redis.xautoclaim(
        STREAM_KEY, GROUP_NAME, workerId,
        PENDING_TIMEOUT, '0-0', 'COUNT', 5
      );
      if (pending && pending[1]) {
        for (const [id, fields] of pending[1]) {
          await processEvent(id, fields, workerId);
        }
      }
    } catch (err) {
      console.error(`Worker ${workerId} error:`, err);
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

async function processEvent(id, fields, workerId) {
  const type = fields[fields.indexOf('type') + 1];
  const payload = JSON.parse(fields[fields.indexOf('payload') + 1]);
  console.log(`[${workerId}] Processing ${type}:`, payload);
  await redis.xack(STREAM_KEY, GROUP_NAME, id);
}

Exercice 5 — Pipeline optimisé : analytics dashboard

Implémentez une fonction getDashboardStats() qui récupère en un seul pipeline Redis : total utilisateurs actifs, CA du jour, top 5 produits, sessions actives, et taux d'erreur. Comparez avec et sans pipeline.

Voir la solution
async function getDashboardStats() {
  const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');

  // AVEC pipeline — 1 round-trip
  const start = Date.now();
  const pipeline = redis.pipeline();
  pipeline.pfcount(`stats:uv:${today}`);            // utilisateurs uniques
  pipeline.get(`stats:revenue:${today}`);           // CA du jour
  pipeline.zrevrange('stats:pages:total', 0, 4, 'WITHSCORES'); // top 5
  pipeline.scard('online:users');                   // sessions actives
  pipeline.get(`stats:errors:${today}`);            // erreurs
  const results = await pipeline.exec();
  const pipelineMs = Date.now() - start;

  // SANS pipeline — 5 round-trips
  const start2 = Date.now();
  await redis.pfcount(`stats:uv:${today}`);
  await redis.get(`stats:revenue:${today}`);
  await redis.zrevrange('stats:pages:total', 0, 4);
  await redis.scard('online:users');
  await redis.get(`stats:errors:${today}`);
  const noPipelineMs = Date.now() - start2;

  console.log(`Pipeline: ${pipelineMs}ms vs No pipeline: ${noPipelineMs}ms`);

  const [
    [, uniqueVisitors],
    [, revenueRaw],
    [, topPagesRaw],
    [, activeSessions],
    [, errorsRaw]
  ] = results;

  const topPages = [];
  for (let i = 0; i < topPagesRaw.length; i += 2) {
    topPages.push({ path: topPagesRaw[i], views: parseInt(topPagesRaw[i+1]) });
  }

  return {
    uniqueVisitors,
    revenue: parseFloat(revenueRaw || '0'),
    topPages,
    activeSessions,
    errors: parseInt(errorsRaw || '0'),
    pipelineMs, noPipelineMs
  };
}