Cache menyembunyikan pekerjaan berat dari request, tetapi sebagian pekerjaan tidak dipicu request sama sekali: pengarsipan surel tiap malam, sinkronisasi stok tiap sepuluh detik, pengiriman metrik berkala. Pekerjaan berkala butuh scheduler — komponen yang membangunkan method pada waktunya tanpa ada yang memanggil. Spring menyediakannya lewat @Scheduled, satu anotasi dengan tiga semantik berbeda. Pada Chapter ini kita bedah ketiganya, membaca cron expression, menyiasati scheduler satu thread, lalu membandingkannya dalam waktu virtual.

Mengaktifkan Penjadwalan #

Dua bagian harus hadir: @EnableScheduling pada kelas konfigurasi untuk menyalakan infrastruktur, dan @Scheduled pada method yang dijadwalkan — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@SpringBootApplication
@EnableScheduling
public class AplikasiToko { ... }

@Component
public class PekerjaBerkala {

    @Scheduled(fixedRate = 10_000)             // tiap 10 detik sejak AWAL eksekusi
    public void sinkronStok() { ... }

    @Scheduled(fixedDelay = 10_000, initialDelay = 30_000)
    public void arsipEmail() { ... }            // 10 detik SETELAH eksekusi selesai

    @Scheduled(cron = "0 15 8 * * *")
    public void laporanPagi() { ... }           // tiap hari pukul 08:15:00
}

Method terjadwal wajib tanpa parameter dan bawaannya mengembalikan void; hasil balikan diabaikan. Nilai waktu pada atribut berformat durasi ISO-8601 (PT10S) atau milidetik; initialDelay menunda eksekusi pertama. Tiga semantiknya perlu dipisah tegas karena salah pilih mengubah ritme aplikasi:

ModeJeda dihitung dariCocok untuk
fixedRateawal eksekusi sebelumnyatugas pendek berirama tetap (metrik)
fixedDelayakhir eksekusi sebelumnyatugas yang tidak boleh bertumpuk (arsip, sinkronisasi)
cronjam dindingtugas berkalender (laporan harian, tagihan bulanan)

fixedRate menjaga irama: bila satu eksekusi terlambat, jadwal berikutnya tetap jatuh pada waktunya. fixedDelay menjaga jarak aman: interval diukur setelah pekerjaan tuntas, sehingga pekerjaan lambat otomatis melonggarkan ritme dan tidak pernah bertumpuk.

Membaca Cron Expression #

Mode cron memakai enam field berurutan: detik menit jam tanggal bulan hari. Setiap field menerima angka tetap, rentang 8-18, daftar 1,15,30, langkah */15, dan * untuk “setiap nilai”. Beberapa pola yang sering dipakai:

EkspresiArti
0 15 8 * * *setiap hari pukul 08:15:00
*/15 * * * * *setiap 15 detik
0 0 9-17 * * MON-FRItiap jam 09.00–17.00 pada hari kerja
0 0 2 1 * *pukul 02:00 setiap tanggal 1

Ada juga makro khusus Spring: @Scheduled(cron = "@daily") setara 0 0 0 * * *, di samping @hourly dan @weekly. Cron dihitung terhadap zona waktu server — server di UTC menjalankan “laporan pagi 08:15” pada 15:15 WIB; tetapkan zone = "Asia/Jakarta" bila ritme mengikuti jam lokal.

Jebakan Satu Thread #

Secara bawaan, seluruh method @Scheduled berbagi satu thread scheduler. Selama sebuah tugas berjalan, tugas lain yang jadwalnya jatuh menunggu di antrean — eksekusi bisa tergeser beberapa detik tanpa satu pun error muncul. Saat beban bertambah, konfigurasikan pool:

java
@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(4);
    scheduler.setThreadNamePrefix("jadwal-");
    return scheduler;
}

Alternatif modern: spring.threads.virtual.enabled=true membuat scheduler Boot memakai virtual threads — tiap eksekusi mendapat thread murah sendiri dan antrean menyusut. Untuk tugas berat, padanan tepatnya @Async — topik Chapter berikutnya.

Waktu Virtual sebagai Kaca Pembesar #

