Scopri il pattern cron + database per eseguire job schedulati con retry, lock e idempotenza — senza mettere in piedi un sistema di code completo.

La maggior parte delle app ha bisogno che del lavoro venga eseguito più tardi o su uno schedule: inviare email di follow-up, eseguire un controllo di fatturazione notturno, pulire record vecchi, ricostruire un report o aggiornare una cache.
All'inizio può sembrare naturale aggiungere un sistema di code completo perché sembra il modo “giusto” di fare background jobs. Ma le code aggiungono parti in movimento: un altro servizio da far girare, monitorare, deployare e debuggare. Per un team piccolo (o un founder da solo), quel peso extra può rallentare.
La vera domanda è: come eseguire lavoro schedulato in modo affidabile senza mettere in piedi altra infrastruttura?
Un primo tentativo comune è semplice: aggiungi un cron che richiama un endpoint e lascia che quell'endpoint faccia il lavoro. Funziona… finché non smette di farlo. Quando hai più di un server, un deploy al momento sbagliato o un job che impiega più del previsto, cominciano a comparire guai confusi.
Il lavoro schedulato di solito si rompe in modi prevedibili:
Il pattern cron + database è una via di mezzo. Continui a usare cron per "svegliarti" secondo uno schedule, ma memorizzi l'intento e lo stato del job nel database in modo che il sistema possa coordinare, fare retry e registrare cosa è successo.
È una buona scelta quando hai già un database (spesso PostgreSQL), un numero ridotto di tipi di job e vuoi comportamento prevedibile con il minimo lavoro operativo. È anche naturale per app costruite velocemente su stack moderni (ad esempio, uno stack React + Go + PostgreSQL).
Non è indicato quando hai bisogno di throughput molto elevato, job di lunga durata che devono streammare progresso, ordinamento rigoroso tra molti tipi di job o un forte fan-out (migliaia di sotto-task al minuto). In quei casi, una coda reale e worker dedicati solitamente valgono l'investimento.
Il pattern cron + database esegue lavoro in background su uno schedule senza gestire un sistema di code completo. Continui a usare cron (o un qualsiasi scheduler), ma cron non decide cosa eseguire: si limita a svegliare spesso un worker (una volta al minuto è comune). È il database che decide quale lavoro è dovuto e si assicura che un solo worker prenda ogni job.
Pensalo come una checklist condivisa su una lavagna. Cron è la persona che entra nella stanza ogni minuto e chiede: “C'è qualcosa da fare ora?” Il database è la lavagna che mostra cosa è dovuto, cosa è già preso e cosa è fatto.
I pezzi sono semplici:
Esempio: vuoi inviare promemoria di fatture ogni mattina, aggiornare una cache ogni 10 minuti e pulire sessioni vecchie di notte. Invece di tre cron separati (ognuno con i propri problemi di overlap e failure), memorizzi voci di job in un unico posto. Cron avvia lo stesso processo worker. Il worker chiede a Postgres: “Cosa è dovuto adesso?” e Postgres risponde permettendo al worker di reclamare in modo sicuro esattamente un job alla volta.
Questo si scala gradualmente. Puoi partire con un worker su un server. Più avanti, puoi eseguire cinque worker su più server. Il contratto resta lo stesso: la tabella è il contratto.
Il cambio di mentalità è semplice: cron è solo la sveglia. Il database è il vigile che decide cosa può girare, registra cosa è successo e ti dà una storia chiara quando qualcosa va storto.
Questo pattern funziona meglio quando il tuo database diventa la fonte di verità su cosa dovrebbe girare, quando dovrebbe girare e cosa è successo l'ultima volta. Lo schema non è sofisticato, ma piccoli dettagli (campi di lock e gli indici giusti) fanno una grande differenza con l'aumentare del carico.
Due approcci comuni:
Se prevedi di fare spesso debug dei fallimenti, tieni la storia. Se vuoi la configurazione più piccola possibile, inizia con una tabella e aggiungi la storia dopo.
Qui c'è un layout comodo per PostgreSQL. Se costruisci in Go con PostgreSQL, queste colonne si mappano facilmente a struct.
-- 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()
);
Alcuni dettagli che evitano problemi in futuro:
send_invoice_emails).jsonb così puoi evolverlo senza migrazioni.Senza indici, i worker finiscono per scansionare troppo. Parti con:
(status, run_at)(locked_until)queued e failed)Questi mantengono rapida la query “trova il prossimo job eseguibile” anche quando la tabella cresce.
L'obiettivo è semplice: molti worker possono girare, ma solo uno deve prendere un job specifico. Se due worker processano la stessa riga ottieni email duplicate, addebiti doppi o dati incasinati.
Un approccio sicuro è trattare la presa del job come un "lease". Il worker marca il job come lockato per una finestra breve. Se il worker crasha, il lease scade e un altro worker può prenderlo. Questo è il senso di locked_until.
Senza un lease, un worker potrebbe lockare un job e non sbloccarlo mai (processo killato, reboot del server, deploy andato storto). Con locked_until, il job diventa nuovamente disponibile quando il tempo passa.
Una regola tipica: un job può essere reclamato quando locked_until è NULL o locked_until <= now().
Il dettaglio chiave è reclamare il job in una singola istruzione (o in una transazione). Vuoi che il database faccia da arbitro.
Ecco un pattern comune in PostgreSQL: prendi un job dovuto, lockalo e ritorna la riga al worker. (Questo esempio usa una tabella jobs; la stessa idea vale se reclami da 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.*;
Perché funziona:
FOR UPDATE SKIP LOCKED permette a più worker di competere senza bloccarsi a vicenda.RETURNING consegna la riga al worker che ha vinto la gara.Imposta il lease più lungo di una run normale, ma abbastanza corto da recuperare rapidamente da un crash. Se la maggior parte dei job finisce in 10 secondi, un lease di 2 minuti è più che sufficiente.
Per task lunghi, rinnova il lease mentre lavori (heartbeat). Un approccio semplice: ogni 30 secondi estendi locked_until se sei ancora il proprietario del job.
WHERE id = $job_id AND locked_by = $worker_idQuest'ultima condizione è importante. Previene che un worker estenda un lease su un job che non possiede più.
I retry sono il punto in cui questo pattern o è zen o diventa un caos rumoroso. L'obiettivo è semplice: quando un job fallisce, riprovarlo più tardi in modo spiegabile, misurabile e arrestabile.
Inizia rendendo lo stato del job esplicito e finito: queued, running, succeeded, failed, dead. In pratica, molte squadre usano failed per dire “fallito ma verrà ritentato” e dead per “fallito e abbiamo rinunciato”. Quella distinzione evita loop infiniti.
Il conteggio degli tentativi è la seconda barriera. Conserva attempts (quante volte hai provato) e max_attempts (quante volte permetti). Quando un worker cattura un errore, dovrebbe:
attemptsfailed se attempts < max_attempts, altrimenti deadrun_at per il prossimo tentativo (solo per failed)Il backoff è semplicemente la regola che decide il prossimo run_at. Scegline una, documentala e mantienila consistente:
Il jitter conta quando una dipendenza va giù e poi torna su. Senza jitter, centinaia di job possono ritentare tutti insieme e fallire di nuovo.
Conserva dettagli sugli errori sufficienti per rendere i fallimenti visibili e debugabili. Non ti serve un sistema di logging completo, ma le basi:
last_error (messaggio breve, sicuro da mostrare in una schermata admin)error_code o error_type (aiuta a raggruppare)failed_at e next_run_atlast_stack (solo se ne controlli la dimensione)Una regola concreta che funziona: marca i job come dead dopo 10 tentativi e usa backoff esponenziale con jitter. Così i fallimenti transitori vengono ritentati, ma i job rotti non consumano CPU per sempre.
Idempotenza significa che il tuo job può essere eseguito due volte e dare comunque lo stesso risultato finale. In questo pattern conta perché la stessa riga potrebbe essere ripresa dopo un crash, un timeout o un retry. Se il tuo job è “invia un'email di fattura”, eseguirlo due volte non è innocuo.
Un modo pratico per pensarci: dividi ogni job in (1) fare il lavoro e (2) applicare un effetto. Vuoi che l'effetto avvenga una sola volta, anche se il lavoro viene tentato più volte.
Una idempotency key dovrebbe venire da ciò che il job rappresenta, non dal tentativo del worker. Chiavi buone sono stabili e facili da spiegare, come invoice_id, user_id + day o report_name + report_date. Se due tentativi di job si riferiscono allo stesso evento del mondo reale, dovrebbero condividere la stessa chiave.
Esempio: “Genera il report vendite giornaliero per 2026-01-14” può usare sales_report:2026-01-14. “Addebita la fattura 812” può usare invoice_charge:812.
La guardia più semplice è lasciare che PostgreSQL rifiuti i duplicati. Memorizza la chiave di idempotenza in un campo indicizzabile, poi aggiungi un vincolo unico.
-- 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;
Questo impedisce l'esistenza di due righe con la stessa chiave nello stesso momento. Se il tuo design permette più righe (per la storia), metti l'unicità su una tabella "effects" separata, ad esempio sent_emails(idempotency_key) o payments(idempotency_key).
Effetti collaterali comuni da proteggere:
sent_emails con una chiave unica prima di inviare, o registra l'ID del provider una volta inviato.delivered_webhooks(event_id) e salta se esiste.file_generated indicizzato su (type, date).Se costruisci su uno stack con Postgres (per esempio, un backend Go + PostgreSQL), questi controlli di unicità sono veloci e facili da tenere vicini ai dati. L'idea chiave è semplice: i retry sono normali, i duplicati sono opzionali.
Scegli un runtime banale e attieniti a quello. Lo scopo del pattern cron + database è avere meno parti in movimento, quindi un piccolo processo in Go, Node o Python che parla con PostgreSQL è di solito sufficiente.
Crea le tabelle e gli indici. Aggiungi una tabella jobs (più eventuali tabelle di lookup), poi indicizza run_at e aggiungi un indice che aiuti il worker a trovare job disponibili velocemente (per esempio su (status, run_at)).
Scrivi una funzione di enqueue minimale. La tua app dovrebbe inserire una riga con run_at settato a “now” o a un tempo futuro. Mantieni il payload piccolo e prevedibile (ID e tipo job, non blob enormi).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running nella stessa transazione.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 *;
Processa e finalizza. Per ogni job reclamato, esegui il lavoro, poi aggiorna a done con finished_at. Se fallisce, registra un messaggio di errore e rimetti in queued con un nuovo run_at (backoff). Mantieni gli update di finalizzazione piccoli e assicurati di eseguirli sempre, anche se il processo sta spegnendosi.
Aggiungi regole di retry spiegabili. Usa una formula semplice come run_at = now() + (attempts^2) * interval '10 seconds' e fermati dopo max_attempts impostando status = 'dead'.
Non ti serve una dashboard completa il primo giorno, ma ti serve abbastanza per notare i problemi.
Se sei già su uno stack Go + PostgreSQL, questo si mappa pulitamente a un singolo binario worker più cron.
Immagina una piccola app SaaS con due lavori schedulati:
Mantieni semplice: una tabella PostgreSQL per i job e un worker che gira ogni minuto (attivato da cron). Il worker reclama i job dovuti, li esegue e registra successo o fallimento.
Puoi enqueuere job da diversi punti:
cleanup_nightly per “oggi”.send_weekly_report per il prossimo lunedì dell'utente.send_weekly_report per un range di date specifico.Il payload è solo il minimo che il worker serve. Mantienilo piccolo così è facile da retryare.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Un worker può crashare nel momento peggiore: subito dopo aver inviato l'email, ma prima di marcare il job come “done”. Al riavvio potrebbe riprendere lo stesso job.
Per evitare doppie inviate, dai al lavoro una chiave naturale di deduplica e memorizzala dove il database la può far rispettare. Per i report settimanali, una buona chiave è (user_id, week_start_date). Prima di inviare, il worker registra “sto per inviare il report X”. Se quel record esiste già, salta l'invio.
Questo può essere semplice come una tabella sent_reports con un vincolo unico su (user_id, week_start_date), o una idempotency_key unica direttamente sul job.
Supponiamo che il provider email faccia timeout. Il job fallisce, quindi il worker:
attemptsSe continua a fallire oltre il limite (per esempio 10 tentativi), marca il job come “dead” e smette di ritentare. Il job o riesce una volta, o ritenta secondo una tabella chiara, e l'idempotenza rende il retry sicuro.
Il pattern cron + database è semplice, ma piccoli errori possono trasformarlo in duplicati, lavoro bloccato o carico a sorpresa. La maggior parte dei problemi arriva dopo il primo crash, deploy o picco di traffico.
La maggior parte degli incidenti reali deriva da poche trappole:
locked_until. Se un worker crasha dopo aver reclamato un job, quella riga può restare “in progress” per sempre. Un timestamp di lease permette a un altro worker di prenderlo più tardi.user_id, invoice_id o una chiave file) e recupera il resto al momento dell'esecuzione.Esempio: invii una email settimanale di fattura. Se il worker va in timeout dopo l'invio ma prima di marcare il job come fatto, lo stesso job può essere ritentato e inviare una email duplicata. Questo è normale per il pattern a meno che tu non aggiunga una guardia (per esempio, registra un evento "email inviata" unico per invoice id).
Evita di mescolare scheduling ed esecuzione nella stessa lunga transazione. Se tieni una transazione aperta mentre fai chiamate di rete, tieni lock più a lungo del necessario e blocchi altri worker.
Fai attenzione alle differenze di orologio fra le macchine. Usa il tempo del database (NOW() in PostgreSQL) come fonte di verità per run_at e locked_until, non l'orologio dell'app server.
Imposta un tempo massimo di esecuzione chiaro. Se un job può impiegare 30 minuti, fai il lease più lungo di quello e rinnovalo se serve. Altrimenti un altro worker potrebbe prenderlo a metà esecuzione.
Mantieni la tabella jobs sana. Se le righe completate si accumulano per sempre, le query rallentano e la contesa dei lock aumenta. Scegli una regola semplice di retention (archivia o cancella le righe vecchie) prima che la tabella diventi enorme.
Prima di mettere in produzione questo pattern, verifica le basi. Una piccola omissione qui di solito si trasforma in job bloccati, duplicati a sorpresa o un worker che martella il database.
run_at, status, attempts, locked_until e max_attempts (più last_error o simile per vedere cosa è successo).invoice_id).max_attempts.Se tutto questo è vero, il pattern cron + database è solitamente stabile per carichi reali.
Una volta che la checklist è OK, concentrati sull'operatività quotidiana.
run_at = now() e pulisce il lock) e “cancel” (sposta in uno stato terminale). Queste salvano tempo durante gli incidenti.status, run_at).Se vuoi costruire questo setup velocemente, Koder.ai (koder.ai) può aiutarti ad andare dallo schema a un'app Go + PostgreSQL deployata con meno wiring manuale, mentre tu ti concentri su locking, retry e regole di idempotenza.
Se in futuro questo setup non ti bastasse, avrai comunque capito chiaramente il lifecycle dei job e le stesse idee si trasferiscono facilmente a un sistema di code completo.