Mekanisme sinkronisasi bawaan Java seperti synchronized, ReentrantLock, atau atomic primitives hanya beroperasi di tingkat JVM tunggal. Ketika aplikasi dideploy dalam arsitektur multi-node atau multi-pod (misalnya di Kubernetes), masing-masing replika memiliki ruang memori terisolasi. Akses bersama ke shared resource (database, payment gateway, stok inventaris) tanpa sinkronisasi terdistribusi akan memicu race condition, double-spending, atau data inconsistency.

Solusi standar industri untuk masalah ini adalah distributed lock menggunakan Redis. Redisson menyediakan abstraksi RLock berbasis protokol Redis yang mengimplementasikan spesifikasi java.util.concurrent.locks.Lock secara terdistribusi dan aman dari bahaya deadlock.

Akar Masalah Konkurensi pada Multi-Node Worker

Pada arsitektur pod majemuk, dua worker di pod berbeda dapat memproses pesan antrean atau request HTTP yang sama secara paralel. Contoh: Pemrosesan pesanan yang sama oleh Pod A dan Pod B akibat retry webhook.

Pod A: Baca Saldo (100) ------- Validasi OK ------- Update Saldo (50)
Pod B: -------- Baca Saldo (100) ------- Validasi OK ------- Update Saldo (50)

Hasil akhir saldo adalah 50, bukan 0. Database level lock (seperti SELECT ... FOR UPDATE) dapat mencegah ini, namun mengorbankan connection pool database dan throughput I/O secara drastis. Distributed lock memindahkan beban koordinasi status ke Redis pada layer memori berkecepatan tinggi.

Setup Dependensi dan Konfigurasi Redisson

Gunakan dependency redisson-spring-boot-starter untuk integrasi otomatis ke ekosistem Spring Boot.

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.37.0</version>
</dependency>

Inisialisasi RedissonClient melalui kelas konfigurasi terpisah jika memerlukan tuning parameter jaringan:

package com.example.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host:localhost}")
    private String host;

    @Value("${spring.data.redis.port:6379}")
    private int port;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress(String.format("redis://%s:%d", host, port))
              .setConnectionMinimumIdleSize(5)
              .setConnectionPoolSize(20)
              .setTimeout(3000);
        return Redisson.create(config);
    }
}

Implementasi RLock dengan tryLock dan Blok finally

Pola penggunaan distributed lock yang benar mensyaratkan:

  1. Memiliki timeout batas tunggu perolehan lock (agar thread tidak tertahan selamanya).
  2. Memeriksa status kepemilikan lock sebelum melepaskannya di dalam blok finally.
package com.example.service;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class PaymentProcessingService {

    private static final Logger log = LoggerFactory.getLogger(PaymentProcessingService.class);
    private final RedissonClient redissonClient;

    public PaymentProcessingService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public void processPayment(String orderId) {
        String lockKey = "lock:payment:" + orderId;
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked = false;

        try {
            // waitTime: 5 detik, leaseTime omit/default untuk mengaktifkan Watchdog
            isLocked = lock.tryLock(5, TimeUnit.SECONDS);
            if (!isLocked) {
                log.warn("Gagal memperoleh lock untuk orderId: {}. Proses sedang berjalan di node lain.", orderId);
                throw new IllegalStateException("Transaksi sedang diproses. Silakan coba kembali.");
            }

            log.info("Lock diperoleh. Menjalankan critical section untuk orderId: {}", orderId);
            executePaymentTransaction(orderId);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Thread terinterupsi saat menunggu lock orderId: {}", orderId, e);
            throw new RuntimeException("Operasi terinterupsi", e);
        } finally {
            // Pastikan lock hanya dirilis oleh thread pemilik aslinya
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("Lock dilepas untuk orderId: {}", orderId);
            }
        }
    }

    private void executePaymentTransaction(String orderId) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Watchdog Auto-Renewal vs Explicit leaseTime

Salah satu fitur krusial Redisson adalah Lock Watchdog. Memahami perbedaan antara pemanggilan dengan explicit leaseTime dan default Watchdog menentukan apakah sistem rentan terhadap pelepasan lock prematur.

