Lär dig cron + databas-mönstret för att köra schemalagda bakgrundsjobb med retries, låsning och idempotens — utan att köra ett fullständigt kö-system.

De flesta appar behöver att arbete körs senare eller enligt schema: skicka uppföljningsmejl, köra en nattlig fakturakontroll, rensa gamla poster, bygga om en rapport eller uppdatera en cache.
I början är det frestande att lägga till ett fullfjädrat kö-system eftersom det känns som det "rätta" sättet att göra bakgrundsjobb. Men köer tillför rörliga delar: en tjänst till att köra, övervaka, deploya och felsöka. För ett litet team (eller en ensam grundare) kan den extra tyngden sakta ner dig.
Så den verkliga frågan är: hur kör du schemalagt arbete pålitligt utan att starta mer infrastruktur?
Ett vanligt första försök är enkelt: lägg till en cron-post som träffar en endpoint, och låt den endpointen göra arbetet. Det fungerar tills det inte gör det. När du har fler än en server, en deploy vid fel tidpunkt, eller ett jobb som tar längre än förväntat, börjar du se förvirrande fel.
Schemalagt arbete går vanligen sönder på några förutsägbara sätt:
Cron + databas-mönstret är en mittenväg. Du använder fortfarande cron för att "väcka" enligt schema, men du lagrar jobbavsikt och jobbstatus i din databas så systemet kan koordinera, retry:a och registrera vad som hände.
Det passar bra när du redan har en databas (ofta PostgreSQL), ett litet antal job-typer och du vill ha förutsägbart beteende med minimal ops. Det är också ett naturligt val för appar byggda snabbt på moderna stackar (till exempel React + Go + PostgreSQL).
Det är inte en bra passform när du behöver mycket hög genomströmning, långkörande jobb som måste streama status, strikt ordning över många job-typer eller tung fan-out (tusentals underuppgifter per minut). I de fallen betalar sig ett riktigt kö-system och dedikerade workers oftast.
Cron + databas-mönstret kör bakgrundsarbete enligt schema utan att köra ett fullständigt kö-system. Du använder fortfarande cron (eller en scheduler), men cron bestämmer inte vad som ska köras. Den väcker bara en worker ofta (en gång per minut är vanligt). Databasen bestämmer vilket arbete som är förfallet och ser till att bara en worker tar varje jobb.
Tänk på det som en delad checklista på en whiteboard. Cron är personen som går in i rummet varje minut och säger "Behöver någon göra något nu?" Databasen är whiteboarden som visar vad som är förfallet, vad som redan är taget och vad som är klart.
Delarna är enkla:
Exempel: du vill skicka fakturapåminnelser varje morgon, uppdatera en cache var 10:e minut och rensa gamla sessioner varje natt. Istället för tre separata cron-kommandon (var och en med sina egna överlapp och felmod), lagrar du jobbposter på ett ställe. Cron startar samma worker-process. Workern frågar Postgres, "Vad är förfallet just nu?" och Postgres svarar genom att låta workern säkert göra anspråk på exakt ett jobb åt gången.
Det här skalar gradvis. Du kan börja med en worker på en server. Senare kan du köra fem workers över flera servrar. Kontraktet är detsamma: tabellen är kontraktet.
Tankesättet är enkelt: cron är bara väckarklockan. Databasen är trafikpolisen som bestämmer vad som får köras, registrerar vad som hände och ger dig en tydlig historik när något går fel.
Detta mönster fungerar bäst när din databas blir sanningskällan för vad som ska köras, när det ska köras och vad som hände senast. Schemat är inte fancyt, men små detaljer (låsfält och rätt index) gör stor skillnad när belastningen växer.
Två vanliga tillvägagångssätt:
Om du förväntar dig att felsöka ofta, behåll historiken. Om du vill ha den minsta möjliga uppsättningen, börja med en tabell och lägg till historik senare.
Här är en PostgreSQL-vänlig layout. Om du bygger i Go med PostgreSQL mappar dessa kolumner rent till 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()
);
Några detaljer som sparar problem senare:
send_invoice_emails).jsonb så du kan utveckla den utan migrationer.Utan index kommer workers skanna för mycket. Börja med:
(status, run_at)(locked_until)queued och failed)Dessa håller frågan "hitta nästa körbara jobb" snabb även när tabellen växer.
Målet är enkelt: många workers kan köras, men bara en ska kunna ta ett specifikt jobb. Om två workers processar samma rad får du dubbla mejl, dubbla avgifter eller rörig data.
Ett säkert tillvägagångssätt är att behandla ett jobbanspråk som ett "leasingavtal". Workern markerar jobbet som låst för ett kort fönster. Om workern kraschar går leasetiden ut och en annan worker kan plocka upp det. Det är vad locked_until är till för.
Utan lease kan en worker låsa ett jobb och aldrig låsa upp det (processen dödad, servern rebootad, deploy misslyckad). Med locked_until blir jobbet tillgängligt igen när tiden passerat.
En typisk regel är: ett jobb kan göras anspråk på när locked_until är NULL eller locked_until <= now().
Den viktiga detaljen är att göra anspråk i en enda sats (eller en transaktion). Du vill att databasen ska vara domaren.
Här är ett vanligt PostgreSQL-mönster: plocka ett förfallet jobb, lås det och returnera det till workern. (Detta exempel använder en enda jobs-tabell; samma idé gäller om du gör anspråk från job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= 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.*;
Varför det fungerar:
FOR UPDATE SKIP LOCKED låter flera workers tävla utan att blockera varandra.RETURNING ger raden till den worker som vann racet.Sätt leasetiden längre än en normal körning, men kort nog så en krasch återhämtar sig snabbt. Om de flesta jobb slutförs på 10 sekunder är en 2 minuters lease mer än tillräckligt.
För långa uppgifter, förnya leaset medan du arbetar (en heartbeat). Ett enkelt tillvägagångssätt: förläng locked_until var 30:e sekund om du fortfarande äger jobbet.
WHERE id = $job_id AND locked_by = $worker_idDen sista villkorssatsen är viktig. Den förhindrar att en worker förlänger en lease på ett jobb den inte längre äger.
Retries är där detta mönster antingen känns lugnt eller blir till ett bullrigt kaos. Målet är enkelt: när ett jobb misslyckas, försök igen senare på ett sätt du kan förklara, mäta och stoppa.
Börja med att göra jobbstatus explicit och ändlig: queued, running, succeeded, failed, dead. I praktiken använder de flesta team failed för att betyda "misslyckades men kommer att retry:as" och dead för att betyda "misslyckades och vi gav upp". Den distinktionen förhindrar oändliga loopar.
Försöksräkning är det andra skyddsräcket. Spara attempts (hur många gånger du försökt) och max_attempts (hur många gånger du tillåter). När en worker fångar ett fel ska den:
attemptsfailed om attempts < max_attempts, annars deadrun_at för nästa försök (endast för failed)Backoff är bara regeln som bestämmer nästa run_at. Välj en, dokumentera den och håll den konsekvent:
Jitter är viktigt när en beroendetjänst går ner och kommer upp igen. Utan det kan hundratals jobb retry:a samtidigt och misslyckas igen.
Spara tillräckligt med felinformation för att göra fel synliga och felsökningsbara. Du behöver inte ett fullständigt loggsystem, men du behöver grunderna:
last_error (kort meddelande, säkert att visa i ett admin-gränssnitt)error_code eller error_type (hjälper gruppering)failed_at och next_run_atlast_stack (endast om du kan kontrollera storleken)En konkret regel som fungerar bra: markera jobb som dead efter 10 försök, och backoff exponentiellt med jitter. Det håller övergående fel retry:ande, men stoppar trasiga jobb från att elda upp CPU för evigt.
Idempotens betyder att ditt jobb kan köras två gånger och ändå ge samma slutresultat. I detta mönster är det viktigt eftersom samma rad kan plockas upp igen efter en krasch, timeout eller retry. Om ditt jobb är "skicka en faktura-mejl", är det inte ofarligt att köra det två gånger.
Ett praktiskt sätt att tänka: dela varje jobb i (1) göra arbetet och (2) applicera en effekt. Du vill att effekten ska hända en gång, även om arbetet försöks flera gånger.
En idempotency-nyckel bör komma från vad jobbet representerar, inte från worker-försöket. Bra nycklar är stabila och lätta att förklara, som invoice_id, user_id + day eller report_name + report_date. Om två jobbförsök hänvisar till samma verkliga händelse bör de dela samma nyckel.
Exempel: "Generera daglig försäljningsrapport för 2026-01-14" kan använda sales_report:2026-01-14. "Charge invoice 812" kan använda invoice_charge:812.
Det enklaste skyddet är att låta PostgreSQL avvisa dubbletter. Spara idempotency-nyckeln där den kan indexeras, och lägg sedan till en unik constraint.
-- 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;
Detta förhindrar att två rader med samma nyckel existerar samtidigt. Om din design tillåter flera rader (för historik), sätt unikheten på en "effects"-tabell istället, som sent_emails(idempotency_key) eller payments(idempotency_key).
Vanliga sidoeffekter att skydda:
sent_emails-rad med en unik nyckel innan du skickar, eller registrera ett provider-message-id när det väl är skickat.delivered_webhooks(event_id) och hoppa över om det redan finns.file_generated-post nycklad på (type, date).Om du bygger på en Postgres-baserad stack (t.ex. Go + PostgreSQL) är dessa unikhetskontroller snabba och lätta att hålla nära datan. Nyckelidén är enkel: retries är normala, dubbletter är undantag.
Välj en tråkig runtime och håll dig till den. Poängen med cron + databas-mönstret är färre rörliga delar, så en liten Go-, Node- eller Python-process som pratar med PostgreSQL räcker oftast.
Skapa tabellerna och indexen. Lägg till en jobs-tabell (plus eventuella uppslags-tabeller senare), indexera run_at och lägg till ett index som hjälper din worker att hitta tillgängliga jobb snabbt (t.ex. på (status, run_at)).
Skriv en liten enqueue-funktion. Din app ska infoga en rad med run_at satt till "now" eller en framtida tid. Håll payloaden liten och förutsägbar (ID:n och en job-typ, inte stora blobbar).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running i samma transaktion.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= 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 och finalisera. För varje påstått jobb, gör arbetet och uppdatera sedan till done med finished_at. Om det misslyckas, registrera ett felmeddelande och flytta tillbaka till queued med en ny run_at (backoff). Håll finaliseringsuppdateringarna små och kör dem alltid, även om din process stängs ner.
Lägg till retry-regler du kan förklara. Använd en enkel formel som run_at = now() + (attempts^2) * interval '10 seconds', och stoppa efter max_attempts genom att sätta status = 'dead'.
Du behöver inte en full dashboard dag ett, men du behöver tillräckligt för att märka problem.
Om du redan kör en Go + PostgreSQL-stack mappar detta rent till en enda worker-binary plus cron.
Föreställ dig en liten SaaS-app med två schemalagda jobb:
Håll det enkelt: en PostgreSQL-tabell för jobb, och en worker som kör varje minut (triggad av cron). Workern gör anspråk på förfallna jobb, kör dem och registrerar succé eller fel.
Du kan enqueua jobb från några platser:
cleanup_nightly-jobb för "idag".send_weekly_report-jobb för användarens nästa måndag.send_weekly_report-jobb som körs omedelbart för ett specifikt datumintervall.Payloaden är bara det minsta workern behöver. Håll den liten så den är lätt att retry:a.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
En worker kan krascha i värsta ögonblicket: precis efter att den skickat mejlet men innan den markerat jobbet som "done". När den startar om kan den plocka samma jobb igen.
För att stoppa dubbelsändningar, ge arbetet en naturlig dedupe-nyckel och spara den där databasen kan tvinga den. För veckorapporter är en bra nyckel (user_id, week_start_date). Innan du skickar registrerar workern "jag ska precis skicka rapport X". Om den posten redan finns hoppar den över sändningen.
Detta kan vara så enkelt som en sent_reports-tabell med en unik constraint på (user_id, week_start_date), eller en unik idempotency_key på jobbet självt.
Säg att din e-postleverantör tajmar ut. Jobbet misslyckas, så workern:
attemptsOm det fortsätter att misslyckas förbi din gräns (som 10 försök), markera det som "dead" och sluta retry:a. Jobbet lyckas antingen en gång, eller så retry:as det enligt ett tydligt schema, och idempotens gör retry säkert.
Cron + databas-mönstret är enkelt, men små misstag kan göra det till dubbletter, fastnade jobb eller överraskande belastning. De flesta problem dyker upp efter den första kraschen, deployen eller trafikspiken.
De flesta incidenter kommer från några fällor:
locked_until. Om en worker kraschar efter att ha gjort anspråk kan den raden förbli "in progress" för evigt. En lease-tidsstämpel låter en annan worker säkert plocka upp den senare.user_id, invoice_id eller en filnyckel) och hämta resten när du kör.Exempel: du skickar ett veckovis faktura-mejl. Om workern tajmar ut efter att ha skickat men före att markera jobbet som klart kan samma jobb retry:as och skicka ett duplicerat mejl. Det är normalt för detta mönster om du inte lägger till ett skydd (t.ex. registrera en unik "email sent"-händelse nycklad på invoice id).
Undvik att blanda schemaläggning och exekvering i samma långlivade transaktion. Om du håller en transaktion öppen medan du gör nätverksanrop behåller du lås längre än nödvändigt och blockerar andra workers.
Se upp för klockskillnader mellan maskiner. Använd databastid (NOW() i PostgreSQL) som sanningskälla för run_at och locked_until, inte applikationsserverns klocka.
Sätt en tydlig maxkörtid. Om ett jobb kan ta 30 minuter, gör leaset längre än det och förnya den vid behov. Annars kan en annan worker plocka upp jobbet mitt i körningen.
Håll din jobs-tabell frisk. Om slutförda jobb staplas på för evigt blir frågor långsamma och låskontentionen ökar. Välj en enkel retention-regel (arkivera eller radera gamla rader) innan tabellen blir gigantisk.
Innan du skickar detta mönster, kontrollera grunderna. Ett litet utelämnande här brukar bli fastnade jobb, överraskande dubbletter eller en worker som hamrar databasen.
run_at, status, attempts, locked_until och max_attempts (plus last_error eller liknande så du kan se vad som hände).invoice_id).max_attempts.Om dessa är sanna är cron + databas-mönstret vanligtvis stabilt nog för verklig belastning.
När checklistan ser bra ut, fokusera på drift:
run_at = now() och rensa låset) och "cancel" (flytta till ett terminalt status). Dessa sparar tid vid incidenter.status, run_at).Om du vill bygga detta snabbt kan Koder.ai (koder.ai) hjälpa dig ta dig från schema till en deployad Go + PostgreSQL-app med mindre manuell koppling, medan du fokuserar på låsning, retries och idempotens-regler.
Om du senare växer ur denna setup kommer du ändå ha lärt dig jobblivscykeln tydligt, och samma idéer mappar väl till ett fullständigt kö-system.