Lewati ke konten
Rama's logo Qisthi Ramadhani
Go back

Laravel Race Condition in Cache and Database Locks: How to Fix

TL;DR

Race conditions occur when multiple processes or requests attempt to read and write the same resource concurrently without proper synchronization. In Laravel, this commonly manifests during inventory updates, payment processing, or any operation where a read-then-write pattern exists. This guide walks you through identifying the symptoms, understanding the root cause, and implementing a production-tested fix with real code examples.


Symptoms

If you’re experiencing this issue, you’ll likely notice one or more of these signs in your application:

If any of these symptoms look familiar, you’re dealing with laravel race condition in cache and database locks. Read on to understand why it happens and how to fix it properly.


Root Cause

Race conditions occur when multiple processes or requests attempt to read and write the same resource concurrently without proper synchronization. In Laravel, this commonly manifests during inventory updates, payment processing, or any operation where a read-then-write pattern exists. Without atomic locking, two requests can read the same stale value and both write conflicting updates, leading to data corruption, overselling, or duplicate transactions.

Understanding the root cause is critical before applying a fix — treating only the symptoms often leads to the problem resurfacing in a different form, especially under production load.


How to Fix

Follow these steps in order. Each step builds on the previous one, and skipping steps may result in an incomplete fix.

Step 1: Identify the critical section

Identify the critical section — the code between reading shared state and writing the update

Step 2: Choose a locking strategy: Cache::lock() for distributed Redis locks or DB::transaction() with SELECT … FOR UPDATE for database-level pessimistic locking

Choose a locking strategy: Cache::lock() for distributed Redis locks or DB::transaction() with SELECT … FOR UPDATE for database-level pessimistic locking

Step 3: For Cache::lock(): use Cache::lock(‘resource-key’, 10)->block(5, fn() => …) to acquire a lock with timeout

For Cache::lock(): use Cache::lock(‘resource-key’, 10)->block(5, fn() => …) to acquire a lock with timeout

Step 4: For database locks: wrap the operation in DB::transaction() and use ->lockForUpdate() on the Eloquent query

For database locks: wrap the operation in DB::transaction() and use ->lockForUpdate() on the Eloquent query

Step 5: Add retry logic with exponential backoff for lock acquisition failures

Add retry logic with exponential backoff for lock acquisition failures

Step 6: Test under concurrent load using PestPHP stress testing or Apache Bench to verify the fix

Test under concurrent load using PestPHP stress testing or Apache Bench to verify the fix


Code Example

The following before/after comparison shows the core pattern you need to change. Pay attention to the comments explaining why each change matters.

Before (Broken)

// UNSAFE: Race condition — two requests can read the same quantity
$product = Product::find($id);
if ($product->quantity >= $requested) {
    $product->quantity -= $requested;
    $product->save();
    Order::create(['product_id' => $id, 'quantity' => $requested]);
}

After (Fixed)

// SAFE: Using Cache::lock for distributed locking
use Illuminate\Support\Facades\Cache;

$lock = Cache::lock("product-{$id}-checkout", 10);

try {
    $lock->block(5); // Wait up to 5 seconds to acquire

    DB::transaction(function () use ($id, $requested) {
        $product = Product::lockForUpdate()->findOrFail($id);

        if ($product->quantity < $requested) {
            throw new InsufficientStockException();
        }

        $product->decrement('quantity', $requested);
        Order::create(['product_id' => $id, 'quantity' => $requested]);
    });
} catch (LockTimeoutException $e) {
    return response()->json(['error' => 'Server busy, please retry'], 503);
} finally {
    $lock?->release();
}

Key Takeaways


Difficulty Level

Advanced

This guide requires deep understanding of framework internals and production debugging techniques. You should be experienced with profiling tools, understand concurrency patterns, and be comfortable debugging issues that only manifest under production load or specific timing conditions.


Frequently Asked Questions

When should I use Cache::lock() vs database locks in Laravel?

Use Cache::lock() (Redis-backed) when you need distributed locking across multiple servers or when the critical section involves non-database resources (API calls, file operations). Use database locks (SELECT … FOR UPDATE) when the race condition is purely database-related and you want transactional guarantees. For maximum safety in payment/inventory scenarios, combine both: Cache::lock for distributed coordination and DB::transaction with lockForUpdate for data integrity.

How do I test for race conditions in Laravel?

Use PestPHP’s built-in stress testing: pest stress http://your-app/checkout --concurrency=10 --duration=5. You can also write a PHPUnit test that uses PHP’s pcntl_fork() or Laravel’s Bus::batch() to simulate concurrent requests. Monitor your database for constraint violations and check that counters are correct after the test.

Does Laravel Octane make race conditions worse?

Yes. Because Octane keeps the application in memory across requests using Swoole workers, global state and singletons persist between requests. This increases the surface area for race conditions. Always use request-scoped data and explicit locking in Octane applications.


Share this post on:
LLM-friendly version:
Open in ChatGPT Open in Claude

Related Posts


Previous Post
Laravel Queue Deadlock with Redis vs Database Driver: How to Fix
Next Post
PestPHP Stress Testing for Health Endpoint Benchmarking: How to Fix