Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Postgres comme queue : jusqu'où ça tient avant de devoir passer à un broker dédié

  • archi

SKIP LOCKED transforme Postgres en queue convenable. Nous l'avons poussé jusqu'à 50 000 jobs/heure sur un seul nœud. Voici les limites concrètes qu'on a rencontrées.

Postgres comme queue : jusqu'où ça tient avant de devoir passer à un broker dédié

Avant d'ajouter Redis, SQS ou RabbitMQ à votre stack, posez-vous la question : est-ce que Postgres ne suffit pas ? Sur la majorité des projets que nous voyons, la réponse est oui. Voici les conditions dans lesquelles cette réponse devient non.

Pourquoi Postgres comme queue fonctionne

La fondation est le SELECT ... FOR UPDATE SKIP LOCKED, disponible depuis Postgres 9.5. Cette clause permet à plusieurs workers de dépiler des jobs en parallèle sans conflit : chaque worker verrouille atomiquement une ligne sans bloquer les autres.

-- Structure minimale d'une table de jobs
CREATE TABLE jobs (
  id          bigserial PRIMARY KEY,
  queue       text NOT NULL DEFAULT 'default',
  payload     jsonb NOT NULL,
  status      text NOT NULL DEFAULT 'pending',
  attempts    int NOT NULL DEFAULT 0,
  max_attempts int NOT NULL DEFAULT 3,
  run_at      timestamptz NOT NULL DEFAULT now(),
  created_at  timestamptz NOT NULL DEFAULT now(),
  locked_at   timestamptz,
  locked_by   text,
  failed_at   timestamptz,
  error       text
);

CREATE INDEX ON jobs (queue, status, run_at)
  WHERE status = 'pending';

Un worker récupère et verrouille un job en une seule transaction :

WITH next_job AS (
  SELECT id FROM jobs
  WHERE queue = 'default'
    AND status = 'pending'
    AND run_at <= now()
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET
  status    = 'locked',
  locked_at = now(),
  locked_by = $1  -- identifiant du worker
WHERE id = (SELECT id FROM next_job)
RETURNING *;

Si le worker crashe avant de terminer, le job reste en statut locked. Il faut un processus de récupération qui libère les jobs dont locked_at est trop ancien :

UPDATE jobs
SET status = 'pending', locked_at = NULL, locked_by = NULL
WHERE status = 'locked'
  AND locked_at < now() - interval '5 minutes';

Ce pattern est simple, debuggable avec psql, et transactionnel : on peut enqueuer un job dans la même transaction que la modification de données qui le déclenche, ce qui est impossible avec une queue externe.

Utiliser pg-boss plutôt qu'implémenter soi-même

Implémenter ce pattern from scratch est instructif mais laborieux. pg-boss encapsule tout ça avec une API propre en TypeScript.

import PgBoss from "pg-boss";

const boss = new PgBoss(process.env.DATABASE_URL!);
await boss.start();

// Enqueue
await boss.send("send-email", {
  to: "[email protected]",
  subject: "Bienvenue",
});

// Worker
await boss.work("send-email", async ([job]) => {
  await sendEmail(job.data);
  // Si pas d'exception, le job est marqué completed
});

pg-boss gère le SKIP LOCKED, les retries avec backoff exponentiel, les dead-letter queues, et un tableau de bord minimal. Il maintient sa propre table pgboss.job avec un schéma complet incluant la priorité, le throttling, et l'expiration.

En production, nous l'avons utilisé sur un projet SaaS avec un volume de 30 000 à 80 000 jobs par heure (envoi d'emails, webhooks sortants, génération de rapports PDF). Sur un Postgres standard db.r7g.large (2 vCPU, 16 Go), le CPU de la base n'a jamais dépassé 40 %.

Les chiffres : où ça commence à peiner

Nos mesures sur un Postgres 16, db.r7g.xlarge (4 vCPU, 32 Go), SSD gp3 3000 IOPS :

  • 50 000 jobs/heure avec 10 workers concurrents : stable, latence p99 < 500 ms
  • 150 000 jobs/heure avec 20 workers : CPU à 60-70 %, latence p99 monte à 2-3 secondes
  • 300 000 jobs/heure : dégradation notable, contention sur les locks même avec SKIP LOCKED

La contention sur les locks n'est pas causée par SKIP LOCKED lui-même, mais par l'index partiel sur (queue, status, run_at). Avec un volume élevé, l'index est mis à jour très fréquemment (chaque transition de statut écrit dans l'index), ce qui génère de la contention WAL.

Une optimisation utile : partitionner la table jobs par statut ou par queue, et archiver régulièrement les jobs completed vers une table d'archive. Une table de jobs qui grossit indéfiniment dégrade les performances des scans indexés.

-- Archivage quotidien des jobs terminés de plus de 7 jours
INSERT INTO jobs_archive
SELECT * FROM jobs
WHERE status IN ('completed', 'failed')
  AND created_at < now() - interval '7 days';

DELETE FROM jobs
WHERE status IN ('completed', 'failed')
  AND created_at < now() - interval '7 days';

Quand passer à un broker dédié

Voici les signaux qui indiquent que Postgres ne suffit plus.

Volume > 500 000 messages/heure avec latence stricte

Redis Streams ou SQS traitent plusieurs millions de messages par heure avec des latences sub-milliseconde. Si votre SLA exige une latence de traitement < 100 ms et un volume élevé, Postgres ne peut pas rivaliser.

Fan-out vers de nombreux consumers

Postgres ne supporte pas le pattern pub/sub à la Redis ou le fan-out vers de multiples queues depuis un message unique sans logique applicative. Si un événement doit déclencher simultanément 20 types de traitements différents, un broker avec du routing (RabbitMQ, Kafka) est plus naturel.

Isolation du workload critique

Si vos jobs consomment des ressources significatives (connexions, CPU, I/O) et que cette consommation risque d'affecter vos queries applicatives, l'isolation physique d'un broker dédié protège votre base principale. Nous avons eu un incident où un burst de génération de rapports PDF a saturé le connection pool Postgres et dégradé l'API principale.

Consumers non-TypeScript/non-Postgres

Si des consumers sont écrits en Python, Go, ou consomment depuis des systèmes qui n'ont pas accès à Postgres, une queue externe avec un protocole standard (AMQP, SQS API) simplifie l'intégration.

Ce qu'on perdrait en passant à Redis ou SQS

La transactionnalité est la perte principale. Avec Postgres, on peut enqueuer un job dans la même transaction que la création d'une commande :

await db.transaction(async (tx) => {
  const order = await tx.insert(orders).values(orderData).returning();
  // Ce job ne sera visible dans la queue que si la transaction commit
  await boss.sendWithConnection(tx, "process-order", { orderId: order[0].id });
});

Avec une queue externe, il faut gérer soi-même la cohérence entre la base et la queue. Si l'application crashe entre le commit en base et l'enqueue, le job est perdu. On peut mitiger ça avec un pattern outbox (écrire dans une table Postgres, puis un processus lit cette table et enqueue dans la queue externe), mais c'est de la complexité supplémentaire.

Pour la plupart des équipes, Postgres comme queue tient jusqu'à des volumes que vous n'atteindrez peut-être jamais. La vraie question n'est pas le volume brut mais l'isolation du workload et les patterns de consommation. Avez-vous déjà mesuré le throughput réel de votre Postgres avant d'ajouter Redis ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Postgres comme queue : limites et alternatives | Apogée Consult