SavefileArchive
USD/IDR ...
|
BTC ...
|
ETH ...
|
GOLD/gram ...
Terbaru
SavefileArchive — Tutorial coding, tips programming, dan dunia musik untuk developer & pecinta musik Indonesia
Mengatasi Race Conditions (Part 2): Deep Dive, Isolation Level, dan Distributed Locks

Mengatasi Race Conditions (Part 2): Deep Dive, Isolation Level, dan Distributed Locks

Mengatasi Race Conditions (Part 2): Deep Dive, Isolation Level, dan Distributed Locks

Ilustrasi Race Condition

Pada artikel Part 1 sebelumnya, kita telah membahas teori dasar tentang Race Condition. Namun, di dunia nyata, teori saja tidak akan menyelamatkan aplikasi Anda dari kerugian finansial akibat transaksi ganda, stok minus saat flash sale, atau saldo e-wallet yang bertambah berkali-kali lipat dari satu kali top-up.

Di Part 2 ini, kita akan masuk ke mode Deep Dive. Kita akan membedah anatomi kode yang salah, memahami bagaimana MySQL bekerja di level terbawah (Isolation Level), hingga mengimplementasikan kode yang kebal peluru menggunakan Laravel dan Redis — lengkap dengan cara menguji dan memonitornya di production.


1. Anatomi Masalah: Kenapa Kode "Normal" Berujung Bencana?

Mari kita lihat skenario klasik: Anda sedang menjalankan flash sale sepatu limited edition yang hanya tersisa 1 stok. Ribuan user me-refresh halaman secara bersamaan.

// ❌ KODE VULNERABLE (Bahaya)
public function beliSepatu($userId, $sepatuId) {
    // 1. Baca stok saat ini
    $sepatu = Product::find($sepatuId);

    // 2. Cek apakah stok masih ada
    if ($sepatu->stock > 0) {

        // (Simulasi proses verifikasi pembayaran yang memakan waktu 500ms)
        sleep(0.5);

        // 3. Kurangi stok dan simpan
        $sepatu->stock = $sepatu->stock - 1;
        $sepatu->save();

        return "Berhasil dibeli!";
    }

    return "Stok habis!";
}

Skenario kehancuran: User A dan User B klik "Beli" di milidetik yang hampir bersamaan.

  1. User A membaca stok = 1.
  2. User B membaca stok = 1 (User A belum selesai menyimpan).
  3. Keduanya lolos pengecekan stock > 0.
  4. Keduanya menyimpan — stok menjadi -1. Reputasi toko rusak, refund wajib dilakukan.

2. Fundamental Masalah: Database Isolation Level

MySQL InnoDB secara default menggunakan REPEATABLE READ. Saat transaksi dimulai, database memberikan "snapshot" dari tabel tersebut. Transaksi lain yang mengubah data di tengah jalan tidak akan terlihat hingga snapshot diperbarui. Inilah pemicu utama race condition jika kita tidak mengatur penguncian secara eksplisit.

Isolation Level Dirty Read Non-Repeatable Read Phantom Read Cocok Untuk
READ UNCOMMITTED ✅ Bisa ✅ Bisa ✅ Bisa Analytics sementara (tidak disarankan)
READ COMMITTED ❌ Tidak ✅ Bisa ✅ Bisa Laporan ringan
REPEATABLE READ ❌ Tidak ❌ Tidak ✅ Bisa Default MySQL — kebanyakan kasus
SERIALIZABLE ❌ Tidak ❌ Tidak ❌ Tidak Transaksi keuangan kritis (lambat)

3. Solusi Level 0: Atomic SQL (Paling Sederhana, Sering Dilupakan)

Sebelum membahas locking yang kompleks, ada solusi paling sederhana yang sering diabaikan: biarkan database engine yang mengerjakan kalkulasi secara atomic dalam satu query.

// ✅ SOLUSI ATOMIC SQL — 1 query, tanpa locking eksplisit
public function beliSepatuAtomic($sepatuId) {
    // UPDATE bersifat row-level atomic di MySQL InnoDB.
    // "stock - 1" dihitung di dalam engine, bukan di PHP.
    $updated = DB::table('products')
        ->where('id', $sepatuId)
        ->where('stock', '>', 0)        // kondisi dicek atomically
        ->update(['stock' => DB::raw('stock - 1')]);

    if ($updated === 0) {
        return "Stok habis!";
    }

    return "Berhasil dibeli!";
}

