Race condition pada update stok pesanan di CodeIgniter 4 biasanya muncul ketika dua request checkout memproses produk yang sama pada waktu hampir bersamaan. Gejalanya sederhana tetapi dampaknya serius: stok bisa menjadi minus, pesanan lolos padahal stok sebenarnya tidak cukup, atau angka stok antar tabel dan log tidak sinkron.
Masalah ini hampir selalu berasal dari pola read-modify-write tanpa proteksi yang benar. Kode membaca stok saat ini, menghitung stok baru di aplikasi, lalu melakukan UPDATE. Ketika dua request membaca nilai lama yang sama, keduanya merasa stok masih cukup. Hasil akhirnya adalah data tidak konsisten meskipun kode terlihat benar saat diuji secara tunggal.
Konteks fitur dan gejala di produksi
Anggap ada fitur checkout sederhana:
- Pengguna mengirim request checkout untuk produk tertentu.
- Backend membaca stok dari tabel
products. - Jika stok cukup, backend mengurangi stok dan membuat order.
- Jika stok tidak cukup, request ditolak.
Secara fungsional ini benar. Masalah muncul ketika dua request untuk produk yang sama tiba hampir bersamaan, misalnya dari:
- dua tab browser,
- retry dari klien mobile,
- dua worker backend,
- lonjakan traffic pada promo.
Gejala yang sering terlihat
- Stok akhir menjadi minus.
- Dua order sukses, padahal stok hanya cukup untuk satu order.
- Log menunjukkan dua request sama-sama lolos validasi stok.
- Support menerima komplain bahwa status order sukses tetapi barang habis.
- Data audit menunjukkan urutan waktu yang sangat rapat pada produk yang sama.
Pola log yang biasanya muncul
Contoh pola log yang perlu dicurigai:
[2026-06-14 10:00:01.120] checkout start request_id=req-a product_id=10 qty=1
[2026-06-14 10:00:01.121] stock read request_id=req-a product_id=10 stock=1
[2026-06-14 10:00:01.122] checkout start request_id=req-b product_id=10 qty=1
[2026-06-14 10:00:01.123] stock read request_id=req-b product_id=10 stock=1
[2026-06-14 10:00:01.140] stock updated request_id=req-a product_id=10 new_stock=0
[2026-06-14 10:00:01.145] stock updated request_id=req-b product_id=10 new_stock=0
[2026-06-14 10:00:01.150] order created request_id=req-a order_id=501
[2026-06-14 10:00:01.151] order created request_id=req-b order_id=502Dari log di atas, kedua request membaca stok yang sama sebelum ada salah satu yang benar-benar mengunci atau mengubah state secara aman. Walaupun stok akhir tampak 0, sebenarnya ada dua order sukses untuk stok awal 1. Ini juga bentuk inkonsistensi.
Reproduksi bug secara sederhana
Sebelum memperbaiki, pastikan bug bisa direproduksi. Tujuannya bukan membuat lingkungan uji yang sempurna, tetapi memastikan akar masalah memang concurrency.
Skenario data awal
- Produk ID 10 memiliki stok 1.
- Dua request checkout dikirim hampir bersamaan dengan qty 1.
Contoh endpoint
Misalnya endpoint POST /checkout menerima:
{
"product_id": 10,
"qty": 1
}Cara reproduksi cepat
- Set stok produk menjadi 1.
- Kirim dua request paralel ke endpoint checkout.
- Amati apakah dua order berhasil atau stok menjadi salah.
Contoh dengan dua terminal:
curl -X POST http://localhost:8080/checkout \
-H "Content-Type: application/json" \
-d '{"product_id":10,"qty":1}'
curl -X POST http://localhost:8080/checkout \
-H "Content-Type: application/json" \
-d '{"product_id":10,"qty":1}'Untuk hasil yang lebih konsisten, gunakan alat load testing atau skrip kecil yang menembakkan request secara paralel. Bahkan dua request hampir bersamaan sudah cukup untuk memunculkan bug jika implementasinya rentan.
Root cause: read-modify-write tanpa proteksi
Pola yang paling sering salah terlihat seperti ini:
$product = $productModel->find($productId);
if ($product['stock'] < $qty) {
return $this->response->setStatusCode(409)->setJSON([
'message' => 'Stok tidak cukup'
]);
}
$newStock = $product['stock'] - $qty;
$productModel->update($productId, ['stock' => $newStock]);
$orderModel->insert([
'product_id' => $productId,
'qty' => $qty,
'status' => 'paid'
]);Masalahnya bukan pada sintaks, tetapi pada urutan operasi:
- Request A membaca stok = 1.
- Request B membaca stok = 1.
- A menghitung stok baru = 0.
- B menghitung stok baru = 0.
- A update stok ke 0 dan membuat order.
- B juga update stok ke 0 dan membuat order.
Keduanya lolos karena keputusan dibuat dari pembacaan yang sudah usang.
Kesalahan umum lain
- Transaksi dipakai, tetapi tetap salah. Banyak developer mengira membungkus operasi dengan transaksi otomatis menyelesaikan race condition. Tidak selalu. Jika transaksi tetap melakukan read-modify-write tanpa lock atau tanpa conditional update, dua transaksi masih bisa membaca state lama yang sama.
- Lock terlalu lambat atau tidak relevan. Misalnya lock baru dilakukan setelah data dibaca, atau lock dilakukan di level aplikasi yang tidak dibagi antar instance server.
- Tidak memeriksa hasil update. Update stok gagal memenuhi syarat, tetapi kode tetap membuat order karena tidak memeriksa
affected rows. - Retry klien membuat duplikasi order. Walaupun stok aman, order bisa tercatat ganda jika request yang sama diproses lebih dari sekali.
Perbaikan praktis yang paling aman
Pendekatan yang paling praktis biasanya menggabungkan beberapa teknik:
- transaksi database,
- conditional update untuk mengurangi stok secara atomik,
- validasi
affected rows, - idempotensi request checkout,
- row locking jika memang perlu untuk alur yang lebih kompleks.
Pola utama: conditional update atomik
Daripada membaca stok lalu menghitung di PHP, lebih aman minta database mengurangi stok hanya jika stok masih cukup. Ini memindahkan validasi dan update menjadi satu langkah atomik.
Contoh SQL:
UPDATE products
SET stock = stock - :qty:
WHERE id = :product_id:
AND stock >= :qty:Jika query ini mengubah 1 baris, stok berhasil dikurangi. Jika 0 baris, stok tidak cukup atau produk tidak ditemukan. Ini jauh lebih aman untuk kasus checkout sederhana karena race condition utama dipotong di level database.
Implementasi model di CodeIgniter 4
<?php
namespace App\Models;
use CodeIgniter\Model;
class ProductModel extends Model
{
protected $table = 'products';
protected $primaryKey = 'id';
protected $allowedFields = ['name', 'stock', 'price'];
public function decrementStockIfAvailable(int $productId, int $qty): bool
{
$db = db_connect();
$builder = $db->table($this->table);
$builder->set('stock', 'stock - ' . (int) $qty, false);
$builder->where('id', $productId);
$builder->where('stock >=', $qty);
$builder->update();
return $db->affectedRows() === 1;
}
}Mengapa ini bekerja: keputusan "stok cukup atau tidak" dan aksi "kurangi stok" dilakukan dalam satu pernyataan SQL. Tidak ada celah di antara read dan write yang bisa disusupi request lain.
Controller checkout dengan transaksi
Setelah stok berhasil dikurangi, pembuatan order tetap harus dibungkus transaksi agar perubahan stok dan order konsisten. Jika pembuatan order gagal, stok harus dikembalikan dengan rollback.
<?php
namespace App\Controllers;
use App\Models\ProductModel;
use App\Models\OrderModel;
use CodeIgniter\RESTful\ResourceController;
class CheckoutController extends ResourceController
{
public function create()
{
$payload = $this->request->getJSON(true);
$productId = (int) ($payload['product_id'] ?? 0);
$qty = (int) ($payload['qty'] ?? 0);
$requestId = $this->request->getHeaderLine('Idempotency-Key');
if ($productId <= 0 || $qty <= 0 || $requestId === '') {
return $this->response->setStatusCode(400)->setJSON([
'message' => 'Input tidak valid'
]);
}
$db = db_connect();
$productModel = new ProductModel();
$orderModel = new OrderModel();
// Cek idempotensi sederhana
$existing = $orderModel->where('request_id', $requestId)->first();
if ($existing) {
return $this->response->setStatusCode(200)->setJSON([
'message' => 'Request sudah pernah diproses',
'order_id' => $existing['id']
]);
}
$db->transBegin();
try {
$stockUpdated = $productModel->decrementStockIfAvailable($productId, $qty);
if (! $stockUpdated) {
$db->transRollback();
return $this->response->setStatusCode(409)->setJSON([
'message' => 'Stok tidak cukup'
]);
}
$orderModel->insert([
'request_id' => $requestId,
'product_id' => $productId,
'qty' => $qty,
'status' => 'paid'
]);
if (! $db->transStatus()) {
throw new \RuntimeException('Transaksi gagal');
}
$db->transCommit();
return $this->response->setStatusCode(201)->setJSON([
'message' => 'Checkout berhasil',
'order_id' => $orderModel->getInsertID()
]);
} catch (\Throwable $e) {
if ($db->transStatus()) {
$db->transRollback();
}
log_message('error', 'Checkout failed: {message}', [
'message' => $e->getMessage()
]);
return $this->response->setStatusCode(500)->setJSON([
'message' => 'Terjadi kesalahan internal'
]);
}
}
}Model order dengan request id unik
<?php
namespace App\Models;
use CodeIgniter\Model;
class OrderModel extends Model
{
protected $table = 'orders';
protected $primaryKey = 'id';
protected $allowedFields = ['request_id', 'product_id', 'qty', 'status'];
}Di database, tambahkan unique index pada request_id agar request yang sama tidak membuat order ganda:
ALTER TABLE orders ADD CONSTRAINT uq_orders_request_id UNIQUE (request_id);Idempotensi penting karena race condition tidak selalu berasal dari dua pengguna berbeda. Sering kali sumbernya adalah retry dari jaringan, browser, gateway pembayaran, atau client timeout.
Kapan perlu row locking
Untuk kasus pengurangan stok sederhana, conditional update + transaksi biasanya sudah cukup dan lebih efisien. Namun ada alur yang lebih kompleks, misalnya:
- stok tersebar di beberapa gudang,
- ada reservasi stok sementara,
- ada beberapa tabel yang harus dibaca dulu sebelum keputusan dibuat,
- ada aturan bisnis yang tidak bisa diringkas menjadi satu
UPDATE ... WHERE.
Pada kondisi seperti itu, row locking bisa relevan, misalnya dengan pola SELECT ... FOR UPDATE di dalam transaksi, selama database yang dipakai mendukungnya.
Contoh pola locking
$db = db_connect();
$db->transBegin();
$product = $db->query(
'SELECT id, stock FROM products WHERE id = ? FOR UPDATE',
[$productId]
)->getRowArray();
if (! $product || $product['stock'] < $qty) {
$db->transRollback();
// return stok tidak cukup
}
$db->query(
'UPDATE products SET stock = stock - ? WHERE id = ?',
[$qty, $productId]
);
// insert order
$db->transCommit();Trade-off:
- Lebih mudah dipahami untuk logika bisnis kompleks.
- Tetapi lock yang terlalu lama dapat menurunkan throughput.
- Jika transaksi mencakup operasi lambat, request lain akan menunggu lebih lama.
- Salah pakai lock dapat memicu deadlock.
Karena itu, jangan langsung memakai locking untuk semua kasus. Mulailah dari conditional update atomik. Gunakan locking hanya ketika memang perlu membaca state yang harus tetap konsisten sampai write selesai.
Kesalahan transaksi yang sering membuat developer terkecoh
1. Transaksi tanpa atomic update
Banyak implementasi seperti ini:
$db->transBegin();
$product = $productModel->find($productId);
if ($product['stock'] >= $qty) {
$productModel->update($productId, ['stock' => $product['stock'] - $qty]);
$orderModel->insert([...]);
}
$db->transCommit();Ini masih rentan karena dua transaksi bisa membaca stok lama yang sama sebelum salah satunya commit.
2. Membuat order walau update stok gagal
Jika Anda memakai conditional update tetapi tidak memeriksa affectedRows(), order masih bisa terbuat saat stok sebenarnya tidak cukup.
3. Operasi eksternal di dalam transaksi
Jangan melakukan panggilan API pembayaran, kirim email, atau proses lambat lain saat row masih terkunci. Transaksi harus sesingkat mungkin.
4. Tidak ada constraint di database
Validasi di aplikasi bagus, tetapi constraint database tetap penting. Misalnya:
- unique index pada
request_id, - foreign key untuk konsistensi relasi,
- opsional check constraint jika database mendukung dan aturan bisnis cocok.
Strategi logging untuk debugging race condition
Bug concurrency sulit dibaca jika log terlalu umum. Tambahkan log yang cukup untuk menghubungkan urutan event tanpa membocorkan data sensitif.
Apa yang perlu dicatat
request_idatau idempotency key,product_id,qty,- waktu mulai dan selesai request,
- hasil update stok berhasil atau gagal,
- jumlah baris terpengaruh,
- order_id jika berhasil,
- error transaksi atau exception.
Contoh logging di CI4
log_message('info', 'Checkout start request_id={request_id} product_id={product_id} qty={qty}', [
'request_id' => $requestId,
'product_id' => $productId,
'qty' => $qty,
]);
log_message('info', 'Stock decrement result request_id={request_id} product_id={product_id} success={success}', [
'request_id' => $requestId,
'product_id' => $productId,
'success' => $stockUpdated ? 'true' : 'false',
]);Catatan: hindari menulis data kartu, token rahasia, atau payload sensitif penuh ke log. Untuk debugging concurrency, identifier dan state transaksional biasanya sudah cukup.
Pola log setelah fix yang sehat
Jika stok awal 1 dan ada dua request paralel qty 1, hasil yang sehat adalah:
- satu request sukses mengurangi stok dan membuat order,
- satu request gagal dengan status konflik atau stok tidak cukup,
- tidak ada dua order sukses untuk stok yang sama,
- tidak ada stok minus.
Checklist verifikasi setelah fix
Jangan berhenti setelah kode berubah. Verifikasi perilakunya di lingkungan uji yang mendekati produksi.
- Uji dua request paralel pada stok 1. Harus hanya satu yang sukses.
- Uji burst kecil misalnya 10 request paralel pada stok 5. Order sukses tidak boleh lebih dari 5.
- Uji retry dengan request_id yang sama. Harus tidak membuat order ganda.
- Uji rollback. Simulasikan kegagalan insert order setelah stok terpotong, lalu pastikan transaksi membatalkan semuanya.
- Periksa log. Pastikan mudah menghubungkan urutan request dan hasil
affected rows. - Periksa metrik error. Setelah fix, konflik stok mungkin naik, tetapi inkonsistensi data harus turun.
- Review indeks database. Pastikan kolom yang sering dipakai di
WHEREdan unique key tersedia. - Uji beban ringan. Pastikan lock atau transaksi tidak membuat endpoint terlalu lambat.
Ringkasan pendek untuk implementasi
Jika Anda ingin memperbaiki bug ini secepat dan seaman mungkin di CodeIgniter 4, urutannya biasanya seperti berikut:
- Ganti pola baca-stok-lalu-update menjadi conditional update atomik.
- Bungkus pengurangan stok dan pembuatan order dalam transaksi.
- Periksa affected rows sebelum membuat order.
- Tambahkan idempotency key dan unique constraint untuk mencegah duplikasi request.
- Tambah logging yang cukup untuk analisis concurrency.
- Pakai row locking hanya jika logika bisnis memang lebih kompleks dari satu update atomik.
Intinya, bug race condition pada update stok pesanan bukan masalah yang selesai hanya dengan menambahkan transaksi secara umum. Yang paling penting adalah memastikan keputusan bisnis dan perubahan data kritis dilakukan secara atomik, lalu memverifikasi hasilnya dengan log, constraint, dan pengujian paralel. Dengan pendekatan ini, implementasi CodeIgniter 4 akan jauh lebih tahan terhadap request bersamaan di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!