Apprenez le pattern cron + base de données pour exécuter des tâches planifiées avec retries, verrouillage et idempotence — sans déployer un système de queue complet.

La plupart des applications ont besoin que du travail s'exécute plus tard ou selon un planning : envoi d'emails de relance, vérification de facturation nocturne, nettoyage d'anciennes entrées, reconstruction d'un rapport ou rafraîchissement d'un cache.
Au début, il est tentant d'ajouter un système de queue complet parce que ça semble être la « bonne » façon de gérer les tâches en arrière-plan. Mais les queues ajoutent des pièces mobiles : un service supplémentaire à lancer, surveiller, déployer et déboguer. Pour une petite équipe (ou un fondateur solo), ce poids supplémentaire peut vous ralentir.
La vraie question est donc : comment exécuter du travail planifié de façon fiable sans déployer plus d'infrastructure ?
Une première tentative courante est simple : ajouter une entrée cron qui frappe un endpoint, et faire faire le travail à cet endpoint. Ça marche… jusqu'à ce que ça ne marche plus. Dès que vous avez plus d'un serveur, un déploiement au mauvais moment, ou une tâche qui prend plus de temps que prévu, vous commencez à voir des échecs confus.
Le travail planifié casse généralement de quelques façons prévisibles :
Le pattern cron + base de données est un compromis. Vous utilisez toujours cron pour « réveiller » le système selon un planning, mais vous stockez l'intention et l'état des tâches dans la base de données afin que le système puisse coordonner, réessayer et enregistrer ce qui s'est passé.
C'est adapté quand vous avez déjà une seule base de données (souvent PostgreSQL), un petit nombre de types de tâches, et que vous voulez un comportement prévisible avec un minimum d'opérations. C'est aussi un choix naturel pour des apps construites rapidement sur des stacks modernes (par exemple, React + Go + PostgreSQL).
Ce n'est pas adapté si vous avez besoin d'un très haut débit, de tâches longues nécessitant un streaming de progression, d'un ordre strict entre de nombreux types de tâches, ou d'un fort fan-out (des milliers de sous-tâches par minute). Dans ces cas, une vraie queue et des workers dédiés finissent généralement par payer.
Le pattern cron + base de données exécute du travail en arrière-plan selon un planning sans déployer un système de queue complet. Vous utilisez encore cron (ou un scheduler), mais cron ne décide pas quoi exécuter. Il se contente de réveiller un worker fréquemment (une fois par minute est courant). La base de données décide du travail dû et s'assure qu'un seul worker prend chaque job.
Pensez-y comme une checklist partagée sur un tableau blanc. Cron est la personne qui entre dans la pièce chaque minute et demande « Qui doit faire quelque chose maintenant ? ». La base de données est le tableau blanc qui montre ce qui est dû, ce qui est pris et ce qui est fait.
Les éléments sont simples :
Exemple : vous voulez envoyer des relances de factures chaque matin, rafraîchir un cache toutes les 10 minutes, et nettoyer d'anciennes sessions chaque nuit. Au lieu de trois commandes cron séparées (chacune ayant ses propres modes d'échec et chevauchement), vous stockez les entrées de job en un seul endroit. Cron démarre le même process worker. Le worker demande à Postgres « Qu'est-ce qui est dû maintenant ? » et Postgres répond en laissant le worker réclamer en toute sécurité exactement un job à la fois.
Cela évolue progressivement. Vous pouvez commencer avec un worker sur un seul serveur. Plus tard, exécuter cinq workers sur plusieurs serveurs. Le contrat reste le même : la table est le contrat.
Le changement de mentalité est simple : cron n'est que l'appel de réveil. La base de données est le policier de la circulation qui décide de ce qui peut s'exécuter, enregistre ce qui s'est passé et vous donne un historique clair quand quelque chose tourne mal.
Ce pattern fonctionne mieux quand votre base de données devient la source de vérité pour ce qui doit s'exécuter, quand cela doit s'exécuter et ce qui s'est passé la dernière fois. Le schéma n'est pas sophistiqué, mais des petits détails (champs de verrou et bons index) font une grande différence à mesure que la charge augmente.
Deux approches courantes :
Si vous prévoyez de déboguer souvent des échecs, gardez l'historique. Si vous voulez la configuration la plus petite possible, commencez par une table et ajoutez l'historique plus tard.
Voici une disposition adaptée à PostgreSQL. Si vous construisez en Go avec PostgreSQL, ces colonnes se mappent proprement à des structs.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Quelques détails qui évitent des douleurs plus tard :
send_invoice_emails).jsonb pour pouvoir l'évoluer sans migrations.Sans index, les workers finissent par balayer trop de lignes. Commencez par :
(status, run_at)(locked_until)queued et failed)Cela garde la requête « trouver le prochain job exécutable » rapide même quand la table grossit.
Le but est simple : de nombreux workers peuvent tourner, mais un seul doit prendre un job spécifique. Si deux workers traitent la même ligne, vous obtenez des emails en double, des facturations en double, ou des données incohérentes.
Une approche sûre est de traiter la réclamation d'un job comme un « bail » (lease). Le worker marque le job comme verrouillé pour une courte fenêtre. Si le worker plante, le bail expire et un autre worker peut le reprendre. C'est l'objet de locked_until.
Sans bail, un worker peut verrouiller un job et ne jamais le déverrouiller (process tué, reboot du serveur, déploiement raté). Avec locked_until, le job redevient disponible quand le temps est écoulé.
Une règle typique : un job peut être réclamé quand locked_until est NULL ou locked_until <= now().
Le détail clé est de réclamer le job en une seule instruction (ou une seule transaction). Vous voulez que la base de données soit l'arbitre.
Voici un pattern courant pour PostgreSQL : choisir un job dû, le verrouiller et le retourner au worker. (Cet exemple utilise une seule table jobs; la même idée s'applique si vous réclamez depuis job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at \u003c= now()
AND (locked_until IS NULL OR locked_until \u003c= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Pourquoi ça marche :
FOR UPDATE SKIP LOCKED permet à plusieurs workers de se concurrencer sans se bloquer mutuellement.RETURNING remet la ligne au worker qui a gagné la course.Fixez le bail plus long qu'une exécution normale, mais suffisamment court pour qu'un crash récupère vite. Si la plupart des jobs finissent en 10 secondes, un bail de 2 minutes suffit.
Pour les tâches longues, renouvelez le bail pendant l'exécution (heartbeat). Une approche simple : toutes les 30 secondes, étendez locked_until si vous possédez toujours le job.
WHERE id = $job_id AND locked_by = $worker_idCette dernière condition est importante. Elle empêche un worker d'étendre le bail d'un job qu'il ne possède plus.
Les retries sont l'endroit où ce pattern paraît calme ou devient un bazar bruyant. Le but est simple : quand un job échoue, réessayez plus tard de manière explicable, mesurable et arrêtée.
Commencez par rendre l'état du job explicite et fini : queued, running, succeeded, failed, dead. En pratique, la plupart des équipes utilisent failed pour « échoué mais sera réessayé » et dead pour « échoué et on a abandonné ». Cette distinction évite les boucles infinies.
Le comptage des tentatives est le deuxième garde-fou. Stockez attempts (nombre de tentatives) et max_attempts (nombre maximum autorisé). Quand un worker attrape une erreur, il doit :
attemptsfailed si attempts < max_attempts, sinon à deadrun_at pour la prochaine tentative (uniquement pour failed)Le backoff n'est que la règle qui décide du prochain run_at. Choisissez-en une, documentez-la et gardez-la cohérente :
Le jitter compte quand une dépendance tombe et revient. Sans lui, des centaines de jobs peuvent retenter en même temps et échouer à nouveau.
Stockez assez d'informations d'erreur pour rendre les échecs visibles et débogables. Vous n'avez pas besoin d'un système de logging complet, mais les bases suivantes sont nécessaires :
last_error (message court, sûr à afficher dans un écran admin)error_code ou error_type (aide au regroupement)failed_at et next_run_atlast_stack (si vous contrôlez la taille)Une règle concrète qui fonctionne bien : marquer dead après 10 tentatives, et utiliser un backoff exponentiel avec jitter. Cela permet aux échecs transitoires de se réessayer, mais empêche les jobs cassés de consommer indéfiniment du CPU.
Idempotence signifie que votre job peut s'exécuter deux fois et produire le même résultat final. Dans ce pattern, c'est important car la même ligne peut être reprise après un crash, un timeout ou un retry. Si votre job est « envoyer un email de facture », l'exécuter deux fois n'est pas sans conséquence.
Une façon pratique de raisonner : scindez chaque job en (1) faire le travail et (2) appliquer un effet. Vous voulez que l'effet arrive une fois, même si le travail est tenté plusieurs fois.
Une clé d'idempotence doit venir de ce que représente le job, pas de la tentative du worker. De bonnes clés sont stables et faciles à expliquer, comme invoice_id, user_id + day, ou report_name + report_date. Si deux tentatives se réfèrent au même événement réel, elles doivent partager la même clé.
Exemple : « Générer le rapport des ventes du 2026-01-14 » peut utiliser sales_report:2026-01-14. « Prélèver la facture 812 » peut utiliser invoice_charge:812.
La garde la plus simple est de laisser PostgreSQL rejeter les doublons. Stockez la clé d'idempotence quelque part indexable, puis ajoutez une contrainte unique.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Cela empêche deux lignes avec la même clé d'exister en même temps. Si votre design permet plusieurs lignes (pour l'historique), mettez l'unicité sur une table d'« effets » à la place, comme sent_emails(idempotency_key) ou payments(idempotency_key).
Effets secondaires courants à protéger :
sent_emails avec une clé unique avant d'envoyer, ou enregistrez un id de message du fournisseur une fois envoyé.delivered_webhooks(event_id) et sautez si ça existe.file_generated lié à (type, date).Si vous construisez sur une stack avec Postgres (par exemple, backend Go + PostgreSQL), ces vérifications d'unicité sont rapides et faciles à garder proches des données. L'idée centrale est simple : les retries sont normaux, les doublons sont optionnels.
Choisissez un runtime basique et tenez-vous-y. Le but du pattern cron + base de données est de réduire les pièces mobiles, donc un petit process en Go, Node ou Python qui parle à PostgreSQL suffit généralement.
Créez les tables et les index. Ajoutez une table jobs (et toutes tables de lookup désirées), indexez run_at, et ajoutez un index qui aide votre worker à trouver rapidement les jobs disponibles (par exemple sur (status, run_at)).
Écrivez une fonction d'enqueue minimale. Votre application doit insérer une ligne avec run_at à « maintenant » ou à un futur. Gardez le payload petit et prévisible (IDs et type de job, pas de gros blobs).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running dans la même transaction.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at \u003c= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Traitez et finalisez. Pour chaque job réclamé, effectuez le travail, puis mettez à jour en done avec finished_at. En cas d'échec, enregistrez un message d'erreur et remettez en queued avec un nouveau run_at (backoff). Gardez les mises à jour de finalisation petites et exécutez-les toujours, même si votre process s'arrête.
Ajoutez des règles de retry explicables. Utilisez une formule simple comme run_at = now() + (attempts^2) * interval '10 seconds', et arrêtez après max_attempts en réglant status = 'dead'.
Vous n'avez pas besoin d'un tableau de bord complet dès le premier jour, mais vous devez pouvoir repérer les problèmes.
Si vous êtes déjà sur une stack Go + PostgreSQL, cela se mappe proprement à un seul binaire worker plus cron.
Imaginez une petite SaaS avec deux tâches planifiées :
Restez simple : une table PostgreSQL pour contenir les jobs, et un worker qui tourne chaque minute (déclenché par cron). Le worker réclame les jobs dus, les exécute et enregistre succès ou échec.
Vous pouvez enqueuer des jobs depuis plusieurs endroits :
cleanup_nightly pour « aujourd'hui ».send_weekly_report pour le prochain lundi de l'utilisateur.send_weekly_report qui s'exécute immédiatement pour une plage de dates spécifique.Le payload est juste le minimum qu'il faut au worker. Gardez-le petit pour faciliter le retry.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Un worker peut planter au pire moment : juste après avoir envoyé l'email, mais avant d'avoir marqué le job comme « done ». Au redémarrage, il peut reprendre le même job.
Pour éviter les envois en double, donnez au travail une clé de déduplication naturelle et stockez-la où la base peut l'appliquer. Pour les rapports hebdomadaires, une bonne clé est (user_id, week_start_date). Avant d'envoyer, le worker enregistre « je m'apprête à envoyer le rapport X ». Si cet enregistrement existe déjà, il saute l'envoi.
Cela peut être aussi simple qu'une table sent_reports avec une contrainte unique sur (user_id, week_start_date), ou une idempotency_key unique sur le job lui-même.
Imaginons que le fournisseur d'email timeoute. Le job échoue, donc le worker :
attemptsS'il continue d'échouer au-delà de la limite (par exemple 10 tentatives), le job est marqué « dead » et on arrête les retries. Le job réussit une fois, ou il réessaie selon un calendrier clair, et l'idempotence rend les retries sûrs.
Le pattern cron + base de données est simple, mais de petites erreurs peuvent le transformer en source de doublons, de jobs bloqués ou de charge surprise. La plupart des problèmes apparaissent après le premier crash, déploiement ou pic de trafic.
La plupart des incidents réels viennent de quelques pièges :
locked_until. Si un worker plante après avoir réclamé un job, cette ligne peut rester « en cours » pour toujours. Un timestamp de bail permet à un autre worker de la reprendre plus tard.user_id, invoice_id ou une clé de fichier) et récupérez le reste à l'exécution.Exemple : vous envoyez un email d'invoice hebdomadaire. Si le worker timeout juste après l'envoi mais avant de marquer le job comme terminé, il peut renvoyer l'email au retry. C'est normal pour ce pattern à moins d'ajouter une garde (par ex. enregistrer un événement unique « email envoyé » indexé par invoice id).
Évitez de mélanger scheduling et exécution dans une même transaction longue. Si vous gardez une transaction ouverte pendant des appels réseau, vous conservez les verrous plus longtemps que nécessaire et bloquez les autres workers.
Surveillez les différences d'horloge entre les machines. Utilisez l'heure de la base (NOW() dans PostgreSQL) comme source de vérité pour run_at et locked_until, pas l'horloge du serveur applicatif.
Fixez une durée maximale d'exécution claire. Si un job peut prendre 30 minutes, rendez le bail plus long que ça et renouvelez-le si nécessaire. Sinon, un autre worker peut le reprendre en plein milieu.
Gardez votre table de jobs saine. Si les jobs complétés s'accumulent pour toujours, les requêtes ralentissent et la contention sur les verrous augmente. Choisissez une règle de rétention simple (archiver ou supprimer les anciennes lignes) avant que la table ne devienne énorme.
Avant de déployer ce pattern, vérifiez l'essentiel. Une petite omission ici devient souvent des jobs bloqués, des doublons surprises ou un worker qui « tape » la base.
run_at, status, attempts, locked_until et max_attempts (plus last_error ou similaire pour voir ce qui s'est passé).invoice_id).max_attempts.Si tout cela est vrai, le pattern cron + base de données est en général suffisamment stable pour des charges réelles.
Une fois la checklist validée, concentrez-vous sur l'exploitation au quotidien.
run_at = now() et efface le lock) et « cancel » (met en statut terminal). Cela fait gagner du temps lors des incidents.status, run_at).Si vous voulez construire rapidement ce type de setup, Koder.ai peut vous aider à passer du schéma à une app Go + PostgreSQL déployée avec moins de câblage manuel, pendant que vous vous concentrez sur le verrouillage, les retries et les règles d'idempotence.
Si vous dépassez plus tard ce dispositif, vous aurez néanmoins bien compris le cycle de vie des jobs, et ces mêmes idées se transposent facilement vers un système de queue complet.