Scaling patterns — stateless, idempotency, locks, queues
TL;DR — Scaler horizontalement un service Nest = 1) stateless (zéro session locale, zéro fichier local), 2) idempotent (chaque request peut être rejouée), 3) distributed locks seulement quand vraiment nécessaire (souvent un mauvais signe), 4) eventual consistency comme défaut, pas comme exception. Sticky sessions = code smell. Queues + workers = friend. Load test avant prod — toujours.
🧠 Mental model — ASCII diagram + analogy
Load balancer
│
┌────┼────┬────┐
▼ ▼ ▼ ▼
API API API API (stateless, interchangeable)
│
├────────────┬────────────┐
▼ ▼ ▼
Postgres Redis BullMQ Queue
(truth) (cache, │
locks, ▼
rate) Worker pool
│
▼
Idempotent handlers
(retries safe)Analogie : si une de tes instances doit "se souvenir" d'un client précédent, tu as un immeuble où chaque ascenseur ne dessert qu'un étage. Le client coincé devant le bon ascenseur attend. Stateless = chaque ascenseur va partout. Si la mémoire est nécessaire, elle vit dans Redis/DB, pas dans le process.
Sticky sessions sont une béquille : elles compensent un design stateful. Acceptable temporairement (websockets), pas comme stratégie.
🛠️ Code minimal
Stateless session via JWT + Redis pour révocation
// session passe par le JWT (stateless verify)
// révocation via Redis (denylist par jti)
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private redis: Redis, private jwt: JwtService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedException();
const payload = await this.jwt.verifyAsync(token);
const revoked = await this.redis.get(`revoked:${payload.jti}`);
if (revoked) throw new UnauthorizedException('revoked');
req.user = payload;
return true;
}
}Idempotency keys
// idempotency.interceptor.ts
import { of, from, throwError } from 'rxjs';
import { mergeMap, catchError } from 'rxjs/operators';
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(@Inject('REDIS') private redis: Redis) {}
async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const req = ctx.switchToHttp().getRequest();
const key = req.headers['idempotency-key'];
if (!key) return next.handle();
const cacheKey = `idem:${req.user.sub}:${key}`;
const cached = await this.redis.get(cacheKey);
if (cached) return of(JSON.parse(cached));
// SETNX with TTL: only one writer
const acquired = await this.redis.set(`${cacheKey}:lock`, '1', 'EX', 60, 'NX');
if (!acquired) throw new ConflictException('in_progress');
// ⚠️ Piège RxJS : `tap(async ...)` ne fait PAS attendre la promesse —
// l'Observable se complète avant le `set`. Et un `catchError(async ...)`
// qui `throw` ne propage pas l'erreur (la promesse rejetée devient une
// valeur de récupération avalée). On `mergeMap` pour vraiment chaîner.
return next.handle().pipe(
mergeMap(async (res) => {
await this.redis.set(cacheKey, JSON.stringify(res), 'EX', 24 * 3600);
await this.redis.del(`${cacheKey}:lock`);
return res;
}),
catchError((err) =>
// on libère le lock PUIS on re-propage l'erreur (vrai re-throw)
from(this.redis.del(`${cacheKey}:lock`)).pipe(
mergeMap(() => throwError(() => err)),
),
),
);
}
}Le client envoie Idempotency-Key: <uuid> avec son POST/PUT. Si la requête est rejouée (retry après timeout), il reçoit la même réponse — pas de double charge sur Stripe.
Mental model — idempotency à 3 états, pas 2. Une clé n'est pas juste « vue / pas vue ». Elle est dans un de trois états :
in_flight(lock posé, réponse pas encore connue),completed(réponse mémorisée),absent. Le retry concurrent pendantin_flightdoit recevoir un409 Conflict(« réessaie dans un instant »), pas démarrer un second traitement. Faille classique : ne mémoriser que les succès. Si la 1ère tentative renvoie un4xxdéterministe (validation), le retry doit recevoir le même4xx, sinon le client boucle. Mémorise donc aussi les erreurs déterministes (statut + corps), mais jamais les5xx/timeouts (ceux-là, on veut qu'ils soient rejouables). La clé doit enfin être scopée à(user, route, hash(body)): réutiliser une clé avec un body différent est une attaque — réponds422 Unprocessableplutôt que de servir l'ancienne réponse.
Distributed lock (Redlock-ish)
import Redlock from 'redlock';
const redlock = new Redlock([redis], { retryCount: 3, retryDelay: 100 });
async function withLock<T>(resource: string, fn: () => Promise<T>): Promise<T> {
const lock = await redlock.acquire([`lock:${resource}`], 5000);
try {
return await fn();
} finally {
await lock.release();
}
}
// usage (TypeORM 0.3 : findOneBy, pas le raccourci findOne(id) déprécié)
await withLock(`account:${accountId}`, async () => {
const acc = await repo.findOneByOrFail({ id: accountId });
acc.balance -= amount;
await repo.save(acc);
});Attention : Redlock garantit l'exclusion mutuelle à condition que le clock skew soit borné et que tu acceptes des fencing tokens. Pour des invariants forts (money), préfère une transaction DB avec SELECT FOR UPDATE ou de l'optimistic locking avec version.
Le piège que Kleppmann démonte (lien en bas) : un lock distribué à TTL ne suffit jamais seul à protéger une ressource. Scénario : process A acquiert le lock (TTL 5s), part en GC pause de 8s, le TTL expire, B prend le lock et écrit, puis A se réveille et écrit aussi — les deux ont cru détenir le lock. La seule parade correcte est le fencing token : le lock renvoie un compteur monotone croissant, et la ressource protégée (DB, S3) rejette toute écriture portant un token inférieur au dernier vu. Sans fencing token côté ressource, Redlock est de la sécurité de confort, pas une garantie. C'est exactement pourquoi, sur de l'argent, on double avec un
SELECT … FOR UPDATE: la DB, elle, est la ressource et fournit l'arbitrage transactionnel.
Tableau de décision — quel mécanisme de concurrence ?
| Besoin | Mécanisme | Garantie | Coût |
|---|---|---|---|
| Invariant sur 1 ligne d'1 DB | @VersionColumn (optimistic) | Forte (retry sur conflit) | Quasi nul, pas de blocage |
| Section critique courte, même DB | SELECT … FOR UPDATE (pessimistic) | Forte | Bloque les concurrents, risque deadlock |
| Sérialiser un job cross-process sur 1 clé | Redis SET NX EX (lock simple) | Best-effort (TTL) | Faible, single point of failure si 1 nœud |
| Exclusion cross-service (S3 + DB + tiers) | Redlock + fencing token | Best-effort renforcé | Élevé (N nœuds, clock skew) |
| Invariant multi-lignes / multi-tables | Transaction SERIALIZABLE + retry | Forte | Aborts à rejouer sous contention |
| Workflow multi-étapes / multi-services | Saga + compensation | Eventual + atomicité métier | Conceptuel élevé |
Règle staff : descends toujours d'une ligne quand c'est possible. Le lock distribué est en bas de la liste parce qu'il échange une garantie forte contre une garantie best-effort et ajoute un composant à faire tomber.
Worker queue avec BullMQ
// orders.queue.ts
import { Queue, Worker } from 'bullmq';
export const ordersQueue = new Queue('orders', { connection: redisConfig });
new Worker('orders', async (job) => {
// idempotent: jobId = orderId
await ordersService.process(job.data.orderId);
}, {
connection: redisConfig,
concurrency: 10,
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
});
// enqueue (jobId = orderId for natural idempotency)
await ordersQueue.add('process', { orderId }, {
jobId: orderId, // dedupe
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
});Optimistic locking (TypeORM)
@Entity()
export class Account {
@PrimaryColumn() id: string;
@Column() balance: number;
@VersionColumn() version: number;
}
// usage: si deux processes lisent v=5, le 2nd update échoue (OptimisticLockVersionMismatchError)
const acc = await repo.findOne({ where: { id } });
acc.balance -= 100;
await repo.save(acc); // throws on conflict → retry🎯 Patterns courants
- Outbox pattern — pour publier sur une queue ET committer en DB atomiquement. Tu insères une ligne dans
outbox_eventsdans la même transaction que ton changement métier ; un worker dépile vers Kafka/RabbitMQ. Zéro perte, zéro double publish. - Saga / process manager — workflows multi-services. Plutôt qu'une transaction distribuée (XA = enfer), une suite de steps locaux avec compensation. Lib :
temporal.io, ou maison. - CQRS + event sourcing (avancé) — write side append-only, read side projections. Scale les lectures massivement, audit gratuit. Coût conceptuel élevé.
- Read replicas — Postgres streaming replication. Routes
GETvers replica,POST/PUTvers primary. Attention : lag de réplication → "read your writes" cassé sans soin. - Sharding — par tenant, par région. Dernier recours, après vertical scaling et read replicas. Reshard est une opération chirurgicale.
- Backpressure — si une queue grossit indéfiniment, le système est cassé. Monitor
queue.depth, alerte, rate-limit en amont ou drop avec dead letter.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- 7 :
@nestjs/bullv0.x (Bull v3, basé sur arena). Pas de BullMQ encore courant. - 8 :
@nestjs/bullv0.5+, BullMQ commence à être adopté (@nestjs/bullmqséparé). - 9 :
@nestjs/bullmqofficiel, recommandé. - 10 : Microservices Kafka client mis à jour (
kafkajs@2). NATS JetStream supporté. - 11 : BullMQ 5.x, support workers ES modules. Microservices supports gRPC + Kafka transport stable.
Côté @nestjs/microservices (transport intra-services) : utile pour RPC interne (TCP, NATS, Redis pub/sub). Pour les flux d'événements business, préfère un broker dédié (Kafka, RabbitMQ).
⚠️ Pitfalls
- State local in-process —
Mapglobal, fichier upload sur disque local, session in-memory. Tout perdu au scale-out. Externalize (Redis, S3, DB). - Sticky sessions sur HTTP REST — corrige le symptôme, pas la cause. Refactor d'abord, sticky uniquement pour websockets/SSE.
- Idempotency mal implémentée — la clé doit couvrir request + user. Sans
user, un attaquant peut deviner la clé d'un autre. - Retry sans backoff — thundering herd quand le downstream remonte. Toujours backoff exponential + jitter.
- Lock distribué pour tout — c'est un goulot. Souvent une transaction DB suffit. Distributed lock = quand tu n'as pas de transaction (cross-service, S3, etc.).
- Eventual consistency cachée au client — le user POST puis GET et voit l'ancien état → bug perçu. Soit le GET passe au primary, soit le client garde l'état localement, soit tu attends la prop.
- Queues sans dead letter — un message empoisonné boucle indéfiniment, sature les workers. Toujours configurer
maxAttempts+ DLQ. process.memoryUsage()qui grimpe sous charge → memory leak ou cache illimité. Limite explicite (LRU max), profile.
🧪 Testing — load testing
# Simple ramp-up test with k6
k6 run --vus 100 --duration 5m scaling-test.js// scaling-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // ramp to 50
{ duration: '3m', target: 200 }, // plateau
{ duration: '1m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<300'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const idemKey = `k6-${__VU}-${__ITER}`;
const res = http.post('http://api/orders', JSON.stringify({ /* ... */ }), {
headers: { 'Idempotency-Key': idemKey, 'Content-Type': 'application/json' },
});
check(res, { 'status 201': r => r.status === 201 });
sleep(0.5);
}Mesure : req/s soutenu, p95/p99, error rate, et corrélé avec CPU/mem/event loop lag des instances. Le point où la latence explose à charge croissante = ton plafond. Soak test 1–2h pour détecter les fuites mémoire.
🎬 Cas d'usage concrets
Scénario 1 — SaaS multi-tenant scalant à 1 200 clients
Qui : SaaS RH multi-tenant. Chaque tenant peut avoir 10 à 50 000 utilisateurs. Total : 1 200 tenants, 4M users actifs. Problème : avant scaling, tout sur un mono Nest + Postgres. Pic à 3K req/s → saturation. Sessions stockées in-memory cassaient le LB. Le moindre dump SQL d'un gros tenant impactait les autres.
// tenant-resolver.middleware.ts — extract tenant from JWT or subdomain
@Injectable()
export class TenantResolverMiddleware implements NestMiddleware {
use(req: any, res: any, next: any) {
const tenant = req.user?.tenantId ?? req.headers['x-tenant-id'];
if (!tenant) throw new BadRequestException('missing_tenant');
req.tenantId = tenant;
res.setHeader('x-tenant-id', tenant);
next();
}
}
// tenant-context.service.ts — request-scoped, durable for perf
@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantContextService {
static create({ tenantId }: { tenantId: string }) {
return { tenantId };
}
constructor(@Inject(CONTEXT) private readonly ctx: { tenantId: string }) {}
get tenantId() { return this.ctx.tenantId; }
}
// db-routing.service.ts — route to per-tenant Postgres schema
@Injectable()
export class DbRouter {
constructor(private readonly dataSource: DataSource, private readonly tenant: TenantContextService) {}
async query<T>(sql: string, params: unknown[]): Promise<T> {
return this.dataSource.query(`SET LOCAL search_path TO tenant_${this.tenant.tenantId}, public; ${sql}`, params);
}
}
// large tenants get a dedicated DB cluster
@Injectable()
export class DbResolver {
resolve(tenantId: string): DataSource {
if (LARGE_TENANTS.has(tenantId)) return this.largeTenantPool.get(tenantId)!;
return this.sharedPool;
}
}Gains : 1 200 tenants servis par 6 pods Nest + 3 Postgres (1 partagé pour les petits, 2 dédiés pour les 8 plus gros). Le durable: true rend le scope REQUEST quasi-gratuit (un seul provider tree par tenant, pas par requête). Aucun "noisy neighbor" depuis 14 mois. Scaling horizontal trivial — ajouter un pod = +15% capacité linéaire.
Scénario 2 — E-commerce avec CDN + cache à plusieurs étages
Qui : marketplace e-commerce, 250K orders/jour, catalogue produit 4M SKU. Front Next.js + API Nest. Problème : la page produit recevait 20K req/s en pic. La DB ne suivait pas. Stratégie : CDN devant l'API (Cloudflare), cache Redis L2, LRU L1 in-process, et un système d'invalidation propagé via Kafka.
// product.controller.ts — Cache-Control headers for CDN
@Get(':id')
@Header('Cache-Control', 'public, max-age=30, stale-while-revalidate=60')
async findOne(@Param('id') id: string) {
return this.products.getEnriched(id);
}
// product.service.ts — multi-layer cache + Kafka invalidation
@Injectable()
export class ProductsService implements OnModuleInit {
private l1 = new LRUCache<string, Product>({ max: 10_000, ttl: 10_000 });
constructor(
@Inject(CACHE_MANAGER) private readonly l2: Cache,
private readonly repo: ProductsRepository,
private readonly kafka: KafkaConsumer,
) {}
async onModuleInit() {
await this.kafka.consume('product-updates', async (msg) => {
const { productId } = JSON.parse(msg.value!.toString());
this.l1.delete(productId);
await this.l2.del(`product:${productId}`);
});
}
async getEnriched(id: string): Promise<Product> {
const local = this.l1.get(id);
if (local) return local;
const cached = await this.l2.get<Product>(`product:${id}`);
if (cached) { this.l1.set(id, cached); return cached; }
const fresh = await this.repo.findEnriched(id);
this.l1.set(id, fresh);
await this.l2.set(`product:${id}`, fresh, 60_000);
return fresh;
}
}Gains : CDN absorbe 80% du trafic produit (cache hit Cloudflare). Pour les 20% qui passent, L1 + L2 absorbent 95%. La DB voit < 1% du trafic produit. Quand un seller change un prix, Kafka propage l'invalidation sur tous les pods en < 200ms. Black Friday 2025 : 38K req/s soutenus avec p95 à 220ms.
Scénario 3 — Banque avec distributed locks pour invariants critiques
Qui : néobanque PME. Opérations sensibles : virements concurrents sur le même compte, mise à jour de plafonds, débit avec autorisation. Problème : deux virements arrivés simultanément sur un compte avec 100 € débitaient 50 € chacun → balance finale -50 € (race condition). Lock optimiste TypeORM aidait, mais pour des opérations cross-services (paiement + ledger + notification), il fallait du distribué.
// payment.service.ts — Redlock pour invariants cross-service
import Redlock from 'redlock';
@Injectable()
export class PaymentService {
private readonly redlock: Redlock;
constructor(@Inject('REDIS_NODES') redisNodes: Redis[]) {
this.redlock = new Redlock(redisNodes, {
retryCount: 5,
retryDelay: 100,
retryJitter: 50,
});
}
async transfer(from: string, to: string, amount: number, idempotencyKey: string) {
// Lock both accounts in a deterministic order to avoid deadlock
const [a, b] = [from, to].sort();
const lock = await this.redlock.acquire([`account:${a}`, `account:${b}`], 5000);
try {
// Re-check idempotency under lock
const existing = await this.idempotency.lookup(idempotencyKey);
if (existing) return existing;
const result = await this.dataSource.transaction(async (tx) => {
const src = await tx.findOneOrFail(Account, { where: { id: from }, lock: { mode: 'pessimistic_write' } });
if (src.balance < amount) throw new InsufficientFundsError(from, amount, src.balance);
src.balance -= amount;
await tx.save(src);
const dst = await tx.findOneOrFail(Account, { where: { id: to } });
dst.balance += amount;
await tx.save(dst);
const entry = await tx.save(LedgerEntry, { from, to, amount, at: new Date() });
return { transferId: entry.id, balance: src.balance };
});
await this.idempotency.store(idempotencyKey, result);
return result;
} finally {
await lock.release();
}
}
}Gains : zéro incident de race condition sur les comptes depuis 18 mois. Le double lock (Redlock + DB transaction FOR UPDATE) est ceinture-bretelles : Redlock évite les conflits cross-service (un autre service ne peut pas modifier le compte pendant qu'on le manipule), la transaction DB garantit la consistance même si Redlock défaille brièvement. Audit ACPR validé sur la robustesse des invariants.
🛠️ Exemple end-to-end
Mise en situation : tu scales un service de commande de courses (last-mile) qui doit gérer 4 000 commandes/min en pic. Stack : API publique Nest + worker BullMQ + Postgres + Redis. Tu veux : stateless (zéro session locale), idempotency keys, outbox pattern pour publier sur Kafka sans perte, retries avec backoff, et HPA K8s sur métrique custom (queue depth).
// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { Order } from './order.entity';
import { OutboxEvent } from './outbox.entity';
@Injectable()
export class OrdersService {
constructor(
private readonly dataSource: DataSource,
@InjectQueue('order-processing') private readonly queue: Queue,
) {}
async create(dto: CreateOrderDto, userId: string, idempotencyKey: string) {
// 1. Idempotency check (Redis-based interceptor handled it; here we trust the key)
// 2. Transaction: persist order + outbox event atomically
return this.dataSource.transaction(async (tx) => {
const existing = await tx.findOne(Order, { where: { idempotencyKey } });
if (existing) return existing;
const order = await tx.save(Order, {
userId,
idempotencyKey,
items: dto.items,
deliveryAddress: dto.deliveryAddress,
status: 'created',
createdAt: new Date(),
});
// Outbox: same transaction as the business write
await tx.save(OutboxEvent, {
aggregateType: 'Order',
aggregateId: order.id,
type: 'order.created',
payload: { orderId: order.id, userId, items: dto.items },
publishedAt: null,
});
return order;
});
}
}// src/outbox/outbox.publisher.ts — separate worker that drains outbox to Kafka
import { Injectable, OnModuleInit, OnApplicationShutdown, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { KafkaProducer } from '../infra/kafka.producer';
import { OutboxEvent } from './outbox.entity';
@Injectable()
export class OutboxPublisher implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(OutboxPublisher.name);
private timer?: NodeJS.Timeout;
private running = false;
constructor(private readonly dataSource: DataSource, private readonly kafka: KafkaProducer) {}
onModuleInit() {
this.timer = setInterval(() => this.drain().catch((e) => this.logger.error({ err: e })), 500);
}
async onApplicationShutdown() {
if (this.timer) clearInterval(this.timer);
while (this.running) await new Promise((r) => setTimeout(r, 50));
}
private async drain() {
if (this.running) return;
this.running = true;
try {
const batch = await this.dataSource.transaction(async (tx) =>
tx.find(OutboxEvent, {
where: { publishedAt: null },
take: 100,
lock: { mode: 'pessimistic_write', onLocked: 'skip_locked' as any },
}),
);
for (const ev of batch) {
await this.kafka.publish(`${ev.aggregateType.toLowerCase()}.events`, {
key: ev.aggregateId,
value: { type: ev.type, payload: ev.payload },
});
await this.dataSource.update(OutboxEvent, ev.id, { publishedAt: new Date() });
}
} finally {
this.running = false;
}
}
}// src/workers/order-processing.worker.ts
import { Worker } from 'bullmq';
import { context, propagation, trace } from '@opentelemetry/api';
const tracer = trace.getTracer('order-worker');
new Worker(
'order-processing',
async (job) => {
const parentCtx = propagation.extract(context.active(), job.data._otel ?? {});
return context.with(parentCtx, () =>
tracer.startActiveSpan('process-order', async (span) => {
span.setAttributes({ 'order.id': job.data.orderId });
try {
// Idempotent: jobId == orderId, BullMQ deduplicates
await dispatchToFleet(job.data.orderId);
await chargeUser(job.data.orderId);
await notifyCustomer(job.data.orderId);
span.end();
} catch (e) {
span.recordException(e as Error);
span.end();
throw e; // BullMQ will retry per the attempt config
}
}),
);
},
{
connection: { url: process.env.REDIS_URL! },
concurrency: 20,
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
);// src/orders/orders.controller.ts — enqueue with idempotent jobId
@Post()
@UseInterceptors(IdempotencyInterceptor)
async create(@Body() dto: CreateOrderDto, @Req() req: any) {
const order = await this.orders.create(dto, req.user.sub, req.headers['idempotency-key']);
await this.queue.add(
'process',
{ orderId: order.id, _otel: this.injectTrace() },
{
jobId: order.id, // natural idempotency
attempts: 5,
backoff: { type: 'exponential', delay: 2_000 }, // 2s, 4s, 8s, 16s, 32s
removeOnComplete: 1000,
removeOnFail: 5000,
},
);
return order;
}# k8s/hpa.yaml — scale on queue depth (custom metric)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: order-worker, namespace: lastmile }
spec:
scaleTargetRef: { kind: Deployment, name: order-worker, apiVersion: apps/v1 }
minReplicas: 3
maxReplicas: 50
metrics:
- type: Pods
pods:
metric: { name: bullmq_queue_depth }
target: { type: AverageValue, averageValue: '50' }
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies: [{ type: Pods, value: 5, periodSeconds: 30 }]
scaleDown:
stabilizationWindowSeconds: 300
policies: [{ type: Pods, value: 2, periodSeconds: 60 }]// src/metrics/queue-depth.exporter.ts — expose queue depth to Prometheus
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('bullmq');
meter.createObservableGauge('bullmq_queue_depth', {
description: 'Active + waiting jobs in BullMQ queue',
}).addCallback(async (result) => {
const waiting = await queue.getWaitingCount();
const active = await queue.getActiveCount();
result.observe(waiting + active, { queue: 'order-processing' });
});Effets concrets : sous pic (4K orders/min), le HPA déclenche un scale-up à 18 workers en < 1 minute. Aucun message perdu grâce à l'outbox (le commit DB et l'event Kafka sont atomiquement liés). Aucun double traitement grâce au jobId: order.id (BullMQ dédoublonne nativement) + idempotency key côté HTTP. Quand un downstream est down (API de paiement), les retries exponentiels lissent la charge — pas de thundering herd au retour. Soak test 4h : zéro fuite mémoire, queue stable, p99 worker < 850ms.
🤖 Scaler une charge LLM/agent depuis NestJS
Servir un agent IA est le pire cas de tout ce chapitre réuni : requêtes longues (10–120 s de streaming), chères (chaque token coûte de l'argent réel), non-déterministes, et qui appellent un downstream avec un rate limit dur (tokens/min). Tout ce qu'on a vu — stateless, idempotency, backpressure, queues, cost-guard — devient non-négociable. Voici comment un staff raisonne.
Le client LLM est un provider DI, jamais un new dans un champ
// llm.module.ts — un seul client SDK, configuré async, partagé, avec retries SDK
import { Module, Global } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
export const LLM = Symbol('LLM');
@Global()
@Module({
providers: [
{
provide: LLM,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 4, // retries SDK (429/5xx) avec backoff respectant Retry-After
timeout: 120_000, // un stream long est normal, ne coupe pas trop tôt
}),
},
],
exports: [LLM],
})
export class LlmModule {}Pourquoi DI et pas new Anthropic() dans un service : (1) un seul pool de connexions keep-alive partagé entre toutes les requêtes (sinon tu refais le TLS handshake à chaque appel) ; (2) testable — tu injectes un fake en test ; (3) la config (clé, base URL pour un proxy/Bedrock, timeout) vit au même endroit. Modèles courants : claude-opus-4-8 (flagship, raisonnement/agents), claude-sonnet-4-6 (équilibre coût/qualité, le défaut prod), claude-haiku-4-5 (rapide/pas cher, classification, routing). Choisis le plus petit modèle qui passe ta barre de qualité — c'est le levier de coût n°1 à l'échelle.
Streaming des tokens via SSE + annulation sur déconnexion client
Le piège qui coûte de l'argent : un client qui ferme l'onglet pendant que tu génères. Sans AbortController câblé à la déconnexion, tu continues à payer des tokens dans le vide. NestJS expose un Observable SSE ; on raccorde l'AbortSignal à req.on('close').
// chat.controller.ts
import { Controller, Post, Body, Req, Res, Inject } from '@nestjs/common';
import type { Request, Response } from 'express';
import Anthropic from '@anthropic-ai/sdk';
@Controller('chat')
export class ChatController {
constructor(@Inject(LLM) private readonly llm: Anthropic) {}
@Post('stream')
async stream(@Body() dto: ChatDto, @Req() req: Request, @Res() res: Response) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// 1. Annulation : si le client se déconnecte, on coupe l'appel LLM => stop facturation
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const stream = this.llm.messages.stream(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: dto.messages,
},
{ signal: ac.signal }, // <-- propage l'annulation au SDK
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
res.write(`event: token\ndata: ${JSON.stringify(event.delta.text)}\n\n`);
}
}
const final = await stream.finalMessage();
// observabilité : on logge le coût réel (tokens) pour chaque génération
res.write(`event: done\ndata: ${JSON.stringify({ usage: final.usage })}\n\n`);
} catch (e) {
if (!ac.signal.aborted) {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'generation_failed' })}\n\n`);
}
} finally {
res.end();
}
}
}Stateless rappel : le LB peut router deux requêtes du même user sur deux pods. L'historique de conversation ne vit pas en mémoire du pod — il vient du body ou de Redis/DB. Un SSE est sticky le temps d'une connexion (un seul pod), mais la prochaine requête peut atterrir ailleurs. C'est OK tant que rien d'irremplaçable n'est en RAM locale.
Le travail lourd va dans BullMQ — idempotence keyée sur un generationId
Streamer en direct, c'est pour le chat interactif. Mais « génère un rapport de 40 pages », « ré-indexe 10 000 docs en embeddings », « run l'agent sur ce ticket » → queue. Les contraintes spécifiques LLM :
- Idempotence forte : un retry BullMQ ne doit jamais relancer une génération déjà facturée. On clé le
jobIdsur ungenerationIdstable (UUID émis à la création), et on persiste le résultat partiel pour reprendre, pas tout refaire. - Retry cost-aware : un
400 invalid_request(prompt trop long) n'est pas rejouable — échec immédiat, pas de retry (sinon tu payes 5× un échec déterministe). Un429/529 overloaded/timeout réseau est rejouable avec backoff. Ne mets pasattempts: 5aveugle sur tout. - Backpressure = rate limit tokens/min : si tu lances 50 workers concurrents, tu exploses le rate limit Anthropic et tout le monde se prend des
429. La concurrence doit être bornée en fonction du budget tokens/min, pas du CPU.
// agent.worker.ts
import { Worker, UnrecoverableError } from 'bullmq';
import Anthropic from '@anthropic-ai/sdk';
new Worker(
'agent-jobs',
async (job) => {
const { generationId, prompt } = job.data;
// Idempotence : déjà produit ? on renvoie sans rappeler le LLM (ni payer).
const done = await store.get(generationId);
if (done) return done;
try {
const msg = await llm.messages.create({
model: 'claude-opus-4-8',
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
});
const out = { generationId, text: textOf(msg), usage: msg.usage };
await store.set(generationId, out); // persistance AVANT de rendre le job "done"
await costLedger.record(generationId, msg.usage); // observabilité coût
return out;
} catch (e: any) {
// Retry cost-aware : on ne rejoue QUE l'éphémère.
const status = e?.status;
const retryable = status === 429 || status === 529 || status >= 500 || e?.name === 'APIConnectionError';
if (!retryable) {
// 400/401/403 : déterministe -> on ne paye pas 5 fois un échec
throw new UnrecoverableError(`non_retryable_llm_error: ${status}`);
}
throw e; // BullMQ rejoue selon backoff exponentiel
}
},
{
connection: { url: process.env.REDIS_URL! },
concurrency: 8, // borné par le budget tokens/min, pas par le CPU
limiter: { max: 50, duration: 60_000 }, // ≤ 50 jobs/min = garde-fou rate limit
},
);
// enqueue
await agentQueue.add(
'run',
{ generationId, prompt },
{
jobId: generationId, // dédoublonnage natif : un double POST = un seul job
attempts: 4,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { count: 1000 },
},
);La boucle agentique (tool-use) côté serveur — où mettre les garde-fous
Un agent = boucle [appel LLM → le modèle demande un outil → tu exécutes l'outil → tu renvoies le résultat → re-appel] jusqu'à stop_reason !== 'tool_use'. À l'échelle, cette boucle est un risque de coût non borné : un modèle peut tourner en rond. Garde-fous staff : un plafond de tours, un budget tokens cumulé, un AbortSignal propagé du client jusqu'à chaque appel, et chaque outil derrière un timeout + rate limit.
async function runAgent(prompt: string, signal: AbortSignal) {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }];
const MAX_TURNS = 8; // garde-fou anti-boucle infinie
let tokenBudget = 50_000; // garde-fou coût
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (signal.aborted) throw new Error('client_aborted');
const res = await llm.messages.create(
{ model: 'claude-sonnet-4-6', max_tokens: 1024, tools, messages },
{ signal },
);
tokenBudget -= res.usage.input_tokens + res.usage.output_tokens;
if (tokenBudget < 0) throw new Error('token_budget_exceeded');
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') return res; // terminé
// Exécute les outils demandés (chacun timeout + rate-limit + try/catch)
const toolResults = await Promise.all(
res.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
.map(async (b) => ({
type: 'tool_result' as const,
tool_use_id: b.id,
content: await runToolSafely(b.name, b.input, signal),
// is_error: true si l'outil a échoué -> le modèle peut se rattraper
})),
);
messages.push({ role: 'user', content: toolResults });
}
throw new Error('max_turns_exceeded');
}À mettre au bord (edge), avant même de toucher le LLM
| Garde-fou | Pourquoi | Où |
|---|---|---|
| Rate limit par user (token bucket Redis) | Un user ne doit pas pouvoir vider ton budget API | Guard/interceptor en amont |
| Cost-guard (budget tokens/jour par tenant) | Coupe avant facturation, pas après | Avant l'enqueue, lu depuis Redis |
| Idempotency-Key sur le POST de génération | Un double-clic ≠ double facturation | IdempotencyInterceptor (ci-dessus) |
| Timeout serveur > timeout SDK | Un stream zombie tient un pod | req.on('close') + AbortController |
| MCP / endpoint agent versionné | Exposer tes tools à d'autres agents proprement | Controller dédié, schéma stable |
Le fil rouge : un LLM, c'est un downstream lent, cher et à rate limit. Tu le traites exactement comme l'API de paiement du scénario banque — idempotence, retries cost-aware, backpressure bornée, observabilité du coût — et tout le reste du chapitre s'applique tel quel.
🔁 Quand utiliser / éviter
- Stateless : toujours par défaut. Exception justifiée seulement pour les websockets (sticky temporaire).
- Idempotency keys : toutes les mutations critiques (paiement, création resource). Optionnel pour les
DELETE(déjà idempotents par sémantique HTTP). - Distributed locks : dernière option. Cherche d'abord transaction DB, optimistic lock, ou décomposition en étapes idempotentes.
- Queues + workers : dès qu'une opération > 1s ou peut échouer. Email, image processing, integration tiers.
- Évite la prématurée distribution — un mono bien tuné scale à 10k req/s. Pas besoin de microservices à 100 req/s.
- Évite eventual consistency quand strong consistency est faisable et que les besoins le requièrent (finance, inventory). EC = trade-off, pas un dogme.
Horizontal scaling — capacity planning
Une fois stateless + idempotent, tu peux scaler. Méthode :
- Bench une instance — quelle est sa capacité (req/s soutenu avec p95 acceptable) ?
- Identifier le bottleneck — CPU, mem, DB connections, downstream API rate limit. Sans ça, scaler horizontalement saturera juste autre chose.
- Calculer le headroom — 30–50% en plus du peak observé. Si pic à 200 req/s par instance et tu vises 1000 req/s total, tu auras besoin de 7–8 instances (1000 / (200×0.7)).
- Auto-scaling (HPA K8s) — sur CPU, mem, ou métrique custom (queue depth, p95 latency).
- DB pool sizing — chaque instance ouvre N connections. 10 instances × 20 pool = 200 connections. Postgres recommandé < 100 ; utilise PgBouncer en transaction pooling pour multiplexer.
Exemple HPA :
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef: { kind: Deployment, name: orders-api, apiVersion: apps/v1 }
minReplicas: 3
maxReplicas: 30
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
- type: Pods
pods:
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: '150' }Sticky sessions — quand c'est inévitable
WebSockets sans state externalisé : le client est connecté à une instance précise. Trois options :
- Sticky load balancing — cookie ou IP hash. Marche, mais limite le rebalancing au scale.
- Pub/sub backplane — chaque instance subscribe à Redis pub/sub. Quand instance A doit pousser à un client connecté sur B, elle publie ; B reçoit, route au client. Pattern utilisé par socket.io adapters.
- Dedicated WS service — séparé de l'API REST. Le WS service ne fait que router, l'API publie sur le broker.
Pour Server-Sent Events (SSE), même problème, mêmes solutions.
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.
Exercice 1 — Idempotency à 3 états (implémenter)
Objectif : écrire un IdempotencyInterceptor qui distingue in_flight, completed, absent et mémorise aussi les erreurs déterministes (4xx) mais jamais les 5xx.
Indice/Solution : clé idem:${user}:${route}:${sha256(body)}. SET … NX EX pour poser le lock atomiquement → si échec et pas de réponse mémorisée, renvoie 409 in_progress. À la complétion, stocke {status, body}. Dans catchError, ne mémorise que si status < 500 ; relâche toujours le lock (cf. le mergeMap/throwError du fix RxJS plus haut). Test : deux requêtes concurrentes même clé → une 201, une 409 ; un retry après succès → 201 identique sans re-exécuter le handler (assert via un spy d'appel).
Exercice 2 — Outbox sans perte ni double publish (production-grade)
Objectif : transformer le OutboxPublisher en garantie exactly-effectively-once : zéro perte même si le pod meurt entre le publish Kafka et l'update publishedAt.
Indice/Solution : Kafka redélivre → tu auras des doublons côté publish. Rends le consumer idempotent (clé = aggregateId + version/eventId), ou active l'idempotent producer Kafka + transactions. Utilise FOR UPDATE SKIP LOCKED (déjà dans l'exemple) pour que N publishers se partagent l'outbox sans se marcher dessus. Casse-le exprès : tue le pod juste après kafka.publish mais avant l'update → relance → vérifie que le consumer ne traite l'event qu'une fois. Bonus : ajoute une colonne attempts + alerte si un event reste non publié > 5 min (broker down).
Exercice 3 — Saturer puis dimensionner (capacity planning)
Objectif : trouver le plafond réel d'une instance avec le test k6, puis calculer combien de pods pour 1 000 req/s soutenus à p95 < 300 ms, et configurer le HPA.
Indice/Solution : ramp-up jusqu'à ce que p95 explose — note la req/s à ce coude (= capacité par instance × 0.7 headroom). pods = ceil(1000 / (capacité × 0.7)). Piège : à 10 pods × pool 20 = 200 connexions Postgres > limite recommandée → ajoute PgBouncer en transaction pooling. Vérifie que le bottleneck n'est pas le pool DB en regardant l'event-loop lag et pg_stat_activity.
Exercice 4 — Casser le lock distribué, puis le réparer (break-then-fix)
Objectif : reproduire la double écriture sous GC pause que Kleppmann décrit, puis la corriger avec un fencing token.
Indice/Solution : simule la pause en mettant un await sleep(TTL + 1s) entre acquire et l'écriture dans le process A pendant que B prend le lock. Observe les deux écritures. Fix : le acquire renvoie un compteur monotone (INCR fence:${resource}) ; la ressource (une ligne avec last_fence) rejette toute écriture dont le token ≤ last_fence via un UPDATE … WHERE last_fence < :token. Conclusion à internaliser : sans coopération de la ressource, aucun lock à TTL n'est sûr.
Exercice 5 — Agent LLM cost-bounded (intégration stack)
Objectif : implémenter la boucle agentique de ce chapitre avec un plafond de tours, un budget tokens, un AbortController câblé à la déconnexion SSE, et un retry cost-aware dans le worker BullMQ.
Indice/Solution : MAX_TURNS = 8, tokenBudget décrémenté via res.usage. Câble req.on('close') → ac.abort() et propage { signal } à chaque messages.create. Dans le worker, mappe 400/401/403 → UnrecoverableError (pas de retry), 429/529/5xx/APIConnectionError → re-throw (retry backoff). Casse-le : déconnecte le client mid-stream et vérifie dans le cost-ledger que la facturation s'arrête (pas de tokens après l'abort). Bonus : ajoute un limiter: { max, duration } BullMQ et prouve que tu ne dépasses jamais le rate limit tokens/min sous 100 jobs simultanés.
Exercice 6 — Read-your-writes sous read replica (failure mode, hard)
Objectif : provoquer le bug « POST puis GET renvoie l'ancien état » avec un read replica laggy, puis le corriger sans router tous les GET vers le primary.
Indice/Solution : route les GET vers le replica, ajoute un lag artificiel. Le user crée une resource puis la liste → absente (lag). Trois fixes à comparer : (a) router vers le primary seulement pendant N secondes après une écriture de ce user (sticky-to-primary par user, stocké en Redis avec TTL) ; (b) renvoyer la resource créée directement dans la réponse du POST (le client n'a pas besoin de re-GET) ; (c) LSN/pg_wait_lsn — le GET attend que le replica ait rattrapé la position d'écriture. Discute le trade-off latence vs cohérence de chacun.
🎤 En entretien
« Ton service est stateless mais tu as quand même besoin de WebSockets. Comment scales-tu ? » → Le state de connexion est intrinsèquement local au pod tenant la socket ; on l'accepte (sticky le temps de la connexion) mais on externalise le routing via un backplane pub/sub Redis : tout pod peut publier un message vers n'importe quel client connecté ailleurs. La logique métier, elle, reste stateless. Pour scaler vraiment, on isole un WS service dédié qui ne fait que router, l'API REST publiant sur le broker.
« Idempotency key vs déduplication par
jobIdBullMQ — c'est la même chose ? » → Non, deux couches complémentaires. L'idempotency key protège la frontière HTTP (un retry réseau / double-clic client ne crée pas deux commandes) et renvoie la même réponse. LejobIdprotège le traitement asynchrone (deux enqueues du même travail = un seul job exécuté). On a besoin des deux : la key empêche la création en double, lejobIdempêche le traitement en double même si la création a légitimement produit un seul ordre ré-enfilé par un retry de publish.« Pourquoi Redlock ne suffit-il pas pour protéger un virement bancaire ? » → Parce qu'un lock à TTL n'arbitre pas les écritures au niveau de la ressource : une GC pause ou un délai réseau peut faire expirer le TTL pendant qu'un process se croit toujours détenteur → double écriture. La parade correcte est le fencing token (compteur monotone que la ressource vérifie), et sur de l'argent on double avec une transaction DB
SELECT FOR UPDATEqui, elle, est l'arbitre transactionnel réel. Redlock seul est best-effort, pas une garantie d'invariant.« Tu sers un LLM à 10 000 users. Quel est ton premier risque opérationnel et comment tu le bornes ? » → Le coût non borné couplé au rate limit tokens/min du provider. Trois garde-fous : (1) cost-guard par tenant avant l'appel (budget tokens/jour en Redis), (2) annulation câblée à la déconnexion client (
AbortController→ on arrête de payer dès que le client part), (3) concurrence des workers bornée par le budget tokens/min (unlimiterBullMQ), pas par le CPU — sinon on s'auto-DDoS en429. Et retries cost-aware : on ne rejoue jamais un400déterministe.
🔗 Liens
- BullMQ docs
- Stripe — Idempotency Keys
- Martin Kleppmann — How to do distributed locking
- PgBouncer — connection pooler Postgres
- Livre : Designing Data-Intensive Applications — Martin Kleppmann
- Temporal.io — workflows distribués
- k6 / Artillery / Locust
- Article : "Transactional outbox pattern" — microservices.io