0

How do you prevent race conditions in Laravel?

Advanced5 min read·eng-10-009
interviewperformance

Concept

Race conditions in Laravel occur when two concurrent requests read the same data, make decisions based on it, and then both write — resulting in inconsistent state.

Classic race condition: Two requests check if ($account->balance >= $amount). Both see balance = $100. Both proceed. Both deduct $100. Balance ends up at $0 (or negative) when it should be $0 after just one deduction.

The root cause: Read → Compute → Write is not atomic. Between the read and the write, another request can change the data.

Prevention strategies:

1. Pessimistic locking (lockForUpdate() / sharedLock()): Locks the row at SELECT time. Other transactions that try to SELECT the same row with lockForUpdate() will WAIT until the lock is released at COMMIT. Prevents race conditions at the cost of serializing concurrent writes.

2. Optimistic locking: Use a version column. Read → increment version in WHERE condition → if 0 rows updated, another process won. Retry. No blocking, but more complex retry logic.

3. Atomic database operations (increment() / decrement()): Execute the math IN the database, not in PHP. UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100. If the check fails, 0 rows are affected — safe.

4. Redis atomic operations (INCR, DECR, SET NX, Lua scripts): Redis is single-threaded — operations are inherently atomic. Use for counters, rate limiting, semaphores.

5. Queuing serialization: Process operations through a queue with concurrency=1 for a specific resource. If only one queue worker handles account transactions, they're naturally serialized.

Code Example

php
<?php
// ❌ RACE CONDITION — not safe
public function deductBalance(int $accountId, float $amount): void
{
    $account = Account::find($accountId);         // read
    if ($account->balance < $amount) throw new InsufficientFundsException();
    $account->balance -= $amount;                  // compute (in PHP)
    $account->save();                              // write
    // Two requests reading simultaneously both see balance = 100
    // Both deduct $50, both save $50 — net balance should be $0, ends up $50!
}

// ✅ PESSIMISTIC LOCKING — safe, blocks concurrent access
public function deductBalance(int $accountId, float $amount): void
{
    DB::transaction(function () use ($accountId, $amount) {
        $account = Account::where('id', $accountId)
            ->lockForUpdate()  // SELECT ... FOR UPDATE — other transactions wait
            ->firstOrFail();

        if ($account->balance < $amount) throw new InsufficientFundsException();
        $account->decrement('balance', $amount); // atomic in DB
    }); // lock released at COMMIT
}

// ✅ OPTIMISTIC LOCKING — no blocking, retry on conflict
public function deductBalance(int $accountId, float $amount): void
{
    $maxAttempts = 3;
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $account = Account::find($accountId);
        if ($account->balance < $amount) throw new InsufficientFundsException();

        $updated = Account::where('id', $accountId)
            ->where('version', $account->version)   // only update if version matches
            ->where('balance', '>=', $amount)
            ->update([
                'balance' => $account->balance - $amount,
                'version' => $account->version + 1,
            ]);

        if ($updated === 1) return; // success
        if ($attempt === $maxAttempts) throw new \RuntimeException('Concurrent modification — try again');
        usleep(100_000 * $attempt); // backoff before retry
    }
}

// ✅ ATOMIC SQL — single statement check + update
public function deductBalance(int $accountId, float $amount): void
{
    $affected = DB::update(
        'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?',
        [$amount, $accountId, $amount]
    );
    if ($affected === 0) throw new InsufficientFundsException('Balance too low or account not found');
}

// ✅ REDIS ATOMIC — for counters and rate limiting
use Illuminate\Support\Facades\Redis;

public function decrementStock(string $productId, int $qty): bool
{
    $luaScript = <<<'LUA'
    local current = tonumber(redis.call('GET', KEYS[1]) or 0)
    if current < tonumber(ARGV[1]) then
        return 0
    end
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
    LUA;

    return Redis::eval($luaScript, 1, "stock:{$productId}", $qty) === 1;
}