1. Explicit leaseTime: tryLock(waitTime, leaseTime, unit)

  • Mekanisme: Redis menetapkan TTL kunci tepat sesuai parameter leaseTime (contoh: 10 detik). Setelah 10 detik berlalu, Redis otomatis menghapus kunci, tanpa memedulikan apakah critical section sudah selesai.
  • Kelebihan: Menjamin lock pasti lepas jika node worker mati mendadak (hard-crash) atau terjadi JVM network partition permanen.
  • Risiko: Jika proses database atau remote API memakan waktu lebih dari 10 detik, lock terlepas secara prematur. Worker lain dapat merebut lock tersebut sementara transaksi pertama masih berjalan, memicu race condition yang hendak dicegah.

2. Redisson Watchdog: tryLock(waitTime, unit)

  • Mekanisme: Bila leaseTime tidak ditentukan atau bernilai -1, Redisson mengaktifkan background task (Watchdog). Default lock timeout adalah 30 detik (lockWatchdogTimeout). Setiap sepertiga durasi timeout (10 detik), Watchdog memperpanjang TTL kembali ke 30 detik secara periodik selama thread pemegang lock masih aktif.
  • Kelebihan: Mencegah lock expired di tengah eksekusi task berdurasi dinamis.
  • Risiko: Jika thread mengalami infinite loop (bukan crash JVM), lock terus diperpanjang dan memblokir worker lain hingga proses dimatikan.

Rekomendasi: Gunakan default Watchdog untuk task bisnis kritis dengan durasi tidak pasti. Gunakan explicit leaseTime hanya untuk background job idempotent yang durasi maksimalnya sudah dapat dipastikan secara absolut.

Penanganan Edge Case: InterruptedException dan Jaringan

1. InterruptedException

Ketika thread dibatalkan saat sedang menunggu lock di tryLock(), Redis connection client melempar InterruptedException. Jangan pernah menelan exception ini:

} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // Kembalikan status interupsi JVM
    throw new CustomBusinessException("LOCK_ACQUISITION_INTERRUPTED", e);
}

2. Redis Connection Failure

Bila koneksi ke Redis kluster putus di tengah operasi, Redisson akan melempar RedisConnectionException atau RedisTimeoutException. Tentukan strategi fallback arsitektural:

  • Fail-Fast: Tolak request langsung (HTTP 503 / downstream retry) demi konsistensi data finansial.
  • Optimistic Fallback: Lanjutkan eksekusi hanya jika data dilindungi oleh layer pengaman lain (misalnya DB unique constraint).

Verifikasi Integrasi dengan Testcontainers

Uji konkurensi worker secara deterministik menggunakan instance Redis terisolasi melalui Testcontainers.

package com.example.service;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
class PaymentProcessingServiceIntegrationTest {

    @Container
    private static final GenericContainer<?> redis = 
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    private RedissonClient redissonClient;
    private PaymentProcessingService service;

    @BeforeEach
    void setUp() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort());
        redissonClient = Redisson.create(config);
        service = new PaymentProcessingService(redissonClient);
    }

    @Test
    void shouldAllowOnlyOneWorkerToEnterCriticalSection() throws InterruptedException {
        int totalThreads = 2;
        ExecutorService executor = Executors.newFixedThreadPool(totalThreads);
        CountDownLatch readyLatch = new CountDownLatch(totalThreads);
        CountDownLatch startLatch = new CountDownLatch(1);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        for (int i = 0; i < totalThreads; i++) {
            executor.submit(() -> {
                readyLatch.countDown();
                try {
                    startLatch.await(); // Seluruh thread mulai serentak
                    service.processPayment("ORD-1001");
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                }
            });
        }

        readyLatch.await();
        startLatch.countDown(); // Trigger race condition
        executor.shutdown();
        executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS);

        // Hanya 1 worker yang berhasil memproses, worker lainnya ditolak
        assertEquals(1, successCount.get());
        assertEquals(1, failCount.get());
        redissonClient.shutdown();
    }
}

Pengujian di atas membuktikan bahwa ketika dua worker mencoba mengakses ID resource yang sama secara serentak, distributed lock Redisson membatasi akses ke tepat satu eksekutor, menjaga integritas proses transaksi.