Job scheduler batch Spring Boot sering dijalankan untuk mentransfer data antar sistem eksternal. Artikel ini langsung menjawab bagaimana mengidentifikasi dan memperbaiki deadlock yang terjadi saat scheduler tersebut melakukan transfer data: dari gejala timeout dan backlog, langkah reproduksi di lingkungan development, analisis root cause, sampai perbaikan konkret.

Konteks Sistem dan Arsitektur Scheduler

Tim membangun satu layanan Spring Boot yang mengambil data dari database SQL, memprosesnya, lalu mengirimkan hasil ke message broker. Scheduler dijalankan setiap 5 menit, memanggil service yang membuka transaksi, membaca batch dari tabel transfer_queue, dan menulis hasil ke tabel transfer_log sebelum mengirim pesan.

Arsitektur singkat:

  • Scheduler: @Scheduled(fixedDelay = 300000).
  • TransferService: 3 langkah—read & lock row, proccess business logic, mark as completed.
  • Transaksi: @Transactional dengan isolation default database.
  • Thread pool: default SimpleAsyncTaskExecutor karena perintah async pada service layer.

Gejala dan Observasi

Gejala nyata di produksi:

  • Scheduler melaporkan timeout karena DataAccessException saat menulis log dan request menunggu lebih dari 30 detik.
  • Queue backlog meningkat dan transfer job mengendap.
  • Thread dump menunjukkan beberapa thread sedang menunggu java.sql.Connection dan terjebak dalam synchronized block internal.
  • Database menunjukkan banyak lock wait dengan query SELECT ... FOR UPDATE dari job lain.

Observabilitas penting: gunakan metrics dari Spring Actuator dan SQL slow log untuk mengidentifikasi kapan scheduler mulai menumpuk.

Langkah Reproduksi Lokal

Reproduksi dibuat dengan:

  1. Membuat dua job scheduler paralel dengan @Async dan fixedDelay sama.
  2. Menjalankan SELECT ... FOR UPDATE dalam transaction pada row overlapping.
  3. Memaksa batch size besar sehingga satu transaksi memegang lock lama.

Dengan profiler (VisualVM atau jconsole), terlihat deadlock antar thread: satu menunggu transaksi SQL, lain menunggu synchronized pada cache batch sementara connection pool habis.

Analisis Root Cause

Root cause utama:

  • Transaksi panjang karena proses batch besar dan log tambahan. Lock row untuk iterasi SQL menjaga agar data tidak diproses ganda.
  • Thread pool terbatas akibat SimpleAsyncTaskExecutor yang membuat task baru tanpa batas, menghabiskan connection pool.
  • Synchronized block pada cache batch (misalnya synchronized (batchTracker)) menunggu thread lain yang ternyata sedang menunggu transaksi database.

Kombinasi lock database + synchronized menghasilkan deadlock: thread A memegang database lock, menunggu synchronized; thread B memegang synchronized, menunggu connection dari pool yang dipakai thread A.

Perbaikan Konkrit

1. Batasi ukuran batch dan kurangi durasi transaksi

Ubah query menjadi membatasi row per iterasi:

SELECT * FROM transfer_queue WHERE status = 'READY' ORDER BY created_at LIMIT :batchSize FOR UPDATE

Set batchSize menjadi 50-100 berdasarkan throughput. Setelah proses, commit transaksi sebelum memanggil service eksternal.

2. Atur transaction isolation dan timeout

Gunakan isolation level READ_COMMITTED untuk menghindari lock yang tidak perlu dan set timeout pendek:

@Transactional(isolation = Isolation.READ_COMMITTED, timeout = 10)
public void processBatch(...) { ... }

Transaction timeout mencegah thread menggantung terlalu lama.

3. Konfigurasi executor async yang bounded

Jangan mengandalkan SimpleAsyncTaskExecutor; definisikan bean executor dengan pool terbatas:

@Bean(name = "transferExecutor")
public ThreadPoolTaskExecutor transferExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(20);
    executor.setThreadNamePrefix("transfer-");
    executor.initialize();
    return executor;
}

Gunakan executor ini pada @Async("transferExecutor") agar connection pool tidak overcommit.

4. Minimalkan synchronized dan gunakan reentrant locks

Alih-alih synchronized di metode shared state, pertimbangkan ReentrantLock dengan tryLock dan timeout singkat agar tidak menunggu forever.

5. Observability dan alerting

Implementasikan metrics:

  • Spring Actuator untuk scheduled.task.running.
  • Micrometer meter untuk latency batch.
  • Alert ketika backlog queue > threshold atau jumlah thread blocked tinggi.

Thread dump otomatis (misalnya dengan jcmd) membantu melihat deadlock.

Takeaways untuk Developer

  • Deadlock scheduler sering disebabkan kombinasi lock database + lock JVM; observability harus menangkap kedua sisi.
  • Batch besar perlu dibagi, transaksi dibuat pendek, dan executor dibatasi agar resource tidak habis.
  • Gunakan isolation level yang sesuai dan set timeout agar transaksi tidak hang.
  • Synchronized pada shared state harus dipertimbangkan kembali bila logika lain menunggu resource yang sama.
  • Monitoring backlog dan thread state memungkinkan deteksi dini deadlock sebelum backlog memuncak.

Dengan mengevaluasi konfigurasi transaction, executor, dan observability, jadwal transfer data bisa berjalan stabil tanpa deadlock.