Pelajari pola cron + database untuk menjalankan background jobs terjadwal dengan retry, locking, dan idempotensi — tanpa perlu menyiapkan sistem antrean penuh.

Kebanyakan aplikasi butuh pekerjaan yang berjalan nanti atau terjadwal: mengirim email follow-up, menjalankan pengecekan billing malam hari, membersihkan record lama, membangun ulang laporan, atau menyegarkan cache.
Awalnya sering terasa menggoda untuk menambahkan sistem antrean penuh karena terasa seperti cara “benar” untuk mengerjakan background jobs. Tapi antrean menambah bagian yang bergerak: layanan lain untuk dijalankan, dipantau, di-deploy, dan di-debug. Untuk tim kecil (atau pendiri tunggal), beban ekstra itu bisa memperlambat Anda.
Jadi pertanyaannya: bagaimana menjalankan pekerjaan terjadwal secara andal tanpa menambah infrastruktur?
Upaya pertama yang umum sederhana: tambahkan entri cron yang memanggil endpoint, lalu endpoint itu mengerjakan tugasnya. Cara ini berhasil sampai tidak lagi. Begitu Anda memiliki lebih dari satu server, deploy di waktu yang salah, atau job yang berjalan lebih lama dari perkiraan, Anda mulai melihat kegagalan yang membingungkan.
Pekerjaan terjadwal biasanya gagal dalam beberapa cara yang bisa diprediksi:
Pola cron + database adalah jalan tengah. Anda masih menggunakan cron untuk “membangunkan” worker sesuai jadwal, tapi Anda menyimpan intent job dan state job di database sehingga sistem bisa mengoordinasikan, me-retry, dan merekam apa yang terjadi.
Ini cocok ketika Anda sudah memiliki satu database (sering PostgreSQL), beberapa tipe job, dan menginginkan perilaku yang dapat diprediksi dengan pekerjaan operasi minimal. Ini juga pilihan alami untuk aplikasi yang dibangun cepat pada stack modern (misalnya React + Go + PostgreSQL).
Ini tidak cocok jika Anda butuh throughput sangat tinggi, job yang berjalan lama dan harus men-stream progres, ordering ketat di banyak tipe job, atau fan-out berat (ribuan sub-task per menit). Dalam kasus itu, antrean nyata dan worker khusus biasanya lebih menguntungkan.
Pola cron + database menjalankan pekerjaan background terjadwal tanpa menjalankan sistem antrean penuh. Anda tetap menggunakan cron (atau scheduler apa pun), tapi cron tidak memilih apa yang akan dijalankan. Cron hanya membangunkan worker dengan frekuensi tertentu (sekali per menit umum). Database yang memutuskan kerja mana yang jatuh tempo dan memastikan hanya satu worker yang mengambil setiap job.
Bayangkan seperti daftar tugas bersama di papan tulis. Cron adalah orang yang masuk ke ruangan setiap menit dan berkata, “Ada yang perlu dikerjakan sekarang?” Database adalah papan tulis yang menunjukkan apa yang jatuh tempo, apa yang sudah diambil, dan apa yang selesai.
Komponennya sederhana:
Contoh: Anda ingin mengirim pengingat faktur setiap pagi, menyegarkan cache setiap 10 menit, dan membersihkan sesi lama setiap malam. Alih-alih tiga perintah cron terpisah (masing-masing dengan mode overlap dan kegagalan), Anda menyimpan entri job di satu tempat. Cron memulai proses worker yang sama. Worker menanyakan Postgres, “Apa yang jatuh tempo sekarang?” dan Postgres menjawab dengan melegalkan worker untuk secara aman mengklaim tepat satu job pada satu waktu.
Ini bisa diskalakan secara bertahap. Anda bisa mulai dengan satu worker di satu server. Nanti, jalankan lima worker di beberapa server. Kontrak tetap sama: tabel adalah kontrak.
Perubahan pola pikirnya sederhana: cron hanya panggilan bangun. Database adalah pengatur lalu lintas yang memutuskan apa yang boleh berjalan, mencatat apa yang terjadi, dan memberi Anda riwayat yang jelas ketika sesuatu salah.
Pola ini bekerja paling baik ketika database Anda menjadi sumber kebenaran untuk apa yang harus dijalankan, kapan harus dijalankan, dan apa yang terjadi terakhir kali. Skema tidak rumit, tapi detail kecil (field lock dan indeks yang tepat) membuat perbedaan besar saat beban bertambah.
Dua pendekatan umum:
Jika Anda sering men-debug kegagalan, simpan riwayat. Jika ingin setup paling kecil, mulai dengan satu tabel dan tambahkan riwayat nanti.
Berikut layout yang ramah PostgreSQL. Jika Anda membangun di Go dengan PostgreSQL, kolom-kolom ini bisa dipetakan dengan rapi ke 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()
);
Beberapa detail yang menghemat rasa sakit nanti:
send_invoice_emails).jsonb supaya bisa berkembang tanpa migrasi.Tanpa indeks, worker akan memindai terlalu banyak baris. Mulai dengan:
(status, run_at)(locked_until)queued dan failed)Ini menjaga query “temukan job berikutnya yang bisa dijalankan” cepat walau tabel tumbuh.
Tujuannya sederhana: banyak worker boleh berjalan, tapi hanya satu yang boleh mengambil job tertentu. Jika dua worker memproses baris yang sama, Anda mendapatkan email ganda, biaya ganda, atau data berantakan.
Pendekatan aman adalah memperlakukan klaim job seperti “lease”. Worker menandai job sebagai terkunci untuk jangka waktu pendek. Jika worker crash, lease berakhir dan worker lain bisa mengambilnya. Itulah fungsi locked_until.
Tanpa lease, worker bisa mengunci job dan tidak pernah membuka (process killed, server reboot, deploy bermasalah). Dengan locked_until, job tersedia lagi saat waktu berlalu.
Aturan umum: job dapat diklaim saat locked_until bernilai NULL atau locked_until <= now().
Detail penting: klaim job harus dilakukan dalam satu pernyataan (atau satu transaksi). Anda ingin database menjadi hakim.
Berikut pola PostgreSQL umum: pilih satu job yang jatuh tempo, kunci, dan kembalikan ke worker. (Contoh ini memakai satu tabel jobs; ide yang sama berlaku jika Anda mengklaim dari 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.*;
Mengapa ini bekerja:
FOR UPDATE SKIP LOCKED memungkinkan banyak worker bersaing tanpa saling memblokir.RETURNING menyerahkan baris ke worker yang menang kompetisi.Set lease lebih lama dari durasi normal run, tapi cukup pendek agar crash pulih cepat. Jika kebanyakan job selesai dalam 10 detik, lease 2 menit sudah memadai.
Untuk tugas panjang, perpanjang lease saat bekerja (heartbeat). Pendekatan sederhana: setiap 30 detik, perpanjang locked_until jika Anda masih punya job itu.
WHERE id = $job_id AND locked_by = $worker_idKondisi terakhir itu penting. Ia mencegah worker memperpanjang lease pada job yang tidak lagi dimilikinya.
Retry adalah tempat pola ini terasa tenang atau berubah jadi berantakan. Tujuannya sederhana: ketika job gagal, coba lagi nanti dengan cara yang bisa Anda jelaskan, ukur, dan hentikan.
Mulailah dengan membuat status job eksplisit dan terbatas: queued, running, succeeded, failed, dead. Dalam praktiknya, tim biasanya memakai failed untuk berarti “gagal tapi akan retry” dan dead untuk “gagal dan kami menyerah”. Satu perbedaan itu mencegah loop tak berujung.
Penghitungan percobaan adalah pengaman kedua. Simpan attempts (berapa kali dicoba) dan max_attempts (berapa kali diperbolehkan). Saat worker menangkap error, ia harus:
attemptsfailed jika attempts < max_attempts, jika tidak deadrun_at untuk percobaan berikutnya (hanya untuk failed)Backoff adalah aturan yang menentukan run_at berikutnya. Pilih satu, dokumentasikan, dan konsisten:
Jitter penting saat dependency turun lalu kembali. Tanpa jitter, ratusan job bisa retry bersamaan dan gagal lagi.
Simpan detail error yang cukup untuk membuat kegagalan terlihat dan bisa di-debug. Anda tidak perlu sistem logging penuh, tapi butuh hal dasar:
last_error (pesan singkat yang aman ditampilkan di admin)error_code atau error_type (membantu pengelompokan)failed_at dan next_run_atlast_stack (hanya jika Anda bisa mengontrol ukurannya)Aturan konkret yang sering bekerja: tandai job dead setelah 10 percobaan, dan gunakan backoff eksponensial dengan jitter. Itu menjaga kegagalan sementara terus dicoba, tapi menghentikan job rusak menghabiskan CPU selamanya.
Idempotensi berarti job Anda bisa dijalankan dua kali dan tetap menghasilkan hasil akhir yang sama. Dalam pola ini, itu penting karena baris yang sama mungkin diambil lagi setelah crash, timeout, atau retry. Jika job Anda adalah “kirim email faktur”, menjalankannya dua kali tidak aman.
Cara praktis memikirkannya: bagi setiap job menjadi (1) melakukan pekerjaan dan (2) menerapkan efek. Anda ingin efek terjadi sekali, meskipun pekerjaan dicoba beberapa kali.
Idempotency key harus berasal dari apa yang direpresentasikan job, bukan dari percobaan worker. Kunci yang baik stabil dan mudah dijelaskan, seperti invoice_id, user_id + day, atau report_name + report_date. Jika dua percobaan job merujuk pada event dunia nyata yang sama, mereka harus berbagi kunci yang sama.
Contoh: “Generate daily sales report for 2026-01-14” bisa memakai sales_report:2026-01-14. “Charge invoice 812” bisa memakai invoice_charge:812.
Pengaman paling sederhana adalah membiarkan PostgreSQL menolak duplikat. Simpan idempotency key di tempat yang bisa diindeks, lalu tambahkan unique 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;
Ini mencegah dua baris dengan kunci yang sama ada pada saat bersamaan. Jika desain Anda mengizinkan banyak baris (untuk riwayat), letakkan keunikan pada tabel “effects” terpisah, mis. sent_emails(idempotency_key) atau payments(idempotency_key).
Efek samping umum yang perlu dilindungi:
sent_emails dengan kunci unik sebelum mengirim, atau rekam provider message id setelah dikirim.delivered_webhooks(event_id) dan lewati jika sudah ada.file_generated yang ter-key oleh (type, date).Jika Anda membangun di stack berbasis Postgres (mis. backend Go + PostgreSQL), pemeriksaan keunikan ini cepat dan mudah ditempatkan dekat data. Inti idenya sederhana: retry itu normal, duplikat adalah hal yang bisa dicegah.
Pilih runtime yang sederhana dan tetap pada itu. Tujuan pola cron + database adalah mengurangi bagian yang bergerak, jadi proses kecil (Go, Node, atau Python) yang berbicara ke PostgreSQL biasanya cukup.
Buat tabel dan indeks. Tambahkan tabel jobs (plus tabel lookup yang Anda inginkan nantinya), lalu indeks run_at, dan indeks yang membantu worker menemukan job tersedia dengan cepat (mis. pada (status, run_at)).
Tulis fungsi enqueue kecil. Aplikasi Anda harus memasukkan baris dengan run_at di-set ke now atau waktu di masa depan. Jaga payload kecil dan prediktabel (ID dan tipe job, bukan blob besar).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running dalam transaksi yang sama.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 *;
Proses dan finalisasikan. Untuk setiap job yang diklaim, lakukan pekerjaan, lalu update ke done dengan finished_at. Jika gagal, rekam pesan error dan pindahkan kembali ke queued dengan run_at baru (backoff). Buat update finalisasi kecil dan selalu jalankan, bahkan saat proses hendak mati.
Tambahkan aturan retry yang bisa dijelaskan. Pakai formula sederhana seperti run_at = now() + (attempts^2) * interval '10 seconds', dan hentikan setelah max_attempts dengan set status = 'dead'.
Anda tidak perlu dashboard penuh di hari pertama, tapi butuh cukup agar melihat masalah.
Jika Anda sudah di stack Go + PostgreSQL, ini berkorespondensi langsung ke satu binary worker plus cron.
Bayangkan SaaS kecil dengan dua pekerjaan terjadwal:
Jaga sederhana: satu tabel PostgreSQL untuk menampung jobs, dan satu worker yang berjalan setiap menit (dipicu cron). Worker mengklaim job yang jatuh tempo, menjalankannya, dan mencatat sukses atau gagal.
Anda bisa enqueue job dari beberapa tempat:
cleanup_nightly untuk “hari ini”.send_weekly_report untuk monday berikutnya pengguna.send_weekly_report yang dijalankan segera untuk rentang tanggal tertentu.Payload cukup minimum yang dibutuhkan worker. Jaga kecil agar mudah di-retry.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Worker bisa crash pada momen terburuk: tepat setelah mengirim email, tapi sebelum menandai job sebagai “done”. Saat restart, ia mungkin mengambil job yang sama lagi.
Untuk menghentikan pengiriman ganda, berikan pekerjaan kunci deduplikasi alami dan simpan tempat database bisa menegakkannya. Untuk laporan mingguan, kunci yang baik adalah (user_id, week_start_date). Sebelum mengirim, worker mencatat “saya akan mengirim laporan X”. Jika catatan itu sudah ada, ia melewati pengiriman.
Ini bisa sesederhana tabel sent_reports dengan constraint unik pada (user_id, week_start_date), atau idempotency_key unik pada job itu sendiri.
Misalnya provider email Anda timeout. Job gagal, sehingga worker:
attemptsJika terus gagal melewati batas (mis. 10 attempts), tandai sebagai “dead” dan hentikan retry. Job akan sukses sekali, atau ia retry menurut jadwal yang jelas, dan idempotensi membuat retry aman.
Pola cron + database sederhana, tapi kesalahan kecil bisa membuatnya menghasilkan duplikat, pekerjaan stuck, atau lonjakan beban. Mayoritas masalah muncul setelah crash pertama, deploy, atau spike lalu lintas.
Kebanyakan insiden nyata berasal dari beberapa jebakan:
locked_until. Jika worker crash setelah mengklaim job, baris itu bisa tetap “in progress” selamanya. Timestamp lease memungkinkan worker lain mengambilnya nanti.user_id, invoice_id, atau key file) dan ambil sisanya saat menjalankan.Contoh: Anda mengirim email faktur mingguan. Jika worker timeout setelah mengirim tapi sebelum menandai job selesai, job yang sama mungkin di-retry dan mengirim email duplikat. Itu normal untuk pola ini kecuali Anda menambahkan pengaman (mis. merekam event “email sent” unik berdasarkan invoice id).
Hindari mencampur penjadwalan dan eksekusi dalam transaksi panjang. Jika Anda memegang transaksi terbuka saat melakukan panggilan jaringan, Anda memperpanjang lock lebih lama dari yang diperlukan dan memblokir worker lain.
Perhatikan perbedaan jam antar mesin. Gunakan waktu database (NOW() di PostgreSQL) sebagai sumber kebenaran untuk run_at dan locked_until, bukan jam server aplikasi.
Tetapkan runtime maksimum yang jelas. Jika job bisa memakan 30 menit, buat lease lebih panjang dari itu, dan perpanjang jika perlu. Kalau tidak, worker lain bisa mengambilnya di tengah jalannya.
Jaga tabel job tetap sehat. Jika baris selesai menumpuk selamanya, query melambat dan kontensi lock meningkat. Pilih aturan retensi sederhana (arsipkan atau hapus row lama) sebelum tabel menjadi sangat besar.
Sebelum Anda mengirimkan pola ini ke produksi, periksa hal dasar. Kelalaian kecil di sini biasanya berujung pada job terjebak, duplikat mengejutkan, atau worker yang membombardir database.
run_at, status, attempts, locked_until, dan max_attempts (plus last_error atau serupa agar Anda bisa melihat apa yang terjadi).invoice_id).max_attempts.Jika semua ini benar, pola cron + database biasanya cukup stabil untuk beban nyata.
Setelah checklist beres, fokus pada operasi sehari-hari.
run_at = now() dan bersihkan lock) dan “cancel” (pindah ke status terminal). Ini menghemat waktu saat insiden.status, run_at).Jika Anda ingin membangun setup semacam ini dengan cepat, Koder.ai (koder.ai) dapat membantu Anda dari skema ke aplikasi Go + PostgreSQL yang dideploy dengan lebih sedikit wiring manual, sementara Anda fokus pada aturan locking, retries, dan idempotensi.
Jika kemudian Anda melebihi kapasitas setup ini, Anda tetap telah mempelajari lifecycle job dengan jelas, dan ide-ide yang sama ini mudah dipetakan ke sistem antrean penuh.