Mencegah overselling stok di Laravel saat flash sale serentak berarti memastikan dua atau lebih request tidak berhasil menjual unit barang yang sama. Masalah ini hampir selalu muncul karena race condition: banyak proses membaca stok yang sama, lalu masing-masing merasa stok masih tersedia.

Dalam sistem ecommerce Laravel, solusi yang benar bukan sekadar menambahkan validasi if stock > 0 di kode aplikasi. Validasi seperti itu tetap rentan saat ribuan user checkout bersamaan. Pendekatan yang lebih aman adalah memindahkan kontrol konkurensi ke level database, lalu merancang alur checkout yang jelas: kapan stok dicek, kapan dikurangi, kapan direservasi, dan kapan dikembalikan.

Studi kasus: overselling saat flash sale

Misalkan sebuah produk memiliki stok 5. Saat flash sale dimulai, 200 user menekan tombol checkout hampir bersamaan. Alur naif yang sering terjadi adalah seperti ini:

  1. Request membaca stok dari tabel produk.
  2. Jika stok lebih dari 0, proses checkout dilanjutkan.
  3. Setelah pembayaran atau pembuatan order, stok dikurangi.

Masalahnya, beberapa request bisa membaca nilai stok yang sama sebelum perubahan disimpan. Akibatnya, 10 order bisa sukses padahal stok hanya 5.

Contoh kode yang tampak benar tetapi rentan:

DB::transaction(function () use ($productId, $qty) {
    $product = Product::findOrFail($productId);

    if ($product->stock < $qty) {
        throw new RuntimeException('Stok tidak cukup');
    }

    $product->stock -= $qty;
    $product->save();

    Order::create([
        'product_id' => $productId,
        'qty' => $qty,
        'status' => 'pending_payment',
    ]);
});

Walaupun dibungkus transaksi, kode di atas belum tentu aman dari race condition jika pembacaan stok tidak mengunci baris yang relevan. Transaksi saja tidak otomatis menyelesaikan masalah konkurensi.

Skema tabel stok yang lebih aman

Untuk membahas solusi dengan jelas, kita gunakan skema yang memisahkan informasi produk dari stok. Ini memudahkan audit, reservasi, dan penguncian baris yang lebih spesifik.

CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price DECIMAL(12,2) NOT NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);

CREATE TABLE inventories (
    product_id BIGINT PRIMARY KEY,
    stock INT NOT NULL,
    reserved INT NOT NULL DEFAULT 0,
    version INT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP NULL,
    CONSTRAINT fk_inventories_product
        FOREIGN KEY (product_id) REFERENCES products(id)
);

Arti kolom penting:

  • stock: total stok yang masih dikelola sistem.
  • reserved: stok yang sedang ditahan sementara untuk checkout atau pembayaran.
  • version: counter untuk optimistic locking.

Dengan skema seperti ini, stok tersedia bisa dihitung sebagai stock - reserved. Pada sebagian sistem, tim memilih hanya menyimpan satu kolom stok final dan mengurangi langsung saat order dibuat. Itu valid, tetapi reservasi sementara biasanya lebih cocok untuk flash sale dan payment flow yang tidak instan.

Pendekatan 1: pessimistic locking dengan SELECT ... FOR UPDATE

Pessimistic locking cocok saat konsistensi sangat penting dan kita rela menerima throughput yang lebih rendah. Intinya, baris stok dikunci dalam transaksi sehingga request lain harus menunggu sampai transaksi selesai.

Implementasi di Laravel

