Lewati ke konten
Rama's logo Qisthi Ramadhani
Go back

Laravel Race Condition Fix: Cache::lock vs Database Locks

TL;DR: To fix Laravel race conditions, use Cache::lock() for application-level distributed locking (e.g., preventing duplicate jobs) and lockForUpdate() for strict database-level pessimistic locking (e.g., deducting account balances). Never use Cache::lock() to protect database integrity.

Understanding the Concurrency Problem

Race conditions occur when multiple processes (or queue workers) attempt to read and modify the same shared resource simultaneously. In Laravel applications running in distributed environments or with high-concurrency workers (e.g., Laravel Octane), this leads to data corruption, duplicate entries, or negative balances.

Laravel provides two distinct mechanisms to handle concurrency: Atomic Distributed Locks and Pessimistic Database Locks. Choosing the wrong tool for the job is a common architectural flaw.

Cache::lock vs Database Locks Comparison

Before writing code to fix a race condition, evaluate what exactly you are trying to protect: code execution or data state.

FeatureDistributed Cache Lock (Cache::lock)Pessimistic DB Lock (lockForUpdate)
Lock LevelApplication / Code executionDatabase / Row-level
MechanismAtomic operations in Redis/MemcachedSELECT ... FOR UPDATE (SQL)
Transaction Bound?No. Based on time/expiration.Yes. Released on commit() or rollBack().
Deadlock RiskLow (auto-expires)High (requires careful indexing and ordering)
Best Use CaseWebhooks, Idempotency, Scheduled TasksFinancial ledgers, Inventory, Ticket booking

Strategy 1: The Application Lock (Cache::lock)

Cache::lock() utilizes your configured cache driver (must support atomic locks, like Redis or Memcached) to enforce that only one PHP process can execute a critical section of code at a specific time.

This does not lock the database. It locks the execution path.

Use Case: Processing a third-party webhook that might be retried or sent concurrently by the provider (e.g., a Stripe payment event).

Bad Practice:

public function handleWebhook(Request $request) {
    // If Stripe sends two identical webhooks simultaneously,
    // both pass this check before either inserts the record.
    if (!Payment::where('transaction_id', $request->id)->exists()) {
        Payment::create(['transaction_id' => $request->id]);
    }
}

Best Practice:

use Illuminate\Support\Facades\Cache;

public function handleWebhook(Request $request) {
    // Acquire a lock for 10 seconds based on the unique transaction ID
    $lock = Cache::lock('processing_payment_'.$request->id, 10);

    // block(5) waits up to 5 seconds to acquire the lock
    $lock->block(5, function () use ($request) {
        if (!Payment::where('transaction_id', $request->id)->exists()) {
            Payment::create(['transaction_id' => $request->id]);
        }
    });
}

Strategy 2: The Database Lock (lockForUpdate)

When executing read-modify-write operations, you must rely on the database’s ACID compliance. Pessimistic locking prevents other transactions from reading or writing the selected rows until your transaction completes.

Laravel provides lockForUpdate() (Exclusive Lock) and sharedLock() (Shared Lock). For fixing data corruption, lockForUpdate() is the standard choice.

Use Case: Deducting a user’s wallet balance.

Bad Practice:

// Concurrency failure: Two requests read $balance = 100 at the same time.
// Both deduct 100 and save 0. The user just spent 200 but only lost 100.
$wallet = Wallet::find($userId);

if ($wallet->balance >= $request->amount) {
    $wallet->balance -= $request->amount;
    $wallet->save();
}

Best Practice:

use Illuminate\Support\Facades\DB;

// The transaction is mandatory. Locks are released upon commit.
DB::transaction(function () use ($userId, $request) {
    // lockForUpdate() appends `FOR UPDATE` to the SQL query
    $wallet = Wallet::where('user_id', $userId)->lockForUpdate()->first();

    if ($wallet->balance >= $request->amount) {
        $wallet->balance -= $request->amount;
        $wallet->save();
    } else {
        throw new InsufficientFundsException();
    }
});

A Note on Deadlocks and Indexes

When using lockForUpdate(), ensure that your queries are hitting highly specific indexes (ideally the Primary Key). If the database cannot use an index, it might escalate to a table lock, causing catastrophic performance degradation and SQLSTATE[40001]: Serialization failure: 1213 Deadlock found errors.

Recommendation

  1. Use Cache::lock() when your goal is Idempotency (preventing an action from happening twice).
  2. Use lockForUpdate() when your goal is Data Integrity (ensuring calculations based on existing data remain accurate).
  3. Combine them when dealing with high-volume, external triggers that hit complex database transactions to reduce unnecessary DB contention.

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

Previous Post
Laravel Queue Deadlock: Redis vs Database Driver (SQLSTATE 40001)
Next Post
🧩 From Laravel to Go (Part 2): Thinking Clean in a Statically Typed World