Preventing Race Conditions in Laravel: Solving the Overselling Problem

Preventing Race Conditions in Laravel: Solving the Overselling Problem

Introduction

When building high-traffic applications in Laravel, handling concurrency issues like race conditions is crucial to maintaining data integrity. One common scenario where race conditions occur is in e-commerce applications when multiple users try to purchase the last available item simultaneously. Without proper handling, this can lead to overselling, which negatively impacts user experience and inventory management.

In this article, we’ll explore how race conditions happen in Laravel applications and discuss effective strategies to prevent them using database transactions, row-level locking, atomic updates, and retry strategies.

Understanding the Race Condition Issue in Laravel

Imagine you have a PHP Laravel application handling product purchases. The initial code for processing an order might look something like this:

$product = Product::query()->where('id', $productId)->first();

if ($product->stock > 0) {
    $product->decrement('stock');
    Order::query()->create([
        'user_id' => auth()->id(),
        'product_id' => $productId,
    ]);
}

The Problem

Since multiple users can access the system at the same time, two or more users might pass the stock check before the decrement operation is completed. This means they both see the last item as available and purchase it simultaneously, resulting in an oversell.

To prevent this, we need to ensure that only one process at a time can check and update the stock.

Solution: Using Database Transactions, Row-Level Locking, and Retry Strategy

Step 1: Implement lockForUpdate()

To ensure that only one request at a time can process the stock update, we use database transactions along with lockForUpdate(), which prevents other transactions from reading or modifying the locked row until the current transaction completes.

Here’s the improved code:

DB::transaction(function () use ($productId) {
    $product = Product::query()->where('id', $productId)->lockForUpdate()->first();

    throw_if(
        $product->stock === 0,
        new CannotOrderProductOutOfStockException()
    );

    $product->decrement('stock');

    Order::query()->create([
        'user_id' => auth()->id(),
        'product_id' => $productId,
    ]);
}, 3); // Retry up to 3 times in case of deadlocks

Step 2: Implement a Retry Strategy for Deadlocks

Sometimes, due to high concurrency, transactions might run into deadlocks. Laravel provides a built-in way to automatically retry transactions when they fail due to deadlocks.

By passing a second argument to DB::transaction(), we specify the number of times Laravel should retry the transaction before throwing an exception.

DB::transaction(function () use ($productId) {
    $product = Product::query()->where('id', $productId)->lockForUpdate()->first();

    throw_if(
        $product->stock === 0,
        new CannotOrderProductOutOfStockException()
    );

    $product->decrement('stock');

    Order::query()->create([
        'user_id' => auth()->id(),
        'product_id' => $productId,
    ]);
}, 5); // Retry up to 5 times in case of deadlocks

Why This Works

  • The lockForUpdate() ensures that once a request retrieves the product row, it locks it, preventing other transactions from reading or modifying it until the transaction is committed.
  • The retry strategy ensures that if a deadlock occurs, Laravel will automatically retry the transaction, reducing the chances of failure in high-concurrency environments.

Alternative Solutions

1. Atomic Updates Using Conditional Queries

Another way to handle this without explicit locking is to use atomic database updates, ensuring that the stock decrement happens only if there is available stock:

$updated = Product::query()->where('id', $productId)
    ->where('stock', '>', 0)
    ->decrement('stock');

throw_unless(
    $updated,
    new CannotOrderProductOutOfStockException()
);

Order::query()->create([
    'user_id' => auth()->id(),
    'product_id' => $productId,
]);

This prevents the race condition by making sure that only one request can decrement stock at a time.

2. Using Redis for Distributed Locking

If your Laravel application runs on multiple database instances or servers, you can use Redis-based locking to synchronize concurrent requests across different application instances.

$lock = Cache::lock('product_'.$productId, 10);

if (!$lock->get()) {
    throw new UnableToAcquireLockException();
}

$product = Product::query()->find($productId);

if ($product->stock > 0) {
    $product->decrement('stock');
    Order::query()->create([
        'user_id' => auth()->id(),
        'product_id' => $productId,
    ]);
}

$lock->release();

This ensures that only one request can modify the product stock at a time, even across multiple servers.

Final Thoughts

Handling race conditions in Laravel applications is essential for preventing issues like overselling in e-commerce platforms. By leveraging database transactions, row-level locking (lockForUpdate()), retry strategies, atomic updates, and Redis-based locking, you can ensure that stock management remains accurate and reliable under high concurrency.

When choosing a solution, consider your application’s specific requirements:

  • Use lockForUpdate() with retries if you’re dealing with a single database instance under high concurrency.
  • Use atomic updates for a simple and efficient fix.
  • Use Redis locks if your application runs on multiple servers.

By implementing these strategies, you can prevent race conditions and build a more robust Laravel application. 🚀