← Formation BDD

🚀 BD08 — Redis Avancé & Patterns

Redis 7 Durée : ~2h30 Niveau : avancé

1. Cache-Aside Pattern

L'application consulte Redis d'abord, puis la base si absent (cache miss), puis stocke le résultat dans Redis.

const redis = require('./redis');
const pool  = require('./db');

async function getProductById(id) {
  const key = `cache:product:${id}`;

  // 1. Vérifier le cache
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);  // Cache HIT
  }

  // 2. Cache MISS — requête DB
  const { rows } = await pool.query(
    'SELECT * FROM products WHERE id = $1', [id]
  );
  const product = rows[0];
  if (!product) return null;

  // 3. Stocker en cache (5 minutes)
  await redis.set(key, JSON.stringify(product), 'EX', 300);
  return product;
}

// Invalidation du cache à la mise à jour
async function updateProduct(id, data) {
  await pool.query('UPDATE products SET name=$1, price=$2 WHERE id=$3',
    [data.name, data.price, id]);
  await redis.del(`cache:product:${id}`);         // invalider
  await redis.del('cache:products:list');          // invalider la liste aussi
}

// Cache avec stampede protection (mutex)
async function getCachedData(key, fetchFn, ttl = 300) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const locked = await redis.set(lockKey, '1', 'EX', 10, 'NX');
  if (!locked) {
    await new Promise(r => setTimeout(r, 100));
    return getCachedData(key, fetchFn, ttl);
  }
  try {
    const data = await fetchFn();
    await redis.set(key, JSON.stringify(data), 'EX', ttl);
    return data;
  } finally {
    await redis.del(lockKey);
  }
}

2. Sessions Express avec Redis

const express      = require('express');
const session      = require('express-session');
const connectRedis = require('connect-redis');
const Redis        = require('ioredis');

const app = express();
const redisClient = new Redis(process.env.REDIS_URL);
const RedisStore = connectRedis(session);

app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000  // 24h
  }
}));

// Utilisation
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body.email, req.body.password);
  req.session.userId = user.id;
  req.session.role   = user.role;
  res.json({ message: 'Connecté' });
});

app.get('/profile', (req, res) => {
  if (!req.session.userId) return res.status(401).json({ error: 'Non autorisé' });
  res.json({ userId: req.session.userId });
});

3. Pub/Sub

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

// Abonnement
await subscriber.subscribe('orders:new', 'payments:completed');

subscriber.on('message', (channel, message) => {
  const data = JSON.parse(message);
  if (channel === 'orders:new') handleNewOrder(data);
  if (channel === 'payments:completed') handlePayment(data);
});

// Publication
await publisher.publish('orders:new', JSON.stringify({
  orderId: 42, userId: 1, total: 99.99
}));

// Pattern subscribe (wildcards)
await subscriber.psubscribe('user:*:events');
subscriber.on('pmessage', (pattern, channel, message) => {
  console.log(`Pattern ${pattern}, channel ${channel}:`, message);
});

4. Redis Streams

Streams : journal append-only persistant, consommable par plusieurs consumer groups.

// XADD — ajouter au stream
const id = await redis.xadd('events:orders', '*',
  'orderId', '42',
  'userId',  '1',
  'total',   '99.99',
  'status',  'created'
);

// XREAD — lire depuis un offset
const results = await redis.xread('COUNT', 10, 'STREAMS', 'events:orders', '$');

// Consumer Groups
await redis.xgroup('CREATE', 'events:orders', 'workers', '$', 'MKSTREAM');

// Lire en tant que worker
const msgs = await redis.xreadgroup(
  'GROUP', 'workers', 'worker-1',
  'COUNT', 5, 'STREAMS', 'events:orders', '>'
);

// Acquitter un message traité
await redis.xack('events:orders', 'workers', messageId);

// Longueur du stream
const len = await redis.xlen('events:orders');

// Lire l'historique
const history = await redis.xrange('events:orders', '-', '+', 'COUNT', 100);

5. Verrous distribués (Redlock)

// npm install redlock
const Redlock = require('redlock');
const redlock = new Redlock([redis], {
  retryCount: 10,
  retryDelay: 200,
  retryJitter: 50
});

async function processPayment(paymentId) {
  const resource = `lock:payment:${paymentId}`;
  const ttl = 10000; // 10 secondes

  let lock;
  try {
    lock = await redlock.acquire([resource], ttl);
    // Traitement critique — une seule instance à la fois
    await doProcessPayment(paymentId);
  } catch (err) {
    if (err.name === 'LockError') {
      console.warn('Paiement déjà en cours de traitement');
    } else {
      throw err;
    }
  } finally {
    if (lock) await lock.release();
  }
}

// Rate limiting avec INCR
async function rateLimit(ip) {
  const key = `ratelimit:${ip}:${Math.floor(Date.now() / 60000)}`; // par minute
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 60);
  if (count > 100) throw new Error('Rate limit exceeded');
}

6. Pipeline & MULTI/EXEC

// Pipeline — grouper les commandes (1 round-trip)
const pipeline = redis.pipeline();
pipeline.set('key1', 'val1');
pipeline.incr('counter');
pipeline.hset('hash1', 'field1', 'value1');
pipeline.expire('key1', 300);
const results = await pipeline.exec();
// [[null, 'OK'], [null, 1], [null, 1], [null, 1]]

// MULTI/EXEC — transaction atomique Redis
const multi = redis.multi();
multi.decr('stock:p1');
multi.zadd('activity:log', Date.now(), 'purchase:p1');
multi.expire('stock:p1', 86400);
const [decrResult, zaddResult] = await multi.exec();

7. Scripts Lua (EVAL)

Les scripts Lua s'exécutent atomiquement côté serveur, sans round-trips intermédiaires.

// Incrémenter avec limite (rate limiter atomique)
const rateLimitScript = `
  local current = redis.call('INCR', KEYS[1])
  if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
  end
  if current > tonumber(ARGV[2]) then
    return 0
  end
  return 1
`;

const allowed = await redis.eval(rateLimitScript, 1,
  `ratelimit:${ip}`,  // KEYS[1]
  '60',               // ARGV[1] — TTL
  '100'               // ARGV[2] — max requêtes
);

// Charger un script une fois (SCRIPT LOAD)
const sha = await redis.script('load', rateLimitScript);
const result = await redis.evalsha(sha, 1, `ratelimit:${ip}`, '60', '100');