DB::transaction(function () use ($productId, $qty) {
    $inventory = DB::table('inventories')
        ->where('product_id', $productId)
        ->lockForUpdate()
        ->first();

    if (!$inventory) {
        throw new RuntimeException('Data inventory tidak ditemukan');
    }

    $available = $inventory->stock - $inventory->reserved;

    if ($available < $qty) {
        throw new RuntimeException('Stok tidak cukup');
    }

    DB::table('inventories')
        ->where('product_id', $productId)
        ->update([
            'stock' => $inventory->stock - $qty,
            'updated_at' => now(),
        ]);

    DB::table('orders')->insert([
        'product_id' => $productId,
        'qty' => $qty,
        'status' => 'pending_payment',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
});

Alternatif Eloquent:

DB::transaction(function () use ($productId, $qty) {
    $inventory = Inventory::where('product_id', $productId)
        ->lockForUpdate()
        ->firstOrFail();

    $available = $inventory->stock - $inventory->reserved;

    if ($available < $qty) {
        throw new RuntimeException('Stok tidak cukup');
    }

    $inventory->stock -= $qty;
    $inventory->save();
});

Mengapa pendekatan ini bekerja

Saat satu transaksi memegang lock pada baris inventory, transaksi lain yang ingin membaca baris itu dengan FOR UPDATE harus menunggu. Akibatnya, hanya satu transaksi yang bisa mengecek dan mengubah stok pada satu waktu. Ini menghilangkan race condition pada baris yang sama.

Kelebihan

  • Sangat kuat untuk menjaga konsistensi.
  • Mudah dipahami untuk kasus stok per produk.
  • Cocok untuk operasi yang benar-benar harus serial pada item yang sama.

Kekurangan

  • Throughput turun pada produk yang sangat populer karena request saling menunggu.
  • Risiko lock contention tinggi saat flash sale.
  • Jika transaksi memuat langkah lambat seperti panggilan API pembayaran, lock bisa tertahan terlalu lama.

Catatan penting: jangan pernah menahan lock database sambil menunggu gateway pembayaran, request HTTP eksternal, atau proses berat lainnya. Lock harus sesingkat mungkin.

Risiko deadlock

Deadlock sering muncul jika satu transaksi mengunci beberapa baris dalam urutan berbeda. Misalnya order A mengunci produk 1 lalu produk 2, sementara order B mengunci produk 2 lalu produk 1. Untuk mengurangi risiko:

  • Kunci baris dalam urutan yang konsisten, misalnya urut berdasarkan product_id.
  • Jaga transaksi tetap pendek.
  • Siapkan mekanisme retry pada error deadlock.
$attempts = 3;

for ($i = 0; $i < $attempts; $i++) {
    try {
        DB::transaction(function () use ($items) {
            ksort($items);

            foreach ($items as $productId => $qty) {
                $inventory = Inventory::where('product_id', $productId)
                    ->lockForUpdate()
                    ->firstOrFail();

                $available = $inventory->stock - $inventory->reserved;
                if ($available < $qty) {
                    throw new RuntimeException("Stok produk {$productId} tidak cukup");
                }

                $inventory->stock -= $qty;
                $inventory->save();
            }
        });

        break;
    } catch (\Throwable $e) {
        if ($i === $attempts - 1) {
            throw $e;
        }
    }
}

Pendekatan 2: optimistic locking dengan kolom version

Optimistic locking mengasumsikan konflik tidak selalu sering, sehingga request tidak langsung mengunci baris. Sebagai gantinya, update hanya berhasil jika versi data yang dibaca masih sama saat akan ditulis.

Contoh alur

  1. Baca stock, reserved, dan version.
  2. Hitung stok tersedia di aplikasi.
  3. Jalankan update dengan syarat version belum berubah.
  4. Jika tidak ada baris ter-update, berarti ada konflik; baca ulang dan retry atau gagal.

Contoh Query Builder

$inventory = DB::table('inventories')
    ->where('product_id', $productId)
    ->first();

if (!$inventory) {
    throw new RuntimeException('Inventory tidak ditemukan');
}

$available = $inventory->stock - $inventory->reserved;

if ($available < $qty) {
    throw new RuntimeException('Stok tidak cukup');
}

$updated = DB::table('inventories')
    ->where('product_id', $productId)
    ->where('version', $inventory->version)
    ->update([
        'stock' => $inventory->stock - $qty,
        'version' => $inventory->version + 1,
        'updated_at' => now(),
    ]);

if ($updated === 0) {
    throw new RuntimeException('Konflik update stok, silakan retry');
}

Mengapa pendekatan ini bekerja

Jika dua request membaca versi yang sama, hanya satu yang akan berhasil mengubah baris dengan syarat where version = ?. Request kedua akan gagal karena versi sudah berubah. Dengan begitu, sistem mendeteksi konflik tanpa lock panjang.

Kapan cocok digunakan

  • Konflik tidak terlalu sering.
  • Latency harus rendah.
  • Anda siap menangani retry di aplikasi.

Trade-off

  • Throughput biasanya lebih baik daripada pessimistic locking saat konflik rendah.
  • Saat flash sale untuk produk sangat populer, konflik bisa sangat tinggi sehingga banyak retry.
  • Logika aplikasi lebih rumit karena perlu strategi retry dan penanganan gagal update.

Pada flash sale ekstrem, optimistic locking sering kalah efisien dari atomic update murni karena terlalu banyak request gagal lalu mencoba lagi.

Pendekatan 3: atomic update berbasis SQL

Untuk kasus pengurangan stok pada satu produk, pendekatan yang paling praktis dan sering paling efisien adalah atomic update. Ide utamanya: jangan baca stok lalu update terpisah; lakukan pengurangan langsung di database dengan syarat stok masih cukup.

Contoh pengurangan stok tanpa reservasi

$affected = DB::table('inventories')
    ->where('product_id', $productId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);

if ($affected === 0) {
    throw new RuntimeException('Stok tidak cukup');
}

Atau jika memakai raw update agar lebih eksplisit:

$affected = DB::update(
    'UPDATE inventories SET stock = stock - ?, updated_at = ? WHERE product_id = ? AND stock >= ?',
    [$qty, now(), $productId, $qty]
);

if ($affected === 0) {
    throw new RuntimeException('Stok tidak cukup');
}

Jika memakai reserved stock

Untuk stok tersedia = stock - reserved, update atomik bisa berbentuk:

$affected = DB::update(
    'UPDATE inventories
     SET reserved = reserved + ?, updated_at = ?
     WHERE product_id = ? AND (stock - reserved) >= ?',
    [$qty, now(), $productId, $qty]
);

if ($affected === 0) {
    throw new RuntimeException('Stok tidak cukup untuk direservasi');
}

Mengapa pendekatan ini bekerja

Database mengeksekusi kondisi dan update sebagai satu operasi atomik. Tidak ada celah antara pengecekan stok dan pengurangan stok. Dua request yang datang bersamaan tidak akan sama-sama mengurangi stok melebihi batas, karena syarat pada WHERE akan dievaluasi terhadap state terkini.

Kelebihan

  • Sederhana dan cepat untuk operasi stok tunggal.
  • Mengurangi kebutuhan lock eksplisit di level aplikasi.
  • Sangat cocok untuk hotspot product saat flash sale.

Keterbatasan

  • Kurang fleksibel jika validasi stok bergantung pada banyak tabel atau banyak langkah bisnis.
  • Untuk cart multi-item, Anda tetap perlu transaksi agar semua item berhasil atau gagal bersama.
  • Perlu desain yang hati-hati jika stok tersebar di banyak gudang atau kanal penjualan.

Dalam banyak sistem ecommerce, atomic update adalah fondasi utama untuk operasi satu SKU yang sangat panas, sedangkan langkah bisnis lain dipisahkan ke proses setelah stok berhasil ditahan.

Pendekatan 4: reservasi stok sementara

Pada checkout modern, user sering belum langsung membayar. Jika stok langsung dikurangi permanen saat user baru masuk halaman pembayaran, stok bisa tertahan oleh banyak cart yang akhirnya tidak jadi bayar. Di sinilah reservasi stok sementara lebih cocok.

Konsep alur reservasi

  1. User klik checkout.
  2. Sistem mencoba menambah reserved secara atomik.
  3. Jika berhasil, buat order berstatus pending_payment dan simpan waktu kedaluwarsa reservasi.
  4. Jika pembayaran sukses, ubah reservasi menjadi pengurangan stok final.
  5. Jika pembayaran gagal atau timeout, lepaskan reservasi.

Contoh tabel reservasi

CREATE TABLE stock_reservations (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    qty INT NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    released_at TIMESTAMP NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);

Contoh membuat reservasi

DB::transaction(function () use ($orderId, $productId, $qty, $expiresAt) {
    $affected = DB::update(
        'UPDATE inventories
         SET reserved = reserved + ?, updated_at = ?
         WHERE product_id = ? AND (stock - reserved) >= ?',
        [$qty, now(), $productId, $qty]
    );

    if ($affected === 0) {
        throw new RuntimeException('Stok tidak cukup');
    }

    DB::table('stock_reservations')->insert([
        'order_id' => $orderId,
        'product_id' => $productId,
        'qty' => $qty,
        'expires_at' => $expiresAt,
        'created_at' => now(),
        'updated_at' => now(),
    ]);
});

Contoh konfirmasi pembayaran

Saat pembayaran berhasil, pindahkan reserved ke penjualan final. Salah satu pendekatan adalah mengurangi stock dan reserved sekaligus.

DB::transaction(function () use ($productId, $qty, $reservationId) {
    $affected = DB::update(
        'UPDATE inventories
         SET stock = stock - ?, reserved = reserved - ?, updated_at = ?
         WHERE product_id = ? AND reserved >= ?',
        [$qty, $qty, now(), $productId, $qty]
    );

    if ($affected === 0) {
        throw new RuntimeException('Gagal finalisasi stok');
    }

    DB::table('stock_reservations')
        ->where('id', $reservationId)
        ->update([
            'released_at' => now(),
            'updated_at' => now(),
        ]);
});

Contoh pelepasan reservasi kedaluwarsa

DB::transaction(function () use ($reservation) {
    $affected = DB::update(
        'UPDATE inventories
         SET reserved = reserved - ?, updated_at = ?
         WHERE product_id = ? AND reserved >= ?',
        [$reservation->qty, now(), $reservation->product_id, $reservation->qty]
    );

    if ($affected > 0) {
        DB::table('stock_reservations')
            ->where('id', $reservation->id)
            ->whereNull('released_at')
            ->update([
                'released_at' => now(),
                'updated_at' => now(),
            ]);
    }
});

Pelepasan reservasi biasanya dijalankan lewat job terjadwal atau worker queue. Pastikan proses ini idempoten: jika job diproses dua kali, stok tidak boleh berkurang atau bertambah ganda.

Memilih pendekatan yang tepat

Kapan memakai pessimistic locking

  • Validasi stok melibatkan beberapa langkah yang harus konsisten dalam satu transaksi.
  • Konflik tinggi dan Anda lebih memilih request menunggu daripada banyak retry.
  • Jumlah item per order terbatas dan transaksi bisa dijaga tetap singkat.

Kapan memakai optimistic locking

  • Konflik relatif rendah.
  • Sistem membutuhkan latensi lebih baik tanpa lock eksplisit berkepanjangan.
  • Tim siap mengimplementasikan retry yang disiplin.

Kapan memakai atomic update SQL

  • Operasi inti hanya perlu memastikan stok tidak negatif.
  • Produk tertentu sangat populer saat flash sale.
  • Anda ingin jalur kritis checkout sesingkat mungkin.

Kapan memakai reservasi stok sementara

  • Pembayaran tidak selalu instan.
  • Ada kemungkinan user meninggalkan halaman pembayaran.
  • Anda ingin mencegah overselling tanpa langsung mengurangi stok final saat checkout dimulai.

Dalam praktik, banyak sistem menggabungkan atomic update + reservasi stok. Ini sering menjadi kompromi terbaik antara throughput dan konsistensi untuk flash sale.

Merancang alur checkout agar stok tetap akurat

Masalah overselling sering bukan hanya soal satu query, tetapi soal desain alur. Berikut pola yang umumnya lebih aman:

  1. Cart tidak memegang stok.
  2. Saat user klik checkout, sistem mencoba membuat reservasi stok secara atomik.
  3. Order dibuat dengan status pending_payment.
  4. Pembayaran diproses di luar transaksi stok utama.
  5. Jika webhook pembayaran sukses, sistem melakukan finalisasi stok dan order.
  6. Jika pembayaran gagal atau timeout, reservasi dilepas oleh worker.

Manfaat alur ini:

  • Jalur kritis penguncian stok pendek.
  • Tidak ada lock yang ditahan selama menunggu pembayaran.
  • Status order lebih mudah diaudit.
  • Rekonsiliasi stok lebih jelas jika terjadi gangguan.

Kesalahan umum yang perlu dihindari

  • Mengecek stok di aplikasi lalu update belakangan tanpa syarat atomik.
  • Mengurangi stok sebelum transaksi pembayaran punya status yang jelas, tanpa mekanisme kompensasi.
  • Menahan transaksi database sambil memanggil API eksternal.
  • Tidak memiliki proses cleanup untuk reservasi kedaluwarsa.
  • Tidak membuat operasi webhook dan job bersifat idempoten.

Strategi performa: throughput vs konsistensi

Flash sale selalu memaksa Anda memilih kompromi. Tidak ada solusi yang unggul mutlak di semua kondisi.

Jika konsistensi absolut lebih penting

Pilih pessimistic locking atau transaksi yang lebih ketat. Konsekuensinya, throughput menurun pada SKU populer karena request harus antre.

Jika throughput dan respons cepat lebih penting

Pilih atomic update dan buat jalur checkout sesingkat mungkin. Konsistensi tetap kuat untuk stok inti, tetapi Anda perlu desain state machine order yang baik agar pemrosesan lanjutan tetap aman.

Jika flow pembayaran lama atau asynchronous

Gunakan reservasi stok sementara. Ini biasanya lebih realistis untuk ecommerce daripada langsung mengurangi stok permanen saat user baru menekan tombol bayar.

Uji beban untuk memvalidasi solusi

Jangan menganggap solusi aman hanya karena lolos pengujian manual. Overselling adalah masalah konkurensi, jadi harus diuji dengan request paralel.

Skenario uji minimum

  • Produk A memiliki stok 100.
  • Simulasikan 500 sampai 2.000 request checkout bersamaan.
  • Setiap request mencoba membeli qty 1.
  • Validasi bahwa total order sukses tidak pernah melebihi 100.
  • Validasi bahwa stock, reserved, dan jumlah order final konsisten setelah test selesai.

Apa yang perlu diamati

  • Jumlah order sukses vs stok awal.
  • Jumlah request gagal karena stok habis.
  • Latency p95 dan p99.
  • Deadlock atau lock wait timeout.
  • Anomali seperti reserved negatif atau stok negatif.

Contoh pendekatan pengujian

Anda bisa memakai alat load testing seperti k6, JMeter, atau script paralel internal. Prinsipnya bukan alatnya, melainkan verifikasi hasil akhir.

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    flash_sale: {
      executor: 'constant-vus',
      vus: 200,
      duration: '10s',
    },
  },
};