Membandingkan fixedRate dan fixedDelay dengan Thread.sleep berarti menunggu menit penuh di dunia nyata. Solusinya: jam virtual yang hanya bergerak atas perintah kita. Program berikut menjalankan tiga tugas pada scheduler satu thread selama “60 detik” — sinkron-stok (fixedRate 10 detik, durasi 2 detik), arsip-email (fixedDelay 10 detik, durasi 6 detik), dan metrik-kirim (cron */15, durasi 2 detik):

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class JadwalVirtual {

    static final DateTimeFormatter JAM =
            DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.ROOT);

    // Satu tugas terjadwal: jenis, periode, durasi eksekusi, dan jadwal berikutnya.
    static class Tugas {

        final String nama;
        final String jenis;   // "fixedRate" | "fixedDelay" | "cron"
        final long periodeMs;
        final long durasiMs;
        final String polaCron;   // hanya untuk jenis cron
        long berikutnya;         // waktu jadwal berikutnya (ms virtual)
        int eksekusi = 0;

        Tugas(String nama, String jenis, long periodeMs, long durasiMs, String polaCron) {
            this.nama = nama;
            this.jenis = jenis;
            this.periodeMs = periodeMs;
            this.durasiMs = durasiMs;
            this.polaCron = polaCron;
            this.berikutnya = 0;   // semua tugas dijadwalkan pada 08:00:00.000
        }
    }

    // Cron mini: hanya field detik pola "*/n" (lainnya wajib "*").
    static long cronBerikutnya(String pola, long kini) {
        String[] field = pola.split(" ");
        for (int i = 1; i < field.length; i++) {
            if (!field[i].equals("*")) {
                throw new IllegalArgumentException("contoh hanya mendukung */n pada detik");
            }
        }
        long n = Long.parseLong(field[0].substring(2)) * 1000;
        return (kini / n + 1) * n;   // kelipatan n BERIKUTNYA setelah kini
    }

    public static void main(String[] args) {
        List<Tugas> daftar = new ArrayList<>();
        daftar.add(new Tugas("sinkron-stok", "fixedRate", 10_000, 2_000, null));
        daftar.add(new Tugas("arsip-email", "fixedDelay", 10_000, 6_000, null));
        daftar.add(new Tugas("metrik-kirim", "cron", 0, 2_000, "*/15 * * * * *"));

        System.out.println("Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00");
        System.out.println("sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)");
        System.out.println("arsip-email  : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)");
        System.out.println("metrik-kirim : cron \"*/15 * * * * *\", durasi 2s");
        System.out.println();

        long batas = 60_000;
        long jam = 0;   // jam virtual dalam milidetik sejak 08:00:00.000

        while (true) {
            // Pilih tugas dengan jadwal terkecil; seri mengikuti urutan pendaftaran.
            Tugas terpilih = null;
            for (Tugas t : daftar) {
                if (t.berikutnya >= batas) {
                    continue;
                }
                if (terpilih == null || t.berikutnya < terpilih.berikutnya) {
                    terpilih = t;
                }
            }
            if (terpilih == null) {
                break;
            }

            // Eksekusi berjalan saat thread bebas, bukan saat jadwal jatuh.
            long mulai = Math.max(jam, terpilih.berikutnya);
            long keterlambatan = mulai - terpilih.berikutnya;
            long selesai = mulai + terpilih.durasiMs;
            jam = selesai;

            System.out.printf("[%s] MULAI   %s%s%n", fmt(mulai), terpilih.nama,
                    keterlambatan > 0 ? "  (antre " + keterlambatan + " ms)" : "");
            System.out.printf("[%s] SELESAI %s%n", fmt(selesai), terpilih.nama);
            terpilih.eksekusi++;

            terpilih.berikutnya = switch (terpilih.jenis) {
                case "fixedRate" -> mulai + terpilih.periodeMs;
                case "fixedDelay" -> selesai + terpilih.periodeMs;
                case "cron" -> cronBerikutnya(terpilih.polaCron, selesai);
                default -> throw new IllegalStateException();
            };
        }

        System.out.println();
        System.out.println("Rekap dalam 60 detik:");
        for (Tugas t : daftar) {
            System.out.printf("  %-12s %2d eksekusi%n", t.nama, t.eksekusi);
        }
    }

    static String fmt(long ms) {
        // 08:00:00.000 + ms (jam virtual beku, bukan jam dinding).
        return LocalTime.ofNanoOfDay((8 * 3600_000L + ms) * 1_000_000).format(JAM);
    }
}

