Pourquoi ton DLQ Kafka finit en cimetière (et pourquoi c’est ton problème)
Dans un système event-driven, les échecs ne sont pas une exception. Ils sont statistiquement garantis. API de “hydration” en panne, timeouts, consumer qui crash au mauvais moment, payload incomplet, schéma qui drift… tout ça arrive, même quand tu as “bien fait les choses”.
Le réflexe classique : un topic Kafka “DLQ”. Sauf qu’en pratique, beaucoup d’équipes transforment ce DLQ en fosse commune. Les messages s’y empilent, personne ne sait répondre à des questions basiques (“qu’est-ce qui a cassé hier ?”, “combien d’échecs par type d’erreur ?”), et rejouer proprement un sous-ensemble devient un mini-projet.
C’est exactement le point soulevé dans le retour d’expérience publié fin 2025 sur un pipeline de reporting chez Wayfair : Kafka est excellent pour transporter des événements, mais une fois en DLQ, l’inspection et le tri deviennent pénibles sans tooling additionnel. Leur choix : utiliser PostgreSQL comme DLQ (CloudSQL sur GCP), en stockant les événements en échec dans une table dédiée, avec contexte et statut, pour rendre l’échec observable et actionnable (source : diljitpr.net).
DLQ : définition utile (pas la version PowerPoint)
Un Dead Letter Queue sert à isoler les messages qui n’arrivent pas à être traités correctement après une ou plusieurs tentatives. L’objectif n’est pas “d’éviter les erreurs”. L’objectif c’est :
- Ne pas bloquer le flux (ton consumer continue à traiter le reste).
- Ne pas perdre l’info (payload + contexte).
- Pouvoir diagnostiquer (requêtes, dashboards, audit).
- Pouvoir rejouer (ciblé, contrôlé, mesurable).
Selon une étude 2025 sur la maturité EDA, 94 % des implémentations matures utilisent une DLQ, et 82 % rejouent les événements après correction de la cause racine. En régime normal, la DLQ représente 0,01 % à 0,05 % du volume, mais peut monter à 0,1 % à 0,3 % pendant une perturbation (panne, déploiement foireux, dépendance lente). (source : ResearchGate, Event-Driven Architecture: The Backbone of Real-Time Enterprise Integration, 2025)
Traduction business : si tu n’as pas une DLQ exploitable, tu vas perdre du temps, de l’argent, et de la confiance.
Pourquoi PostgreSQL est un bon DLQ (quand tu veux du concret)
L’idée est simple : au lieu d’envoyer les messages en échec dans un topic DLQ, tu les persistes dans Postgres.
1) Visibilité immédiate
Avec Postgres, ton DLQ est SQL-native :
- “Top 10 des erreurs depuis 24h”
- “Tous les events d’un customer_id qui ont échoué”
- “Échecs uniquement sur le type
OrderCreated” - “Échecs après le déploiement X”
Tu passes d’un flux opaque à une base interrogable.
2) Durabilité + audit
Postgres te donne la durabilité ACID, des contraintes, du versioning de schéma, et un audit facile. Pour des systèmes qui alimentent des rapports financiers / opérationnels, c’est un énorme plus.
3) Moins d’infra, moins de bullshit
Si tu as déjà Postgres (c’est le cas de 90 % des SaaS), tu ajoutes une table et deux index, pas un nouveau cluster, pas un nouveau provider, pas une nouvelle surface d’attaque.
4) Reprocessing contrôlé avec FOR UPDATE SKIP LOCKED
Le pattern que Wayfair met en avant (et qui marche très bien) : plusieurs workers peuvent récupérer des messages en échec sans se marcher dessus via :
SELECT id, payload
FROM dlq_events
WHERE status = 'PENDING'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;SKIP LOCKED évite que deux workers prennent la même ligne, sans orchestration externe.
Le design d’une table DLQ qui ne te sabotera pas
Voici un schéma pragmatique (à adapter) :
CREATE TYPE dlq_status AS ENUM ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'GAVE_UP');
CREATE TABLE dlq_events (
id BIGSERIAL PRIMARY KEY,
event_key TEXT, -- idempotency key (si tu en as)
event_type TEXT NOT NULL,
source TEXT, -- consumer/service
payload JSONB NOT NULL,
error_code TEXT,
error_message TEXT,
error_stack TEXT,
status dlq_status NOT NULL DEFAULT 'PENDING',
attempts INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX dlq_events_status_retry_idx
ON dlq_events (status, next_retry_at, created_at);
CREATE INDEX dlq_events_type_idx
ON dlq_events (event_type, created_at);
CREATE INDEX dlq_events_payload_gin
ON dlq_events USING GIN (payload);Champs qui font vraiment la différence
- event_key : si tu veux rejouer sans créer de doublons, c’est ton filet de sécurité (idempotence côté “write”).
- attempts + next_retry_at : tu implémentes un backoff et tu évites le retry en boucle.
- payload JSONB + index GIN : tu peux filtrer sur un champ métier sans ETL.
Le workflow qui marche en prod
1) Échec détecté → insert en DLQ
Tu captures : payload brut + contexte + cause.
- API downstream down →
error_code = DOWNSTREAM_TIMEOUT - schéma invalide →
error_code = VALIDATION_ERROR
2) Reprocessor (cron / worker) → lock + processing
Pseudo-flow :
SELECT ... FOR UPDATE SKIP LOCKEDsur lesPENDINGdontnext_retry_at <= now()UPDATE status='PROCESSING', attempts=attempts+1- tente le traitement
- succès →
SUCCEEDED - échec →
PENDINGavecnext_retry_at = now() + backoff - au-delà de N tentatives →
GAVE_UP(et alerte)
3) Observabilité : tu alertes sur le temps passé en DLQ
Le KPI utile n’est pas “nombre de messages en DLQ”. C’est :
- âge max des PENDING
- taux d’entrée DLQ (par type d’événement)
- taux de succès au reprocess
Si un message reste PENDING 2h alors que ton SLA est 10 min : alerte.
Quand PostgreSQL DLQ est un excellent choix (et quand c’est une mauvaise idée)
Excellent si :
- tu as un débit modéré à élevé, mais pas du “centaines de milliers d’events/sec”
- tu veux diagnostic + replay ciblé sans tooling Kafka custom
- tu as déjà Postgres en datastore durable (cas Wayfair)
- ton équipe veut réduire la complexité infra
Mauvaise idée si :
- tu as un DLQ qui peut contenir des millions de lignes longtemps sans purge : tu vas souffrir (bloat, index, VACUUM)
- tu veux du multi-région “global active-active” pour la DLQ : Postgres n’est pas Kafka
- tu as besoin d’un système de queue complet (consumer groups massifs, retention streaming, etc.)
La communauté le résume bien : FOR UPDATE SKIP LOCKED est solide pour small-to-medium workloads, mais si la table grossit trop, il faut partitionner et nettoyer régulièrement (retours Reddit cités dans la recherche web).
Les patterns anti-emmerdes : partitionnement, rétention, et “bloat hygiene”
1) Partitionner par date (ou par statut)
Si tu gardes les DLQ 30 jours, partitionne par semaine/mois :
- requêtes plus rapides
- purge simple (
DROP PARTITION)
2) Rétention agressive + archivage
Garde en base ce qui est actionnable. Le reste : export S3/GCS (par jour) et basta.
3) Indexer pour tes requêtes réelles
Ne mets pas 12 index “au cas où”. Mesure tes requêtes :
status + next_retry_at + created_atpour le reprocessevent_type + created_atpour l’analyse- GIN JSONB seulement si tu l’utilises
Et si tu veux aller plus loin : PGMQ, Queen, et l’approche hybride
Il y a une tendance claire fin 2025–début 2026 : hybrider.
- Kafka/SQS/RabbitMQ pour le transport à haut débit
- Postgres pour la durabilité, l’audit, et la gestion “opérationnelle” des échecs
Des projets comme Queen (queue open-source sur Postgres) poussent l’idée plus loin : consumer groups, replay, exactly-once, DLQ intégrée. Et côté écosystème Postgres, PGMQ (popularisée via Supabase) montre que beaucoup d’équipes préfèrent “un outil en moins” quand le besoin est pragmatique.
Le bon réflexe : ne pas être dogmatique. Tu gardes Kafka pour ce qu’il fait le mieux, et tu utilises Postgres pour ce qu’il fait le mieux.
Checklist actionnable (tu peux l’implémenter cette semaine)
- Crée une table DLQ avec
status,attempts,next_retry_at,payload JSONB. - Écris un reprocessor (worker) avec
FOR UPDATE SKIP LOCKED. - Ajoute l’idempotence côté write (event_key / unique constraint si possible).
- Mets des alertes sur l’âge max des PENDING + taux d’entrée.
- Planifie la rétention (partition + purge) dès le jour 1.
Si tu fais ça, ton DLQ arrête d’être un cimetière. Il devient un outil de contrôle qualité sur ton système distribué.
Tu veux automatiser tes opérations avec l'IA ? Réserve un call de 15 min pour en discuter.