Kenapa ini bekerja? Pernyataan UPDATE di MySQL berjalan dalam kunci internal row-level. Dua UPDATE pada baris yang sama tidak bisa terjadi secara bersamaan — salah satunya pasti menunggu. Jika $updated === 0, berarti kondisi stock > 0 tidak terpenuhi saat UPDATE dieksekusi.

Keterbatasan: Tidak cocok jika Anda perlu melakukan operasi lain (insert order, kirim notifikasi) secara atomic bersama pengurangan stok. Untuk itu, gunakan solusi berikutnya.


4. Solusi Level 1: Pessimistic Locking (SELECT ... FOR UPDATE)

Pendekatan Pessimistic berasumsi: "pasti akan ada orang lain yang mencoba mengambil data ini bersamaan, jadi saya harus menguncinya lebih dulu."

// ✅ SOLUSI PESSIMISTIC LOCKING
use Illuminate\Support\Facades\DB;

public function beliSepatuAman($userId, $sepatuId) {
    return DB::transaction(function () use ($userId, $sepatuId) {

        // Row di tabel dikunci. Transaksi lain yang mencoba SELECT baris ini
        // akan dihentikan sementara (blocking) sampai transaksi ini commit.
        $sepatu = Product::where('id', $sepatuId)->lockForUpdate()->first();

        if ($sepatu->stock <= 0) {
            throw new \Exception("Stok habis!");
        }

        $sepatu->decrement('stock');

        Order::create([
            'user_id'    => $userId,
            'product_id' => $sepatuId,
            'status'     => 'pending',
        ]);

        return "Berhasil dibeli!";
    });
}

Ancaman: Deadlock dan Cara Menghindarinya

Deadlock terjadi ketika Transaksi A mengunci Baris 1 lalu butuh Baris 2, sementara Transaksi B mengunci Baris 2 lalu butuh Baris 1. Keduanya saling menunggu selamanya. MySQL akan mematikan salah satunya secara sepihak dan melempar Deadlock found when trying to get lock.

// ✅ PENCEGAHAN DEADLOCK: Selalu kunci tabel dalam urutan yang konsisten
// ❌ SALAH — urutan berbeda di dua tempat bisa deadlock
// Transaksi A: kunci products → wallets
// Transaksi B: kunci wallets → products

// ✅ BENAR — selalu urutan yang sama di semua transaksi
// Transaksi A: kunci products (id kecil dulu) → wallets
// Transaksi B: kunci products (id kecil dulu) → wallets

// Jika perlu mengunci banyak produk sekaligus (misal bundle), urutkan by ID
$productIds = collect([$idA, $idB, $idC])->sort()->values();
Product::whereIn('id', $productIds)->orderBy('id')->lockForUpdate()->get();

5. Solusi Level 2: Optimistic Locking (Tanpa Antre, Gunakan Versi)

Pendekatan Optimistic berasumsi: "bentrok data itu jarang terjadi, biarkan semua orang proses, tapi cek versi di detik terakhir." Tidak ada baris yang dikunci, sehingga performa baca lebih tinggi.

Tambahkan kolom version (integer, default 0) di tabel produk Anda.

// ✅ SOLUSI OPTIMISTIC LOCKING
public function beliSepatuCepat($sepatuId) {
    $maxRetry = 3;

    for ($attempt = 1; $attempt <= $maxRetry; $attempt++) {
        $sepatu = Product::find($sepatuId);

        if ($sepatu->stock <= 0) {
            return "Stok habis!";
        }

        // UPDATE hanya berhasil jika version di DB masih sama
        $updated = Product::where('id', $sepatuId)
            ->where('version', $sepatu->version)
            ->update([
                'stock'   => $sepatu->stock - 1,
                'version' => $sepatu->version + 1,
            ]);

        if ($updated > 0) {
            return "Berhasil dibeli! (percobaan ke-{$attempt})";
        }

        // Versi berubah — ada yang menyalip. Coba lagi.
        usleep(100000 * $attempt); // backoff: 100ms, 200ms, 300ms
    }

    throw new \Exception("Terlalu banyak permintaan bersamaan, coba sebentar lagi.");
}

