Simpan setiap kemunculan jadwal sebagai row database dengan primary key nama schedule plus waktu slot, sehingga hanya satu instance yang bisa membuatnya. Worker meng-claim row dengan FOR UPDATE SKIP LOCKED dan lease yang bisa kedaluwarsa. Karena worker bisa crash setelah efek samping tetapi sebelum mencatat sukses, Anda mendapat at-least-once delivery dan membuat handler idempotent untuk hasil effectively-once.
02Kenapa cron job jalan dua kali saat saya scale ke beberapa instance?
Cron adalah daemon per mesin tanpa pengetahuan tentang host lain, jadi setiap instance dengan crontab yang sama menembak sendiri-sendiri. Job tiap 5 menit di 3 instance menghasilkan 864 eksekusi per hari, bukan 288. Rolling deploy yang menjaga container lama dan baru hidup bersamaan menyebabkan duplikat yang sama.
03Apa beda advisory lock dan lease?
Advisory lock tingkat session di Postgres dipegang sampai dilepas atau session database berakhir, jadi bergantung pada koneksi yang tetap hidup. Lease membawa timestamp kedaluwarsa yang harus diperpanjang pemegangnya lewat heartbeat, sehingga worker yang crash atau macet kehilangannya otomatis. Lease cocok untuk job panjang dan pemulihan, sedangkan advisory lock cocok untuk mutual exclusion murah seperti aturan no-overlap.
04Apa yang harus dilakukan scheduler pada run yang terlewat saat downtime?
Pilih per job antara melewati slot yang terlewat, menjalankan sekali untuk slot terbaru, atau menjalankan setiap slot yang terlewat. Dengan job 15 menit dan outage dari 10:02 sampai 10:52, tiga slot terlewat, jadi kebijakan itu menjalankan 0, 1, atau 3 run. Tambahkan batas keterlambatan maksimum agar slot yang sangat lama ditandai skipped, bukan dijalankan.
05Berapa lama retry harus menunggu di antara attempt?
Gunakan exponential backoff dengan full jitter: tunggu waktu acak antara nol dan min(cap, base kali 2 pangkat attempt dikurangi 1). Dengan base 30 detik dan cap 900 detik, batas atasnya 30, 60, 120, 240, 480, dan 900 detik. Batasi jumlah attempt, kirim error yang tidak bisa di-retry langsung ke status dead, dan beri alert pada dead run.
Agar tiap scheduled job jalan sekali di banyak instance, beri setiap slot waktu satu row di database, biarkan worker meng-claim-nya dengan FOR UPDATE SKIP LOCKED dan lease yang bisa kedaluwarsa, lalu buat handler idempotent. Exactly-once end to end tidak mungkin, jadi hasilnya at-least-once dengan efek effectively-once. Tambahkan catch-up policy, retry ber-jitter, dan alert lag.
Cara klasik mempelajari ini adalah hari ketika Anda menambah replica kedua dan laporan malam terkirim dua kali. Cron tidak salah: ia jalan tepat waktu di setiap mesin yang punya crontab, dan tidak ada di dalamnya yang tahu mesin lain ada.
Ini desain yang dikerjakan di atas kertas, bukan cerita perang, jadi semua angka di bawah diturunkan dari asumsi yang disebutkan dengan perhitungan yang ditunjukkan. Perilaku FOR UPDATE SKIP LOCKED dan advisory lock berasal dari dokumentasi PostgreSQL, penalaran lease dan fencing dari Martin Kleppmann, dan matematika backoff dari AWS Builders Library. Stack-nya yang biasa saya pakai di satu VPS: Postgres, Node, dan NestJS.
Kenapa cron di beberapa instance menjalankan job dua kali?
Cron adalah daemon per mesin. Ia membaca crontab sendiri, memantau jam sendiri, lalu menjalankan perintah, tanpa shared state dan tanpa cara bertanya apakah host lain sudah menjalankannya. Pasang jadwal yang sama di tiga replica, atau biarkan rolling deploy menjaga container lama dan baru hidup bersamaan semenit, dan masing-masing menembak sendiri. Clock drift memperburuk keadaan karena salinan-salinan itu tidak menembak di detik yang sama, sehingga lock naif yang diambil sebelum job bisa sudah dilepas sebelum salinan yang lebih lambat tiba.
Ambil job tiap 5 menit di 3 instance. Satu hari punya 24 kali 60 dibagi 5, yaitu 288 slot. Tiga instance menembak tiap slot, jadi 3 kali 288 menghasilkan 864 eksekusi padahal yang Anda mau 288, dan 576 di antaranya duplikat. Kalau job-nya mengirim email atau menagih kartu, itu 576 efek samping yang salah per hari. Solusinya bukan lock yang lebih pintar di sekitar job, melainkan berhenti menjadikan jam dinding sebagai pemicu dan menjadikan slot itu sendiri sebuah record.
Bagaimana membuat satu slot terjadwal hanya jalan sekali?
Simpan jadwal dan setiap kemunculan konkretnya di database. Satu row schedule menyatakan kapan slot berikutnya jatuh tempo. Satu row run mewakili satu slot, dan primary key-nya adalah nama schedule ditambah waktu slot. Instance mana pun boleh mencoba membuat row itu, dan primary key menjamin hanya satu yang berhasil. Di bawah ini schema dan tick yang bisa dijalankan setiap instance tiap beberapa detik.
-- A schedule says WHEN. A job_run is one concrete slot of it.
CREATE TABLE schedules (
name text PRIMARY KEY,
every interval NOT NULL, -- e.g. '5 minutes'
next_run_at timestamptz NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE TABLE job_runs (
schedule_name text NOT NULL REFERENCES schedules(name),
scheduled_for timestamptz NOT NULL, -- the slot, not the wall clock
status text NOT NULL DEFAULT 'pending', -- pending | running | done | dead
attempts int NOT NULL DEFAULT 0,
run_after timestamptz NOT NULL DEFAULT now(),
locked_by text,
locked_until timestamptz,
last_error text,
finished_at timestamptz,
PRIMARY KEY (schedule_name, scheduled_for) -- this line is the whole "once per slot" guarantee
);
-- The tick. Every instance may run it every few seconds; they cannot collide.
WITH due AS (
SELECT name, next_run_at, every
FROM schedules
WHERE enabled AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 50
FOR UPDATE SKIP LOCKED -- a second instance skips rows the first holds
), fired AS (
INSERT INTO job_runs (schedule_name, scheduled_for)
SELECT name, next_run_at FROM due
ON CONFLICT DO NOTHING -- belt and braces: the slot already exists
RETURNING schedule_name
)
UPDATE schedules s
SET next_run_at = d.next_run_at + d.every -- one slot at a time = "run every missed slot"
FROM due d
WHERE s.name = d.name;
Dua hal membuatnya aman. Pertama, waktu slot berasal dari next_run_at yang tersimpan, bukan dari jam tiap instance, dan satu-satunya jam yang dibaca adalah now() di database, yaitu satu jam saja. Kedua, FOR UPDATE SKIP LOCKED berarti instance yang menemukan schedule jatuh tempo sudah dikunci instance lain akan lanjut tanpa menunggu. Dokumentasi PostgreSQL mencatat bahwa melewati row terkunci memberi tampilan data yang tidak konsisten, sehingga tidak cocok untuk query umum, tetapi justru itu yang Anda mau untuk tabel mirip antrean ketika tiap worker harus mengambil row yang berbeda.
Bagaimana claim-with-lease bekerja dengan SKIP LOCKED?
Membuat row slot belum berarti menjalankan job. Worker harus meng-claim sebuah run, dan claim itu harus bertahan saat worker mati. Lease melakukannya: alih-alih lock yang hidup sampai ada yang melepasnya, claim membawa masa kedaluwarsa, locked_until. Sebuah run bisa di-claim ketika statusnya pending dan sudah jatuh tempo, atau ketika statusnya running tetapi lease-nya sudah habis. Kedua kasus lewat query yang sama.
-- Claim one run with a lease. Pending work and work whose lease expired look the same.
UPDATE job_runs
SET status = 'running',
attempts = attempts + 1, -- doubles as the fencing token
locked_by = $1,
locked_until = now() + make_interval(secs => $2)
WHERE (schedule_name, scheduled_for) = (
SELECT schedule_name, scheduled_for
FROM job_runs
WHERE (status = 'pending' AND run_after <= now())
OR (status = 'running' AND locked_until < now()) -- the previous worker died or stalled
ORDER BY scheduled_for
LIMIT 1
FOR UPDATE SKIP LOCKED -- concurrent claimers skip, never wait
)
RETURNING schedule_name, scheduled_for, attempts;
Ambil lease 60 detik, heartbeat tiap 20 detik, dan worker yang polling tiap 5 detik, semuanya asumsi yang bisa Anda tala. Jika worker terbunuh di tengah run, lease-nya habis dalam 60 detik dan poll berikutnya menemukan run itu, jadi jeda pemulihan terburuk sekitar 60 ditambah 5, yaitu 65 detik. Kolom attempts bertambah tiap claim dan sekaligus berfungsi sebagai fencing token, yang dipakai di bagian retry. Untuk mekanisme antrean di balik pola ini, lihat post saya sebelumnya tentang Postgres SKIP LOCKED job queue; post ini membahas kapan job jatuh tempo, bukan cara job dikonsumsi.
Atur heartbeat sekitar sepertiga lease. Satu beat yang terlewat hanyalah gangguan kecil, dua jadi peringatan, tiga berarti worker sudah hilang. Lease yang jauh lebih pendek dari jeda garbage-collection atau gangguan jaringan yang normal akan merebut kembali run yang sebenarnya masih sehat.
Apakah Anda butuh advisory lock atau leader?
Sering kali tidak, karena primary key sudah menghilangkan race. Tetapi ada baiknya melihat pilihan berdampingan. Leader dan lock adalah alat untuk mempersempit siapa yang melakukan tick atau melarang overlap, bukan syarat kebenaran setelah slot menjadi row.
Pendekatan
Jalan dua kali?
Jika instance mati
Cocok saat
Cron di setiap instance
Ya, sekali per instance
Instance yang tersisa tetap menembak
Hanya jika job aman diulang
Cron di satu instance khusus
Tidak
Tidak ada yang jalan sampai ada orang yang sadar
Satu mesin dan downtime bisa diterima
Leader dengan advisory lock
Tidak selama lock masih dipegang
Postgres melepas lock saat session berakhir dan instance lain mengambil alih
Beberapa schedule dan Postgres sudah ada di stack
Slot row plus claim dengan lease
Tidak per slot; handler masih bisa mengulang
Lease habis dan worker lain merebut kembali run
Banyak instance dan butuh riwayat run
Leader adalah optimisasi murah: satu instance menjalankan tick, sisanya diam. Advisory lock tingkat session di Postgres memberikannya lewat satu panggilan, karena dokumentasi PostgreSQL menyebut lock itu dipegang sampai dilepas secara eksplisit atau sampai session berakhir. Lock itu juga bisa melarang overlap: job panjang memegang lock berkunci id schedule numerik, dan worker kedua yang gagal mengambilnya melewati run tersebut. Anggap lock sebagai petunjuk dan tetap jadikan primary key sebagai jaminan.
// Leader election lite: one dedicated connection holds a session-level advisory lock.
const LEADER_KEY = 421001; // any bigint your app agrees on; it names nothing in the database
const leader = await pool.connect(); // NOT pool.query: the lock lives on this session
const { rows } = await leader.query("SELECT pg_try_advisory_lock($1) AS got", [LEADER_KEY]);
if (rows[0].got) {
startTickLoop(); // only the leader materialises slots
} else {
leader.release(); // someone else leads; retry in a few seconds
}
// If this process dies, Postgres drops the session and the lock with it.
Advisory lock tingkat session milik satu koneksi database, jadi ambil di koneksi khusus dan bukan lewat pool yang membagikan koneksi, dan periksa dulu perilaku proxy transaction pooling sebelum mengandalkannya. Bahkan begitu, proses yang sempat terjeda bisa bangun dan mengira masih memimpin padahal instance lain sudah mengambil alih, yaitu kegagalan yang dijelaskan Martin Kleppmann untuk lock lease. Jangan biarkan leadership saja melindungi uang.
Tidak end to end, dan lebih baik mengatakannya daripada menjanjikannya. Worker melakukan efek samping, lalu menulis done. Jika ia crash di antara keduanya, scheduler tidak bisa tahu apakah efeknya terjadi. Ia bisa menjalankan ulang job, yang berisiko duplikat, atau membuangnya, yang berisiko hilang. Anda memilih at-least-once atau at-most-once; exactly-once delivery tidak ada di menu.
Yang bisa dibangun adalah effectively-once: pengiriman at-least-once ditambah handler idempotent, sehingga run kedua tidak mengubah apa pun. Slot memberi idempotency key yang sempurna, karena slot yang sama selalu membawa nama schedule dan waktu terjadwal yang sama, berapa pun jumlah attempt-nya. Counter attempts memagari row milik scheduler sendiri, karena worker zombie yang lease-nya sudah direbut akan cocok dengan nol row saat mencoba menyelesaikan. Seperti ditunjukkan Kleppmann, fencing token hanya melindungi resource yang memeriksanya, jadi untuk dunia luar Anda butuh idempotency key.
// Wrong: a retry or a reclaimed lease sends the second invoice.
await db.query("INSERT INTO invoices (customer_id, amount) VALUES ($1, $2)", [id, amount]);
// Right: the slot is the idempotency key, so the second attempt is a no-op.
const periodKey = name + ":" + slot.toISOString(); // "monthly-billing:2026-11-01T00:00:00.000Z"
await db.query(
`INSERT INTO invoices (period_key, customer_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (period_key, customer_id) DO NOTHING`,
[periodKey, id, amount],
);
Teruskan key yang sama ke apa pun yang Anda panggil. Penyedia pembayaran dan email umumnya menerima idempotency key pada request, jadi berikan juga key slot kepada mereka, dan pertahankan unique constraint Anda sendiri sebagai pengaman terakhir. Post saya tentang idempotency key di desain API membahas cara membangun sisi penerimanya.
Apa yang terjadi pada run yang terlewat saat scheduler mati?
Putuskan per job, di muka, karena jawaban yang benar berbeda-beda. Ambil job tiap 15 menit dan outage dari 10:02 sampai 10:52. Slot 10:15, 10:30, dan 10:45 jatuh tempo dan terlewat, jadi 3 slot. Ada tiga kebijakan yang jujur.
Skip: jalankan 0 run dan lanjut di 11:00. Tepat untuk reminder yang tidak ada gunanya terlambat sejam.
Run once: jalankan 1 run, berlabel slot terlewat terbaru, 10:45. Tepat untuk refresh atau sync yang hanya peduli state terbaru.
Run all: jalankan 3 run, satu per slot yang terlewat. Tepat ketika tiap slot adalah fakta tersendiri, seperti penutupan per periode yang harus ada untuk setiap periode.
-- Catch-up policy "run once": jump next_run_at past now() instead of one slot at a time.
SET next_run_at = d.next_run_at + d.every * (
1 + floor(extract(epoch FROM (now() - d.next_run_at)) / extract(epoch FROM d.every))
)
Tick di atas memajukan satu slot per putaran, yaitu run-all. Statement di atas mengubahnya menjadi run-once dengan melompatkan next_run_at melewati now(). Untuk menyatakan skip, tambahkan batas keterlambatan maksimum dan tandai slot yang lebih tua dari itu sebagai skipped, bukan pending. Apa pun pilihannya, tulis di samping schedule agar engineer berikutnya tidak menemukannya ulang saat insiden.
Bagaimana retry dan backoff seharusnya bekerja?
Retry dengan exponential backoff dan full jitter, dan batasi baik delay maupun jumlah attempt. AWS Builders Library menjelaskan full jitter sebagai tidur selama waktu acak antara nol dan batas atas eksponensial, supaya klien yang gagal bersamaan tidak retry bersamaan. Dengan base 30 detik dan cap 900 detik, batas atas untuk attempt 1 sampai 6 adalah 30, 60, 120, 240, 480, lalu 960 yang dipotong menjadi 900 detik. Jumlahnya 1.830 detik, sekitar 30,5 menit, dan dengan full jitter rata-rata tunggunya setengah dari tiap batas atas, jadi sekitar 15 menit. Setelah attempt 6, run masuk ke dead untuk ditangani manusia. Berikut worker loop dengan lease, heartbeat, dan penulisan yang dipagari.
import { Pool } from "pg";
const pool = new Pool();
const LEASE_SECONDS = 60;
const HEARTBEAT_MS = 20_000; // a third of the lease: two missed beats still leave margin
const MAX_ATTEMPTS = 6;
const BASE_SECONDS = 30;
const CAP_SECONDS = 900;
// Full jitter: wait a random time between 0 and the exponential ceiling.
function backoffSeconds(attempt: number): number {
const ceiling = Math.min(CAP_SECONDS, BASE_SECONDS * 2 ** (attempt - 1));
return Math.random() * ceiling;
}
// Every write is fenced on attempts: a worker whose lease was taken over matches 0 rows.
const FINISH_SQL = `UPDATE job_runs SET status = 'done', finished_at = now(), locked_until = NULL
WHERE schedule_name = $1 AND scheduled_for = $2 AND attempts = $3`;
const HEARTBEAT_SQL = `UPDATE job_runs SET locked_until = now() + make_interval(secs => $4)
WHERE schedule_name = $1 AND scheduled_for = $2 AND attempts = $3 AND status = 'running'`;
const FAIL_SQL = `UPDATE job_runs
SET status = CASE WHEN attempts >= $4 THEN 'dead' ELSE 'pending' END,
run_after = now() + make_interval(secs => $5),
last_error = $6, locked_until = NULL
WHERE schedule_name = $1 AND scheduled_for = $2 AND attempts = $3`;
async function runOnce(workerId: string, handler: (slot: Date) => Promise<void>) {
const { rows } = await pool.query(CLAIM_SQL, [workerId, LEASE_SECONDS]);
if (rows.length === 0) return;
const { schedule_name: name, scheduled_for: slot, attempts } = rows[0];
const beat = setInterval(
() => pool.query(HEARTBEAT_SQL, [name, slot, attempts, LEASE_SECONDS]),
HEARTBEAT_MS,
);
try {
await handler(slot); // must be safe to run twice: see the handler section
await pool.query(FINISH_SQL, [name, slot, attempts]);
} catch (err) {
await pool.query(FAIL_SQL, [name, slot, attempts, MAX_ATTEMPTS,
backoffSeconds(attempts), String(err)]);
} finally {
clearInterval(beat);
}
}
Dua detail penting. Pisahkan error yang tidak bisa diperbaiki dengan retry, seperti kegagalan validasi, dan kirim langsung ke dead daripada menghabiskan enam attempt. Dan pastikan jendela retry muat dalam jadwal: jendela retry 30 menit pada job 5 menit tumpang tindih dengan enam slot berikutnya, jadi larang overlap dengan lock per schedule atau terima bahwa job bisa jalan bersamaan dengan retry-nya sendiri.
Apa yang harus dimonitor pada job scheduler?
Scheduler gagal dengan diam: tidak ada yang crash, sebuah job hanya berhenti terjadi. Jadi beri alert pada tidak adanya keberhasilan, bukan hanya pada error. Tiga angka per schedule mencakup sebagian besar kebutuhan, dan satu query menghasilkannya.
-- Three numbers per schedule that tell you the scheduler is healthy.
SELECT schedule_name,
now() - max(finished_at) FILTER (WHERE status = 'done') AS since_last_success,
count(*) FILTER (WHERE status = 'dead') AS dead_runs,
now() - min(scheduled_for) FILTER (WHERE status = 'pending') AS oldest_pending_age
FROM job_runs
GROUP BY schedule_name;
Beri alert saat since_last_success melebihi dua interval, misalnya 10 menit pada job 5 menit. Ini menangkap scheduler yang berhenti, yang tidak pernah terlihat oleh alert error.
Beri alert saat dead_runs di atas nol. Dead run adalah job yang menghabiskan attempt-nya dan butuh manusia.
Pantau oldest_pending_age. Nilai yang terus naik berarti worker terlalu sedikit atau terlalu lambat, meski tiap run akhirnya sukses.
Catat satu baris terstruktur per attempt berisi nama schedule, slot, nomor attempt, dan id worker, agar satu slot bisa diikuti lintas retry dan worker.
Uji kegagalannya dengan sengaja: bunuh worker di tengah run dan pastikan worker lain menyelesaikan slot dalam lease ditambah interval poll, sekitar 65 detik dengan angka di atas.
Jangan page untuk satu attempt yang gagal; retry ada untuk menyerapnya. Page untuk tiga sinyal di atas, dan simpan log attempt untuk post-mortem.
Scheduler tidak boleh bergantung pada jam untuk memutuskan siapa menjalankan job. Jadikan tiap slot sebuah row dengan key unik, claim dengan lease, anggap job kadang jalan dua kali, dan buat handler tidak berbahaya saat itu terjadi. Lalu tentukan catch-up policy, beri jitter pada retry, dan beri alert pada keheningan.