Race condition pada stok saat checkout bersamaan biasanya muncul ketika dua request membaca stok yang sama sebelum salah satunya sempat menulis perubahan ke database. Akibatnya, kedua checkout sama-sama lolos validasi dan stok bisa menjadi minus, atau lebih buruk lagi, sistem menerima pesanan melebihi stok yang tersedia.
Di Laravel, bug ini sering terlihat sepele karena kode checkout tampak benar saat diuji sendirian. Masalah baru muncul di production ketika request paralel datang hampir bersamaan. Kuncinya bukan hanya memeriksa if stock >= qty, tetapi memastikan proses baca-dan-kurangi stok berlangsung aman di bawah konkurensi.
Gejala di Production yang Perlu Dicurigai
Kasus race condition stok biasanya tidak selalu muncul di environment development biasa karena trafik rendah dan request berjalan berurutan. Di production, gejalanya lebih terlihat dari pola data dan log.
Gejala umum
- Stok minus pada tabel produk atau inventory.
- Oversell, yaitu jumlah order sukses melebihi stok yang tersedia.
- Bug intermiten: sulit direproduksi manual, tetapi kadang muncul saat flash sale, promo, atau trafik tinggi.
- Log checkout terlihat normal jika dilihat per request, tetapi bermasalah bila dikorelasikan antar request pada waktu yang sama.
- Keluhan user: dua pembeli berhasil membeli item terakhir secara bersamaan.
Mengapa bug ini sering lolos review kode?
Karena secara logika bisnis, alurnya terlihat masuk akal:
- Ambil data produk.
- Cek apakah stok cukup.
- Kurangi stok.
- Buat order.
Masalahnya, urutan tersebut aman hanya jika tidak ada request lain yang masuk di tengah proses. Dalam sistem nyata, dua request bisa membaca stok lama yang sama, lalu keduanya mengurangi stok berdasarkan nilai yang sudah tidak valid.
Contoh Kode yang Tampak Benar, tetapi Rentan Race Condition
Misalkan ada implementasi checkout seperti ini:
public function checkout(Request $request)
{
$product = Product::findOrFail($request->product_id);
$qty = (int) $request->qty;
if ($product->stock < $qty) {
return response()->json(['message' => 'Stok tidak cukup'], 422);
}
$product->stock = $product->stock - $qty;
$product->save();
$order = Order::create([
'product_id' => $product->id,
'qty' => $qty,
'status' => 'paid',
]);
return response()->json($order);
}Di kode di atas, validasi stok dilakukan sebelum update. Jika dua request datang hampir bersamaan ketika stok tersisa 1 dan keduanya membeli qty 1, maka skenario berikut bisa terjadi:
- Request A membaca stok = 1.
- Request B membaca stok = 1.
- Request A lolos validasi dan menulis stok = 0.
- Request B juga lolos validasi berdasarkan bacaan lama, lalu menulis stok = 0 atau bahkan menghasilkan state lain yang salah, tergantung urutan write dan logika lanjutan.
Secara bisnis, dua order berhasil untuk satu item terakhir. Inilah inti dari race condition.
Cara Reproduksi Lokal Secara Sederhana
Bug konkurensi sulit dibuktikan jika hanya klik endpoint sekali-sekali. Anda perlu memaksa dua atau lebih request berjalan hampir serentak.
Siapkan data uji yang kecil
Buat satu produk dengan stok sangat rendah, misalnya 1 atau 2. Tujuannya agar konflik cepat terlihat.
Product::query()->updateOrCreate(
['id' => 1001],
['name' => 'Flash Sale Item', 'stock' => 1, 'price' => 100000]
);Kirim request paralel
Anda bisa menggunakan alat sederhana seperti dua terminal dengan curl, atau tool beban ringan seperti ApacheBench, hey, atau k6. Tidak perlu benchmark rumit; yang penting ada request paralel ke endpoint checkout yang sama.
curl -X POST http://localhost:8000/api/checkout \
-H "Content-Type: application/json" \
-d '{"product_id":1001,"qty":1}' &
curl -X POST http://localhost:8000/api/checkout \
-H "Content-Type: application/json" \
-d '{"product_id":1001,"qty":1}' &
waitJika bug ada, Anda mungkin melihat dua respons sukses, padahal stok hanya 1.
Tambahkan delay buatan untuk memperbesar peluang bentrok
Saat investigasi lokal, menambahkan jeda singkat di titik kritis sering membantu membuktikan masalah.
public function checkout(Request $request)
{
$product = Product::findOrFail($request->product_id);
$qty = (int) $request->qty;
if ($product->stock < $qty) {
return response()->json(['message' => 'Stok tidak cukup'], 422);
}
usleep(300000); // 300 ms, hanya untuk debugging lokal
$product->stock = $product->stock - $qty;
$product->save();
$order = Order::create([
'product_id' => $product->id,
'qty' => $qty,
'status' => 'paid',
]);
return response()->json($order);
}Jangan pernah meninggalkan delay seperti ini di production. Ini hanya alat bantu untuk memancing race condition saat investigasi.
Analisis Root Cause: Transaksi, Query Update, dan Konkurensi Request
Masalah utamanya adalah read-check-write yang tidak atomik
Pola rentan race condition biasanya seperti ini:
- Read: ambil stok saat ini.
- Check: pastikan stok cukup.
- Write: simpan stok baru.
Jika tiga langkah ini tidak dilindungi dengan mekanisme konkurensi yang tepat, request lain bisa masuk di antara langkah-langkah tersebut.
Transaksi database saja belum tentu cukup
Banyak developer mencoba membungkus kode checkout dengan DB::transaction() lalu menganggap masalah selesai. Ini tidak selalu benar. Transaksi memang menjaga konsistensi perubahan dalam satu request, tetapi tidak otomatis mencegah dua transaksi membaca baris yang sama lalu mengambil keputusan berdasarkan data lama.
Dengan kata lain, transaction bukan pengganti locking atau atomic condition. Anda tetap perlu memastikan cara membaca dan mengubah stok aman terhadap request paralel.
Apa yang terjadi di level database?
Tanpa row-level locking atau atomic conditional update, dua transaksi dapat:
- membaca nilai stok yang sama,
- sama-sama menganggap stok cukup,
- lalu masing-masing menulis hasilnya sendiri.
Ini bisa menyebabkan lost update atau oversell, tergantung pola query dan urutan eksekusi.
Langkah Investigasi yang Praktis
1. Korelasikan log per request
Tambahkan request ID atau correlation ID agar log dari satu checkout bisa diikuti dari awal sampai akhir. Ini penting karena bug konkurensi tidak terlihat jika log dibaca terpisah-pisah.
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
public function checkout(Request $request)
{
$requestId = (string) Str::uuid();
Log::info('checkout.start', [
'request_id' => $requestId,
'product_id' => $request->product_id,
'qty' => $request->qty,
]);
// proses checkout...
Log::info('checkout.finish', [
'request_id' => $requestId,
]);
}Log yang berguna untuk investigasi:
- waktu request mulai dan selesai,
- product_id dan qty,
- stok sebelum update,
- hasil update database,
- order_id yang dibuat,
- apakah request gagal karena stok habis atau karena retry/idempotensi.
2. Inspeksi query SQL yang benar-benar dieksekusi
Jangan hanya melihat kode Eloquent. Saat debugging race condition, Anda perlu tahu query SQL yang dikirim ke database.
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
Log::debug('sql', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time_ms' => $query->time,
]);
});Dengan ini Anda bisa memverifikasi apakah aplikasi melakukan:
selectlaluupdateterpisah,select ... for update,- atau
update ... where stock >= ?yang atomik.
3. Jalankan skenario beban kecil, bukan tes besar
Tujuan investigasi bukan mengejar angka throughput, tetapi mencari kondisi bentrok. Gunakan 2-10 request paralel pada produk yang sama dengan stok rendah. Setelah itu, verifikasi:
- jumlah order sukses,
- sisa stok akhir,
- apakah ada stok minus,
- apakah ada dua order yang memakai item terakhir.
4. Cocokkan data order dengan mutasi stok
Kalau sistem sudah kompleks, cek apakah setiap order sukses benar-benar memiliki pengurangan stok yang konsisten. Jika ada tabel inventory movement, audit akan lebih mudah. Jika belum ada, inilah saat yang tepat mempertimbangkan pencatatan mutasi stok agar debugging ke depan lebih jelas.
Perbaikan Praktis yang Umum Dipakai
Pendekatan 1: Database transaction + row-level locking
Pendekatan ini cocok jika proses checkout memang perlu membaca baris produk terlebih dahulu, menjalankan validasi tambahan, lalu menulis beberapa tabel secara konsisten.
use Illuminate\Support\Facades\DB;
public function checkout(Request $request)
{
$qty = (int) $request->qty;
return DB::transaction(function () use ($request, $qty) {
$product = Product::whereKey($request->product_id)
->lockForUpdate()
->firstOrFail();
if ($product->stock < $qty) {
return response()->json(['message' => 'Stok tidak cukup'], 422);
}
$product->decrement('stock', $qty);
$order = Order::create([
'product_id' => $product->id,
'qty' => $qty,
'status' => 'paid',
]);
return response()->json($order, 201);
});
}Mengapa ini bekerja? Karena lockForUpdate() meminta database mengunci baris produk yang sedang diproses sampai transaksi selesai. Request lain yang ingin mengunci baris yang sama harus menunggu. Dengan begitu, dua checkout tidak bisa sama-sama membaca stok lama untuk baris yang sama.
Trade-off pendekatan locking
- Kelebihan: mudah dipahami, kuat untuk konsistensi, cocok jika ada beberapa operasi terkait dalam satu transaksi.
- Kekurangan: menambah waktu tunggu saat kontensi tinggi, berpotensi memicu lock wait atau deadlock jika banyak baris/tabel dikunci dengan urutan yang tidak konsisten.
Jika Anda memakai locking, pastikan transaksi singkat. Hindari memanggil API eksternal, mengirim email, atau pekerjaan lambat lain di dalam transaksi.
Pendekatan 2: Atomic update dengan pengecekan stok
Untuk kasus pengurangan stok yang sederhana, pendekatan yang sering lebih efisien adalah melakukan update atomik langsung di database dengan syarat stok cukup.
use Illuminate\Support\Facades\DB;
public function checkout(Request $request)
{
$productId = (int) $request->product_id;
$qty = (int) $request->qty;
return DB::transaction(function () use ($productId, $qty) {
$updated = Product::where('id', $productId)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
if ($updated === 0) {
return response()->json(['message' => 'Stok tidak cukup'], 422);
}
$order = Order::create([
'product_id' => $productId,
'qty' => $qty,
'status' => 'paid',
]);
return response()->json($order, 201);
});
}Mengapa ini bekerja? Karena keputusan dan perubahan stok digabung dalam satu query update bersyarat. Database hanya akan mengurangi stok jika kondisi stock >= qty terpenuhi pada saat query dieksekusi. Ini menghindari celah antara baca dan tulis.
Kapan atomic update lebih cocok?
- Saat aturan stok relatif sederhana.
- Saat Anda ingin meminimalkan lock duration.
- Saat throughput tinggi pada item populer.
Namun jika validasi bisnis bergantung pada banyak tabel atau state kompleks, row-level locking dalam transaksi sering lebih mudah dipelihara.
Pendekatan 3: Idempotensi pada endpoint checkout
Idempotensi tidak langsung menyelesaikan race condition stok, tetapi sangat penting untuk mencegah double order dari request yang sama, misalnya karena retry dari client, timeout, atau user menekan tombol bayar dua kali.
Gunakan idempotency key unik per percobaan checkout. Simpan key tersebut, lalu tolak atau kembalikan hasil yang sama jika request identik datang lagi.
// contoh konsep sederhana, skema bisa disesuaikan
public function checkout(Request $request)
{
$key = $request->header('Idempotency-Key');
if (!$key) {
return response()->json(['message' => 'Idempotency-Key wajib'], 400);
}
$existing = CheckoutRequest::where('idempotency_key', $key)->first();
if ($existing) {
return response()->json([
'order_id' => $existing->order_id,
'message' => 'Request duplikat'
], 200);
}
return DB::transaction(function () use ($request, $key) {
// kurangi stok secara aman
// buat order
// simpan idempotency record
});
}Idempotensi melindungi dari duplikasi request yang berasal dari sumber yang sama, tetapi tidak menggantikan locking atau atomic update untuk konflik antar user yang membeli produk yang sama.
Menggabungkan Solusi yang Realistis
Untuk banyak aplikasi e-commerce atau sistem pemesanan, kombinasi berikut cukup aman dan praktis:
- Atomic update atau row-level locking untuk pengurangan stok.
- Database transaction untuk menjaga konsistensi antara stok dan order.
- Idempotency key untuk mencegah order ganda akibat retry.
- Unique constraint jika ada identitas bisnis yang harus unik.
Contohnya, Anda bisa memakai atomic update untuk stok lalu tetap membungkus pembuatan order dalam transaksi singkat. Jika order gagal dibuat, transaksi di-rollback sehingga stok kembali konsisten.
Kesalahan Umum Saat Memperbaiki Bug Ini
- Hanya menambahkan transaction tanpa locking atau atomic condition.
- Mengecek stok di aplikasi lalu mengurangi dengan query terpisah.
- Memindahkan masalah ke cache tanpa sumber kebenaran yang konsisten di database.
- Transaksi terlalu panjang, misalnya mencakup request ke payment gateway.
- Tidak punya idempotensi sehingga retry dari client membuat order ganda.
- Tidak menguji konkurensi setelah perbaikan, sehingga bug bisa muncul lagi di perubahan berikutnya.
Strategi Testing untuk Mencegah Regresi
1. Uji logika stok di level unit/integration
Minimal, pastikan fungsi pengurangan stok gagal jika stok tidak cukup, dan sukses jika cukup. Ini belum membuktikan keamanan konkurensi, tetapi tetap penting sebagai fondasi.
2. Tambahkan concurrency test
Testing konkurensi memang lebih sulit dibanding test biasa, tetapi tetap bisa dilakukan secara pragmatis. Ada beberapa pendekatan:
- menjalankan dua proses HTTP paralel ke endpoint lokal,
- membuat command/test khusus yang memicu request bersamaan,
- menjalankan skenario load kecil di pipeline atau environment staging.
Yang diuji bukan performa, melainkan invariant bisnis:
- jumlah order sukses tidak boleh melebihi stok awal,
- stok akhir tidak boleh minus,
- request duplikat dengan idempotency key yang sama tidak membuat order baru.
3. Simulasikan retry dari client
Selain request paralel antar user, uji juga dua request identik dengan Idempotency-Key yang sama. Hasilnya harus satu order yang sama, bukan dua order berbeda.
Tips Debugging Lanjutan di Production
- Tambahkan metric untuk jumlah kegagalan checkout karena stok habis, lock timeout, atau duplicate idempotency key.
- Log hasil row count pada atomic update. Jika
updated = 0, berarti stok tidak cukup atau data sudah berubah. - Amati deadlock/lock wait di log database jika memakai locking.
- Pastikan isolasi transaksi dipahami, tetapi jangan mengandalkan asumsi default database tanpa verifikasi perilaku query Anda.
- Audit setelah insiden: cocokkan total order sukses dengan total pengurangan stok untuk product yang terdampak.
Trade-off Performa dan Desain
Row-level locking
Bagus untuk konsistensi kuat pada alur bisnis kompleks, tetapi kontensi akan meningkat pada produk yang sangat populer. Jika banyak request menunggu lock pada satu baris produk, latensi checkout bisa naik.
Atomic update
Umumnya lebih ringan untuk kasus stok sederhana karena database menangani validasi dan pengurangan dalam satu langkah. Namun kode bisnis bisa menjadi kurang eksplisit jika terlalu banyak aturan ditumpuk dalam query.
Queue bukan solusi utama untuk race condition request sinkron
Memasukkan checkout ke queue bisa mengurangi bentrokan, tetapi bukan obat universal. Anda tetap membutuhkan kontrol konsistensi di sumber data. Queue lebih cocok sebagai bagian arsitektur yang lebih besar, bukan pengganti proteksi stok di database.
Prinsip pentingnya: stok adalah state kritikal. Pengaman utamanya harus berada di lapisan yang benar-benar mengelola state bersama, yaitu database atau mekanisme sinkronisasi yang setara andalnya.
Checklist Verifikasi Setelah Deploy
- Pastikan endpoint checkout memakai transaction yang benar-benar membungkus perubahan stok dan pembuatan order.
- Pastikan ada salah satu proteksi inti: lockForUpdate() atau atomic update dengan syarat stok.
- Verifikasi dari log SQL bahwa query yang dieksekusi sesuai desain, bukan kembali ke pola select lalu update terpisah.
- Uji dua atau lebih request paralel pada produk dengan stok 1.
- Pastikan hanya satu order yang sukses untuk item terakhir.
- Pastikan stok akhir tidak minus.
- Uji retry dengan Idempotency-Key yang sama dan pastikan tidak muncul order ganda.
- Monitor error rate, lock wait, dan anomali stok selama periode setelah deploy.
- Siapkan query audit cepat untuk membandingkan stok dan order pada produk yang trafiknya tinggi.
Penutup
Debug race condition pada stok di Laravel hampir selalu bermuara pada satu hal: ada celah antara membaca stok dan mengubahnya ketika dua request berjalan bersamaan. Solusi yang andal bukan sekadar menambah validasi, melainkan memastikan operasi stok aman di bawah konkurensi dengan row-level locking atau atomic update, lalu melengkapinya dengan transaction dan idempotensi.
Jika Anda sedang menangani bug oversell di production, mulailah dari reproduksi lokal yang sengaja dibuat paralel, lihat query SQL yang benar-benar berjalan, lalu perbaiki titik kritis pada level database. Setelah itu, tambahkan test concurrency dan checklist verifikasi pasca deploy agar bug yang sama tidak kembali dalam perubahan berikutnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!