Kapan retry loop diperlukan? Tanpa retry, pengguna yang "kalah balapan" langsung menerima error meski stok masih tersedia. Retry dengan exponential backoff memberikan kesempatan kedua tanpa membebani server.


6. Solusi Level 3: Redis Distributed Locks (Multi-Server)

Ketika infrastruktur Anda menggunakan Load Balancer dengan banyak server instance, kunci di level database bisa menyebabkan High CPU Usage pada DB server. Solusinya: pindahkan kunci ke Redis yang berjalan di RAM.

// ✅ SOLUSI REDIS DISTRIBUTED LOCK (Laravel Cache)
use Illuminate\Support\Facades\Cache;

public function beliSepatuSkalaBesar($userId, $sepatuId) {
    $kunci = 'purchase_lock:product:' . $sepatuId;

    // TTL = 10 detik. Jika server crash, kunci otomatis hilang (cegah zombie lock)
    $lock = Cache::lock($kunci, 10);

    // block(3) = tunggu maksimal 3 detik untuk mendapatkan kunci
    if (! $lock->block(3)) {
        return response()->json([
            'status'  => 'busy',
            'message' => 'Sistem sedang padat, masuk ke antrean...',
        ], 429);
    }

    try {
        return DB::transaction(function () use ($userId, $sepatuId) {
            $sepatu = Product::find($sepatuId);

            if ($sepatu->stock <= 0) {
                return response()->json(['status' => 'out_of_stock'], 409);
            }

            $sepatu->decrement('stock');

            $order = Order::create([
                'user_id'    => $userId,
                'product_id' => $sepatuId,
                'status'     => 'confirmed',
            ]);

            return response()->json(['status' => 'success', 'order_id' => $order->id]);
        });
    } finally {
        // KRUSIAL: release() di finally agar kunci SELALU dilepas walau terjadi exception
        $lock->release();
    }
}

Redis SETNX: Mekanisme di Balik Layar

Laravel's Cache::lock() menggunakan perintah Redis SET key value NX PX ttl — artinya "Set hanya jika key belum ada (NX), dengan waktu kedaluwarsa (PX) dalam milidetik." Operasi ini bersifat atomic di level Redis, sehingga hanya satu proses yang bisa mendapatkan kunci meskipun ribuan proses mencoba bersamaan.

# Di balik layar Laravel Cache::lock():
SET purchase_lock:product:42 unique_token NX PX 10000
# NX  = Only set if Not eXists
# PX  = expire in milliseconds
# Hanya satu SET yang berhasil di antara semua yang mencoba secara bersamaan

7. Solusi Level 4: Queue-Based Serialization (Skalabilitas Tinggi)

Untuk traffic yang sangat tinggi (ratusan ribu request/detik), bahkan Redis lock pun bisa menjadi bottleneck. Solusi arsitektur yang lebih scalable: serialisasi pemrosesan melalui Queue. Setiap request "beli" tidak langsung diproses, melainkan dimasukkan ke antrian dan diproses satu per satu oleh worker.

// app/Jobs/ProcessPurchaseJob.php
class ProcessPurchaseJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public function __construct(
        public int $userId,
        public int $sepatuId,
        public string $idempotencyKey,
    ) {}

    public function handle(): void
    {
        // Cek apakah sudah pernah diproses (idempotency)
        if (Cache::has('processed:' . $this->idempotencyKey)) {
            return; // sudah diproses, abaikan duplikat
        }

        DB::transaction(function () {
            $sepatu = Product::where('id', $this->sepatuId)->lockForUpdate()->first();

            if ($sepatu->stock <= 0) {
                event(new PurchaseFailedEvent($this->userId, 'out_of_stock'));
                return;
            }

            $sepatu->decrement('stock');
            Order::create(['user_id' => $this->userId, 'product_id' => $this->sepatuId]);

            // Tandai sebagai sudah diproses (TTL 24 jam)
            Cache::put('processed:' . $this->idempotencyKey, true, 86400);

            event(new PurchaseSuccessEvent($this->userId));
        });
    }
}