export default function () {
  const payload = JSON.stringify({
    product_id: 1,
    qty: 1,
  });

  const res = http.post('https://example.test/api/checkout', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'status valid': (r) => [200, 409, 422].includes(r.status),
  });
}

Setelah test, jalankan query audit:

SELECT stock, reserved FROM inventories WHERE product_id = 1;
SELECT COUNT(*) FROM orders WHERE product_id = 1 AND status IN ('pending_payment', 'paid');

Untuk sistem dengan reservasi, audit harus dibedakan antara order pending_payment, order paid, dan reservasi yang sudah kedaluwarsa.

Uji edge case yang sering terlupakan

  • Webhook pembayaran datang dua kali.
  • Job pelepasan reservasi diproses ulang.
  • Order berisi beberapa item dan satu item gagal reservasi.
  • Database mengalami deadlock lalu transaksi di-retry.
  • Satu produk sangat panas, produk lain normal.

Debugging saat overselling masih terjadi

Jika overselling tetap muncul, biasanya sumber masalah ada di salah satu titik berikut:

  • Ada jalur kode lain yang mengubah stok tanpa mekanisme aman yang sama.
  • Webhook pembayaran dan endpoint checkout sama-sama mengurangi stok.
  • Retry tidak idempoten sehingga stok berkurang dua kali.
  • Reservasi expired dilepas lebih dari sekali.
  • Read replica dipakai untuk cek stok, tetapi update dilakukan ke primary sehingga data terbaca terlambat.

