When users want to purchase their orders, The quantity of the products should be decreased. Now I'm planning to use a Transaction and a FOR UPDATE
lock on the products and decrease the quantity of each product.
Laravel psudo code:
// Check quantities before entering the transaction
...
DB::transaction(function () use ($cartItems) {
$ids = $cartItems->pluck('product_id')->toArray(); // Extract product_ids -> [50, 100, 99, ...]
$products = Product::whereIn('id', $ids)->lockForUpdate()->get();
// Check quantities
foreach ($cartItems as $item) {
// Find the associated product
$product = $products->first(fn ($product) => $product->id === $item->product_id);
// Throw an exception if the product quantity is less than the quantity of the cart item
if ($product->quantity < $item->quantity) {
throw new \Exception("Quantity error for Product #{$product->id}");
}
}
// Decrease quantities for each product
foreach ($cartItems as $item) {
$product = $products->first(fn ($product) => $product->id === $item->product_id);
// SET `quantity` = `quantity` - ?
$product->decrement('quantity', $item->quantity);
$product->save();
}
...
});
...
// Go to paypal
Why the FOR UPDATE
lock?
Because of race-conditions. Multiple users could order the same product at the same time while the product quantity was 1.
I tried to log sql queries of Woocommerce and some other eCommerce projects but they don't use Transactions or the for update
lock. I also asked ChatGPT to generate the code it gave me a similar piece of code to mine.
Please correct me and my code if i'm wrong.
Update
An update for one of the comments.
In the following code we have 3 routes.
/lock-and-sleep
Selects a product with thefor update
lock then sleeps for 20 seconds./test-without-lock
Selects the same product without a lock. (Independent)/test-with-lock
Selects the product with afor update
. This is dependent to the first route.
// Response time: 20s~
Route::get('/lock-and-sleep', function () {
DB::transaction(function () {
Product::where('id', 5)->lockForUpdate()->first();
sleep(20);
});
});
// Response time: 0.XYZms (Not Blocked)
Route::get('/test-without-lock', function () {
Product::where('id', 5)->first();
DB::transaction(function () {
Product::where('id', 5)->first();
});
});
// Response time: 20s~ (Blocked)
Route::get('/test-with-lock', function () {
DB::transaction(function () {
Product::where('id', 5)->lockForUpdate()->first();
});
});