Output yang Dihasilkan:

text
Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00
sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)
arsip-email  : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)
metrik-kirim : cron "*/15 * * * * *", durasi 2s

[08:00:00.000] MULAI   sinkron-stok
[08:00:02.000] SELESAI sinkron-stok
[08:00:02.000] MULAI   arsip-email  (antre 2000 ms)
[08:00:08.000] SELESAI arsip-email
[08:00:08.000] MULAI   metrik-kirim  (antre 8000 ms)
[08:00:10.000] SELESAI metrik-kirim
[08:00:10.000] MULAI   sinkron-stok
[08:00:12.000] SELESAI sinkron-stok
[08:00:15.000] MULAI   metrik-kirim
[08:00:17.000] SELESAI metrik-kirim
[08:00:18.000] MULAI   arsip-email
[08:00:24.000] SELESAI arsip-email
[08:00:24.000] MULAI   sinkron-stok  (antre 4000 ms)
[08:00:26.000] SELESAI sinkron-stok
[08:00:30.000] MULAI   metrik-kirim
[08:00:32.000] SELESAI metrik-kirim
[08:00:34.000] MULAI   sinkron-stok
[08:00:36.000] SELESAI sinkron-stok
[08:00:36.000] MULAI   arsip-email  (antre 2000 ms)
[08:00:42.000] SELESAI arsip-email
[08:00:44.000] MULAI   sinkron-stok
[08:00:46.000] SELESAI sinkron-stok
[08:00:46.000] MULAI   metrik-kirim  (antre 1000 ms)
[08:00:48.000] SELESAI metrik-kirim
[08:00:52.000] MULAI   arsip-email
[08:00:58.000] SELESAI arsip-email
[08:00:58.000] MULAI   sinkron-stok  (antre 4000 ms)
[08:01:00.000] SELESAI sinkron-stok

Rekap dalam 60 detik:
  sinkron-stok  6 eksekusi
  arsip-email   4 eksekusi
  metrik-kirim  4 eksekusi

Mari kita bedah peristiwa pentingnya:

  1. Tugas dan pemilihan terpilih: loop utama memilih tugas dengan berikutnya terkecil; seri mengikuti urutan pendaftaran — persis antrean scheduler satu thread. berikutnya dihitung berbeda per jenis: mulai + periode (fixedRate), selesai + periode (fixedDelay), dan kelipatan cron berikutnya setelah selesai.
  2. Math.max(jam, terpilih.berikutnya): eksekusi dimulai saat thread bebas, bukan saat jadwal jatuh — selisihnya tercetak sebagai (antre N ms). arsip-email dijadwalkan 08:00:00.000 tetapi baru berjalan 08:00:02.000 karena sinkron-stok memegang thread — gejala scheduler satu thread dalam bentuk teramati.
  3. metrik-kirim (antre 8000 ms): cron */15 jatuh pada detik 0, 15, 30, 45, namun detik ke-0 bentrok dengan dua tugas lain — cron yang tepat waktu di atas kertas tergeser ketika scheduler sibuk. Menaikkan poolSize atau memindahkan tugas ke @Async mengembalikan ketepatan itu.
  4. Ritme arsip-email (fixedDelay): eksekusi selesai pukul 08:00:08.000, jadwal berikutnya 08:00:18.000 — jarak diukur dari selesai, sehingga durasi 6 detik tidak pernah mempersempit jeda 10 detik. Bandingkan dengan sinkron-stok: eksekusi kedua dimulai tepat 08:00:10.000 meski sebelumnya selesai 08:00:02.000 — irama tetap kencang karena jeda diukur dari mulai.
  5. Rekap 60 detik: 6 eksekusi fixedRate (tiap 10 detik), 4 eksekusi fixedDelay (16 detik efektif per siklus), 4 eksekusi cron — angka yang bisa dihitung tangan dari durasi dan mode masing-masing.

Penjadwalan menyuruh method berjalan pada waktu tertentu. Pertanyaan berikutnya: bagaimana method berjalan tanpa membuat pemanggil menunggu — pekerjaan latar seperti kirim surel konfirmasi atau proses unggahan besar. Itulah ranah @Async dan CompletableFuture pada Chapter Pemrosesan Asinkron.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