Langkah debug yang praktis:

  1. Log semua perubahan stok beserta order_id, product_id, qty, sumber event, dan timestamp.
  2. Buat audit trail sederhana atau tabel mutasi stok.
  3. Pastikan semua operasi stok hanya terjadi di service yang sama, bukan tersebar di controller, job, dan webhook secara bebas.
  4. Telusuri apakah ada pembacaan dari replica untuk keputusan kritis stok.

Rekomendasi implementasi untuk ecommerce Laravel

Untuk studi kasus flash sale serentak di Laravel, pendekatan yang paling seimbang umumnya adalah:

  1. Gunakan atomic update untuk reservasi stok.
  2. Simpan reservasi per order dengan waktu kedaluwarsa.
  3. Finalisasi stok hanya saat pembayaran benar-benar sukses.
  4. Lepaskan reservasi secara asynchronous melalui queue atau scheduler.
  5. Buat webhook dan job bersifat idempoten.
  6. Tambahkan retry terbatas hanya untuk error transient seperti deadlock, bukan untuk semua error bisnis.

Jika order multi-item dan konsistensi antar item harus kuat, jalankan reservasi dalam transaksi dengan urutan penguncian yang konsisten. Jika SKU sangat populer dan tiap checkout biasanya hanya satu item, atomic update sering menjadi jalur paling efisien.

Penutup

Mencegah overselling stok di Laravel saat flash sale serentak tidak cukup dengan validasi stok biasa di level aplikasi. Race condition harus ditangani di level database dan didukung oleh alur checkout yang tepat.

Ringkasnya:

  • Pessimistic locking: paling ketat, throughput bisa turun.
  • Optimistic locking: cocok saat konflik rendah, butuh retry.
  • Atomic update SQL: sederhana dan efektif untuk hotspot stok.
  • Reservasi stok sementara: paling realistis untuk flow pembayaran asynchronous.

Jika target Anda adalah flash sale dengan trafik tinggi, mulai dari atomic reservation yang singkat, order state yang jelas, dan load test paralel yang memverifikasi hasil akhir. Itu jauh lebih penting daripada sekadar membuat endpoint checkout terlihat berhasil pada pengujian biasa.