// Di Controller — response instan ke user, proses di background
public function beli(Request $request, $sepatuId) {
    $idempotencyKey = $request->header('X-Idempotency-Key', Str::uuid());

    ProcessPurchaseJob::dispatch(auth()->id(), $sepatuId, $idempotencyKey)
        ->onQueue('purchases');  // queue khusus untuk transaksi

    return response()->json([
        'status'  => 'queued',
        'message' => 'Pesanan Anda sedang diproses',
        'key'     => $idempotencyKey,
    ], 202);
}

Keunggulan arsitektur ini: API server hanya menerima request dan memasukkan ke queue — sangat cepat. Worker memproses antrian secara berurutan — nol race condition. Jika worker crash, job otomatis di-retry.


8. Idempotency Key: Mencegah Pemrosesan Ganda

Masalah baru muncul di era microservices: duplicate request. User me-klik tombol "Bayar" dua kali, atau payment gateway mengirim webhook dua kali karena timeout. Tanpa perlindungan, saldo bisa terpotong dua kali.

// ✅ POLA IDEMPOTENCY KEY — Jaminan Exactly-Once Processing
class PaymentController extends Controller
{
    public function topUp(Request $request)
    {
        $key = $request->header('X-Idempotency-Key');

        if (! $key) {
            return response()->json(['error' => 'X-Idempotency-Key header wajib'], 400);
        }

        $cacheKey = 'idempotency:topup:' . $key;

        // Jika key sudah ada, kembalikan response yang sama (jangan proses ulang)
        if ($cached = Cache::get($cacheKey)) {
            return response()->json($cached);
        }

        $result = DB::transaction(function () use ($request) {
            // Double-check di dalam transaksi dengan row lock
            $wallet = Wallet::where('user_id', auth()->id())->lockForUpdate()->first();
            $wallet->increment('balance', $request->amount);

            return [
                'status'      => 'success',
                'new_balance' => $wallet->fresh()->balance,
                'processed_at'=> now()->toISOString(),
            ];
        });

        // Simpan hasil di cache selama 24 jam
        Cache::put($cacheKey, $result, 86400);

        return response()->json($result);
    }
}

9. Metodologi Testing Race Condition

Menulis kode yang benar saja tidak cukup — Anda harus membuktikannya dengan pengujian konkuren. Berikut tiga cara yang teruji.

A. Unit Test dengan Concurrent Requests (PHP)

// tests/Feature/RaceConditionTest.php
class RaceConditionTest extends TestCase
{
    public function test_stok_tidak_minus_saat_flash_sale()
    {
        // Setup: 1 produk, stok = 1
        $produk = Product::factory()->create(['stock' => 1]);
        $users  = User::factory()->count(50)->create();

        // Simulasi 50 request bersamaan menggunakan Guzzle concurrent
        $client   = new \GuzzleHttp\Client(['base_uri' => config('app.url')]);
        $promises = $users->map(fn($user) =>
            $client->postAsync("/api/beli/{$produk->id}", [
                'headers' => ['Authorization' => 'Bearer ' . $user->createToken('test')->plainTextToken],
            ])
        );

        \GuzzleHttp\Promise\Utils::settle($promises->toArray())->wait();

        // Assert: stok tidak pernah di bawah 0
        $this->assertGreaterThanOrEqual(0, $produk->fresh()->stock);

        // Assert: tepat 1 order berhasil dibuat
        $this->assertEquals(1, Order::where('product_id', $produk->id)->count());
    }
}

B. Load Test dengan Apache Bench (ab)

# Kirim 100 request, 20 bersamaan — simulasi flash sale mini
ab -n 100 -c 20 -H "Authorization: Bearer TOKEN" \
   -T "application/json" \
   http://localhost/api/beli/42

# Yang perlu diamati di output:
# Failed requests: harus 0
# Non-2xx responses: jumlah request yang ditolak (stok habis) — wajar

# Verifikasi di database setelah test:
# SELECT stock FROM products WHERE id = 42;
# SELECT COUNT(*) FROM orders WHERE product_id = 42;
# Kedua angka harus konsisten (stok_awal - order_count = stock_akhir)

C. Load Test dengan wrk (Lebih Akurat)

