Tampilan
Kenapa penting
Hampir semua aplikasi backend punya pekerjaan yang tidak dipicu request pengguna: mengirim pengingat tagihan tiap pagi, membatalkan pesanan yang tidak dibayar dalam 24 jam, menghapus token kedaluwarsa, menjalankan relay outbox dari bab sebelumnya, atau rekonsiliasi pembayaran dengan bank. Menulis @Scheduled itu mudah; membuatnya benar di production yang jauh lebih sulit. Begitu aplikasi berjalan di beberapa instance, job bisa jalan dobel. Begitu datanya besar, job bisa mengunci database. Begitu job crash di tengah jalan, kamu harus tahu apakah aman menjalankannya ulang. Bab ini membahas penjadwalan di Spring Boot beserta pola agar job aman, idempoten, dan bisa dipantau.
Konsep inti
@EnableScheduling dan @Scheduled
Penjadwalan diaktifkan sekali di kelas konfigurasi, lalu method bean diberi @Scheduled. Method tidak boleh punya parameter dan umumnya void (nilai kembalian diabaikan).
java
// Potongan — Spring Boot 4
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@Component
public class HousekeepingJobs {
@Scheduled(fixedRate = 60_000) // tiap 60 detik, diukur dari mulai ke mulai
public void refreshExchangeRates() { /* ... */ }
@Scheduled(fixedDelay = 5, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
public void publishOutbox() { /* ... */ } // jeda 5 detik setelah eksekusi sebelumnya selesai
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Jakarta")
public void purgeExpiredSessions() { /* ... */ } // tiap hari 02.00 WIB
}| Mode | Diukur dari | Cocok untuk |
|---|---|---|
fixedRate | mulai ke mulai | sampling berkala yang durasinya pendek dan stabil |
fixedDelay | selesai ke mulai | polling (relay outbox, antrean) — tidak menumpuk bila satu eksekusi lambat |
cron | jadwal kalender | job harian/mingguan yang terikat jam bisnis |
Satuan default fixedRate/fixedDelay adalah milidetik; atribut timeUnit mengubahnya. Nilai sebaiknya dari konfigurasi agar bisa diubah tanpa build ulang, misalnya @Scheduled(cron = "${jobs.reminder.cron}", zone = "Asia/Jakarta").
Ekspresi cron Spring dan zona waktu
Cron Spring punya enam field, bukan lima seperti cron Unix: detik, menit, jam, tanggal, bulan, hari dalam minggu.
text
┌───────── detik (0-59)
│ ┌─────── menit (0-59)
│ │ ┌───── jam (0-23)
│ │ │ ┌─── tanggal (1-31)
│ │ │ │ ┌─ bulan (1-12 atau JAN-DEC)
│ │ │ │ │ ┌ hari (0-7 atau MON-SUN)
0 0 2 * * * tiap hari 02.00
0 */15 * * * * tiap 15 menit
0 0 8 * * MON-FRI hari kerja 08.00
0 0 0 1 * * tengah malam tanggal 1 tiap bulanTanpa zone, cron memakai zona default JVM — dan container production biasanya UTC. Jadwal "jam 2 pagi" pun diam-diam menjadi jam 9 pagi WIB, tepat di jam sibuk. Selisihnya bisa kamu cek dengan java.time, seperti di contoh lengkap di bawah: 02.00 WIB sama dengan 19.00 UTC hari sebelumnya.
Pilih jam yang aman
Indonesia tidak memakai daylight saving time, tetapi banyak zona lain memakainya. Di zona seperti itu, jam 02.00–03.00 bisa terlewat atau terulang saat pergantian musim. Untuk job global, jadwalkan di UTC atau hindari jam rawan tersebut.
Thread scheduler dan virtual threads
Secara default Spring Boot membuat satu ThreadPoolTaskScheduler dengan satu thread. Artinya semua job berbagi satu thread: job laporan yang macet 10 menit membuat relay outbox ikut tertunda. Ada dua jalan keluar:
yaml
# application.yml — pilih salah satu
spring:
task:
scheduling:
pool:
size: 4 # pool platform thread untuk scheduler
threads:
virtual:
enabled: true # Boot memakai SimpleAsyncTaskScheduler dengan virtual threadsDengan virtual threads aktif (Java 21+), Boot memakai SimpleAsyncTaskScheduler yang menjalankan tiap eksekusi di virtual thread dan mengabaikan properti pool. Ini cocok untuk job yang banyak menunggu I/O (HTTP, database). Tetap ingat: virtual thread tidak membuat database punya koneksi lebih banyak — batasnya tetap ukuran connection pool.
Job dobel di banyak instance dan ShedLock
@Scheduled bekerja per JVM. Deploy tiga replika berarti tiga eksekusi tiap jadwal. Solusi yang paling umum adalah ShedLock: sebelum menjalankan job, instance mencoba mengambil lock di tabel database bersama; yang gagal mendapat lock melewati eksekusi itu.
xml
<!-- pom.xml — cek versi terbaru di repositori ShedLock -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>${shedlock.version}</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>${shedlock.version}</version>
</dependency>sql
-- Migrasi Flyway
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);java
// Potongan — konfigurasi ShedLock
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // pakai jam database, bukan jam tiap pod
.build());
}
}Dua parameter penting di @SchedulerLock: lockAtMostFor adalah batas pengaman bila pemegang lock mati (lock tetap kedaluwarsa), dan lockAtLeastFor menahan lock minimal sekian lama agar instance lain dengan jam sedikit berbeda tidak menjalankan ulang job yang sangat singkat. ShedLock bukan penjadwal terdistribusi: ia tidak membagi kerja antar-node, hanya mencegah eksekusi bersamaan. Untuk job polling seperti relay outbox, SELECT ... FOR UPDATE SKIP LOCKED dari bab sebelumnya justru lebih baik karena semua instance bisa ikut bekerja.
Job idempoten dan bisa dilanjutkan
Lock bisa kedaluwarsa saat job masih berjalan, deploy bisa mematikan pod di tengah job, dan operator bisa memicu job manual. Karena itu job harus:
- Idempoten — menjalankannya dua kali tidak menggandakan efek. Contoh: catat
reminder_sent_atper invoice dan hanya pilih yang masihNULL. - Bisa dilanjutkan — kerjakan dalam batch kecil dan simpan progres (checkpoint) setelah tiap batch, sehingga jalan ulang mulai dari posisi terakhir, bukan dari nol.
Simulasi Java murni berikut menunjukkan job yang crash setelah satu batch lalu melanjutkan dari checkpoint:
java
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
public class ResumableJobDemo {
record Invoice(long id, String status) {}
static final List<Invoice> invoices = new ArrayList<>();
static long checkpoint = 0; // id terakhir yang sudah diproses (di production: tabel DB)
static List<Invoice> nextBatch(int size) {
return invoices.stream()
.filter(inv -> inv.id() > checkpoint && inv.status().equals("OVERDUE"))
.limit(size)
.toList();
}
static void runJob(int batchSize, int crashAfterBatches) {
int batches = 0;
List<Invoice> batch;
while (!(batch = nextBatch(batchSize)).isEmpty()) {
if (batches == crashAfterBatches) {
System.out.println("Crash! checkpoint=" + checkpoint);
return;
}
for (Invoice inv : batch) {
System.out.println("Kirim pengingat invoice " + inv.id());
}
checkpoint = batch.getLast().id(); // simpan progres setelah batch selesai
batches++;
}
System.out.println("Selesai, checkpoint=" + checkpoint);
}
public static void main(String[] args) {
for (long id = 1; id <= 7; id++) {
invoices.add(new Invoice(id, id % 3 == 0 ? "PAID" : "OVERDUE"));
}
runJob(2, 1); // jalan pertama crash setelah 1 batch
runJob(2, -1); // jalan ulang melanjutkan dari checkpoint
var jakarta = ZonedDateTime.of(LocalDateTime.of(2026, 10, 24, 2, 0), ZoneId.of("Asia/Jakarta"));
System.out.println("02:00 WIB = " + jakarta.withZoneSameInstant(ZoneId.of("UTC")).toLocalTime() + " UTC");
}
}text
Kirim pengingat invoice 1
Kirim pengingat invoice 2
Crash! checkpoint=2
Kirim pengingat invoice 4
Kirim pengingat invoice 5
Kirim pengingat invoice 7
Selesai, checkpoint=7
02:00 WIB = 19:00 UTCInvoice 1 dan 2 tidak dikirim ulang pada jalan kedua. Di production, checkpoint bisa berupa kolom status per baris (paling sederhana) atau tabel progres job.
Batch kecil vs Spring Batch
Untuk sebagian besar job — ratusan sampai ratusan ribu baris, logika sederhana — @Scheduled + query per batch + ShedLock sudah cukup. Spring Batch layak dipertimbangkan bila kamu butuh pemrosesan jutaan baris berbasis chunk (read–process–write), restart otomatis dari titik gagal yang dicatat di JobRepository, skip/retry per item, partisi paralel, dan riwayat eksekusi job yang bisa diaudit (misalnya impor file settlement bank). Spring Batch mengurus bagaimana job berjalan; pemicunya tetap bisa @Scheduled, cron Kubernetes, atau tombol admin.
Observability job
Job yang gagal diam-diam adalah job yang paling berbahaya — tidak ada pengguna yang melapor. Minimal:
- Log awal dan akhir job beserta jumlah item yang diproses dan durasinya.
- Tangkap exception di level job dan log dengan konteks; exception yang lolos dari method
@Scheduledhanya di-log oleh error handler scheduler, lalu job dicoba lagi di jadwal berikutnya. - Metrik: Spring Framework mencatat observasi Micrometer
tasks.scheduled.executionuntuk tiap eksekusi bila observability aktif; tambahkan counter sendiri untuk item sukses/gagal. - Endpoint Actuator
/actuator/scheduledtasksmenampilkan job yang terdaftar beserta jadwalnya. - Alert untuk job yang tidak jalan, misalnya "relay outbox: baris tertua belum terkirim lebih dari 5 menit".
Contoh di dunia backend
Job harian pengingat invoice jatuh tempo: hanya satu instance yang menjalankan, diproses per batch, dan idempoten lewat kolom reminder_sent_at.
java
// Potongan — Spring Boot 4 + ShedLock
@Component
public class InvoiceReminderJob {
private static final Logger log = LoggerFactory.getLogger(InvoiceReminderJob.class);
private static final int BATCH_SIZE = 500;
private final InvoiceReminderService service;
public InvoiceReminderJob(InvoiceReminderService service) {
this.service = service;
}
@Scheduled(cron = "${jobs.invoice-reminder.cron:0 0 8 * * *}", zone = "Asia/Jakarta")
@SchedulerLock(name = "invoiceReminder", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
public void run() {
long started = System.nanoTime();
int total = 0;
int sent;
do {
sent = service.sendNextBatch(BATCH_SIZE); // satu transaksi per batch
total += sent;
} while (sent == BATCH_SIZE);
log.info("invoiceReminder selesai: {} pengingat, {} ms",
total, (System.nanoTime() - started) / 1_000_000);
}
}
@Service
public class InvoiceReminderService {
private final JdbcTemplate jdbc;
private final NotificationClient notifications;
public InvoiceReminderService(JdbcTemplate jdbc, NotificationClient notifications) {
this.jdbc = jdbc;
this.notifications = notifications;
}
@Transactional
public int sendNextBatch(int size) {
List<Long> ids = jdbc.queryForList("""
SELECT id FROM invoice
WHERE status = 'OVERDUE' AND reminder_sent_at IS NULL
ORDER BY id
LIMIT ?
FOR UPDATE SKIP LOCKED
""", Long.class, size);
for (Long id : ids) {
notifications.sendInvoiceReminder(id);
jdbc.update("UPDATE invoice SET reminder_sent_at = now() WHERE id = ?", id);
}
return ids.size();
}
}sendNextBatch dipanggil lewat bean lain (bukan this) agar proxy @Transactional aktif. Bila job mati di tengah, invoice yang sudah ditandai tidak disentuh lagi; sisanya diambil di jalan berikutnya. Pengiriman notifikasi idealnya juga lewat outbox agar email tidak terkirim untuk batch yang akhirnya di-rollback.
Pertanyaan interview
1. Apa beda fixedRate, fixedDelay, dan cron di @Scheduled?
fixedRate menjadwalkan eksekusi dengan jarak tetap dari mulai ke mulai; bila satu eksekusi lebih lama dari interval, eksekusi berikutnya mulai terlambat. fixedDelay menunggu jeda tetap setelah eksekusi sebelumnya selesai, cocok untuk polling. cron mengikuti jadwal kalender dengan enam field dan sebaiknya selalu diberi zone agar tidak bergantung pada zona server.
2. Aplikasimu berjalan di lima pod. Bagaimana memastikan job harian hanya jalan sekali?
Pakai lock terdistribusi seperti ShedLock dengan tabel di database bersama: setiap pod mencoba mengambil lock bernama sama, hanya satu yang berhasil. Atur lockAtMostFor lebih lama dari durasi normal job sebagai pengaman bila pod mati. Karena lock bisa kedaluwarsa atau job bisa dipicu ulang, job tetap dibuat idempoten. Alternatif lain: pindahkan pemicu ke Kubernetes CronJob atau scheduler eksternal.
3. Bagaimana merancang job yang memproses jutaan baris dengan aman?
Proses per batch kecil dengan transaksi pendek, pilih baris memakai kriteria yang otomatis menyempit (misalnya status atau processed_at IS NULL) atau checkpoint id terakhir, sehingga job bisa dilanjutkan setelah crash. Ukur dan log progres, batasi laju agar tidak mengganggu trafik online, dan bila butuh restart terkelola, skip/retry per item, atau partisi paralel, pertimbangkan Spring Batch.
Jebakan umum
- Lupa
@EnableScheduling— method@Scheduledtidak pernah jalan tanpa error apa pun. - Cron tanpa
zonedi container UTC, sehingga job "jam 2 pagi" berjalan jam 9 pagi WIB. - Menulis cron lima field ala Unix (
0 2 * * *) — Spring menolaknya karena butuh enam field. - Mengandalkan satu thread scheduler default untuk banyak job; satu job lambat menahan semuanya.
- Scale out tanpa lock terdistribusi sehingga setiap replika menjalankan job yang sama.
- Satu transaksi raksasa untuk seluruh data — mengunci tabel, dan bila gagal semua progres hilang.
Ringkasan
@EnableScheduling+@Scheduledmenjalankan method secara berkala; pilihfixedRate,fixedDelay, ataucronsesuai sifat job.- Cron Spring memakai enam field; selalu tentukan
zoneuntuk jadwal yang terikat jam bisnis. - Scheduler default hanya satu thread; perbesar pool atau aktifkan virtual threads untuk job yang banyak menunggu I/O.
- Di banyak instance, pakai ShedLock agar job hanya jalan sekali — dan tetap buat job idempoten.
- Proses data dalam batch kecil dengan checkpoint agar job bisa dilanjutkan; Spring Batch untuk kebutuhan chunk, restart, dan audit yang lebih berat.
- Pantau job dengan log durasi dan jumlah item, metrik, serta alert untuk job yang gagal atau tidak jalan.