JadwalVirtual.java

Unduh berkas
java
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class JadwalVirtual {

    static final DateTimeFormatter JAM =
            DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.ROOT);

    // Satu tugas terjadwal: jenis, periode, durasi eksekusi, dan jadwal berikutnya.
    static class Tugas {

        final String nama;
        final String jenis;   // "fixedRate" | "fixedDelay" | "cron"
        final long periodeMs;
        final long durasiMs;
        final String polaCron;   // hanya untuk jenis cron
        long berikutnya;         // waktu jadwal berikutnya (ms virtual)
        int eksekusi = 0;

        Tugas(String nama, String jenis, long periodeMs, long durasiMs, String polaCron) {
            this.nama = nama;
            this.jenis = jenis;
            this.periodeMs = periodeMs;
            this.durasiMs = durasiMs;
            this.polaCron = polaCron;
            this.berikutnya = 0;   // semua tugas dijadwalkan pada 08:00:00.000
        }
    }

    // Cron mini: hanya field detik pola "*/n" (lainnya wajib "*").
    static long cronBerikutnya(String pola, long kini) {
        String[] field = pola.split(" ");
        for (int i = 1; i < field.length; i++) {
            if (!field[i].equals("*")) {
                throw new IllegalArgumentException("contoh hanya mendukung */n pada detik");
            }
        }
        long n = Long.parseLong(field[0].substring(2)) * 1000;
        return (kini / n + 1) * n;   // kelipatan n BERIKUTNYA setelah kini
    }

    public static void main(String[] args) {
        List<Tugas> daftar = new ArrayList<>();
        daftar.add(new Tugas("sinkron-stok", "fixedRate", 10_000, 2_000, null));
        daftar.add(new Tugas("arsip-email", "fixedDelay", 10_000, 6_000, null));
        daftar.add(new Tugas("metrik-kirim", "cron", 0, 2_000, "*/15 * * * * *"));

        System.out.println("Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00");
        System.out.println("sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)");
        System.out.println("arsip-email  : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)");
        System.out.println("metrik-kirim : cron \"*/15 * * * * *\", durasi 2s");
        System.out.println();

        long batas = 60_000;
        long jam = 0;   // jam virtual dalam milidetik sejak 08:00:00.000

        while (true) {
            // Pilih tugas dengan jadwal terkecil; seri mengikuti urutan pendaftaran.
            Tugas terpilih = null;
            for (Tugas t : daftar) {
                if (t.berikutnya >= batas) {
                    continue;
                }
                if (terpilih == null || t.berikutnya < terpilih.berikutnya) {
                    terpilih = t;
                }
            }
            if (terpilih == null) {
                break;
            }

            // Eksekusi berjalan saat thread bebas, bukan saat jadwal jatuh.
            long mulai = Math.max(jam, terpilih.berikutnya);
            long keterlambatan = mulai - terpilih.berikutnya;
            long selesai = mulai + terpilih.durasiMs;
            jam = selesai;

            System.out.printf("[%s] MULAI   %s%s%n", fmt(mulai), terpilih.nama,
                    keterlambatan > 0 ? "  (antre " + keterlambatan + " ms)" : "");
            System.out.printf("[%s] SELESAI %s%n", fmt(selesai), terpilih.nama);
            terpilih.eksekusi++;

            terpilih.berikutnya = switch (terpilih.jenis) {
                case "fixedRate" -> mulai + terpilih.periodeMs;
                case "fixedDelay" -> selesai + terpilih.periodeMs;
                case "cron" -> cronBerikutnya(terpilih.polaCron, selesai);
                default -> throw new IllegalStateException();
            };
        }

        System.out.println();
        System.out.println("Rekap dalam 60 detik:");
        for (Tugas t : daftar) {
            System.out.printf("  %-12s %2d eksekusi%n", t.nama, t.eksekusi);
        }
    }

    static String fmt(long ms) {
        // 08:00:00.000 + ms (jam virtual beku, bukan jam dinding).
        return LocalTime.ofNanoOfDay((8 * 3600_000L + ms) * 1_000_000).format(JAM);
    }
}