# Install: brew install wrk
# 12 thread, 400 koneksi bersamaan, selama 10 detik
wrk -t12 -c400 -d10s --script=beli.lua http://localhost/api/beli/42

# beli.lua — script untuk set header Authorization
wrk.method  = "POST"
wrk.headers["Authorization"] = "Bearer TOKEN_DISINI"
wrk.headers["Content-Type"]  = "application/json"

10. Monitoring Race Condition di Production

Race condition yang lolos ke production seringkali tidak terdeteksi sampai ada keluhan pelanggan. Berikut sistem deteksi proaktif yang perlu dipasang.

A. Database Constraint sebagai Safety Net Terakhir

-- Tambahkan CHECK constraint — database menolak mentah-mentah jika stok minus
ALTER TABLE products ADD CONSTRAINT chk_stock_non_negative CHECK (stock >= 0);

-- Jika terjadi race condition yang lolos dari kode, MySQL akan melempar error:
-- SQLSTATE[HY000]: Check constraint 'chk_stock_non_negative' is violated.
-- Error ini akan tercatat di log dan alert Anda — bukan diam-diam jadi data -1.

B. Logging Anomali Stok

// Observer untuk mendeteksi penurunan stok yang mencurigakan
class ProductObserver
{
    public function updating(Product $product): void
    {
        $stokLama = $product->getOriginal('stock');
        $stokBaru = $product->stock;

        // Alert jika stok turun lebih dari threshold dalam satu operasi
        if (($stokLama - $stokBaru) > 1) {
            Log::channel('security')->warning('Anomali stok terdeteksi', [
                'product_id' => $product->id,
                'dari'       => $stokLama,
                'ke'         => $stokBaru,
                'selisih'    => $stokLama - $stokBaru,
                'user'       => auth()->id(),
                'ip'         => request()->ip(),
                'trace'      => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5),
            ]);
        }
    }
}

C. Dashboard Metrik Real-time

// Kirim metrik ke Prometheus / StatsD / Laravel Telescope
class PurchaseMetrics
{
    public static function record(string $result, string $method): void
    {
        // Increment counter berdasarkan hasil
        app('statsd')->increment("purchase.{$result}", 1, ["method:{$method}"]);

        // Pantau di Grafana:
        // purchase.success   — seharusnya naik saat flash sale
        // purchase.failed    — lonjakan = ada masalah stok/kunci
        // purchase.retried   — tinggi = optimistic lock sering bentrok
        // purchase.rejected  — tinggi = Redis lock jenuh
    }
}

Kapan Menggunakan Apa? (Cheat Sheet Senior Developer)

Skenario Metode Terbaik Alasan
Counter sederhana (view count, like) Atomic SQL (DB::raw) Satu query, overhead minimal, cukup untuk kasus non-kritis
Transaksi uang, top up saldo, transfer Pessimistic Locking + DB Constraint Integritas data adalah harga mati. Lambat saat antre, tapi 100% aman
Edit profil, update artikel CMS Optimistic Locking (version column) Bentrok jarang terjadi. Performa tinggi, UX lebih baik
Flash sale, booking tiket konser Redis Distributed Lock MySQL tidak bisa menampung ribuan row lock/detik. Redis di RAM jauh lebih cepat
Microservices, traffic > 10.000 req/detik Queue-Based Serialization + Idempotency Key Pisahkan penerimaan request dari pemrosesan. Skalabel horizontal tanpa batas
Payment gateway webhook Idempotency Key Gateway sering mengirim webhook duplikat. Key mencegah double debit

Kesimpulan

Race condition bukan sekadar bug — ini adalah kerentanan arsitektur yang bisa menyebabkan kerugian finansial nyata. Dari kode satu baris DB::raw('stock - 1') hingga arsitektur queue dengan idempotency key, setiap level solusi memiliki tempatnya masing-masing tergantung skala dan kebutuhan bisnis Anda.

Yang terpenting: selalu uji dengan skenario concurrent, pasang database constraint sebagai safety net terakhir, dan monitor anomali di production. Kode yang tidak pernah diuji di bawah beban concurrent hanyalah kode yang belum ketahuan masalahnya.

Ingat filosofi ini: "Optimistic saat traffic rendah, Pessimistic saat uang terlibat, Redis saat skala besar, Queue saat butuh resiliency penuh."