I once spent 72 hours debugging a production issue where users were being charged twice for the same order. The code looked perfect. Tests passed. Code review approved. But under high concurrency, a race condition manifested that cost the company $47,000 before we caught it. This experience taught me that concurrency bugs are the most expensive bugs you'll ever ship.

Understanding Race Conditions

A race condition occurs when the behavior of software depends on the relative timing of events, such as the order of thread execution. Here's the classic "double-spend" scenario:

// Dangerous: Race condition in payment processing
public function processPayment(Order $order) 
{
    // Thread A reads: isPaid = false
    // Thread B reads: isPaid = false (simultaneous!)
    if (!$order->isPaid()) {
        $this->chargeCustomer($order);  // Both threads execute this!
        $order->markAsPaid();
    }
}

Both threads see isPaid = false because neither has committed yet. Result: customer charged twice.

The Locking Toolkit: Choosing the Right Strategy

1. Pessimistic Locking (Database-Level)

Use when: Write contention is high, and you need guaranteed consistency.

// Laravel: Pessimistic locking with FOR UPDATE
public function processPayment(Order $order)
{
    DB::transaction(function () use ($order) {
        // Lock the row - other transactions wait
        $order = Order::where('id', $order->id)
            ->lockForUpdate()
            ->first();
        
        if (!$order->isPaid()) {
            $this->chargeCustomer($order);
            $order->markAsPaid();
            $order->save();
        }
    });
}

Pros: Simple, guaranteed consistency. Cons: Can cause deadlocks, reduces throughput.

2. Optimistic Locking (Version-Based)

Use when: Read-heavy workloads with occasional writes.

// Model with version tracking
class Order extends Model
{
    protected $fillable = ['amount', 'status', 'version'];
}

public function processPayment(Order $order)
{
    $currentVersion = $order->version;
    
    // Attempt update with version check
    $updated = Order::where('id', $order->id)
        ->where('version', $currentVersion)
        ->where('status', '!=', 'paid')
        ->update([
            'status' => 'paid',
            'version' => $currentVersion + 1
        ]);
    
    if ($updated === 0) {
        // Another process modified the order - retry or fail
        throw new ConcurrencyException('Order was modified by another process');
    }
    
    $this->chargeCustomer($order);
}

3. Distributed Locking with Redis

Use when: Multiple application servers need to coordinate access to shared resources.

// Redis distributed lock implementation
use Illuminate\Support\Facades\Cache;

public function processPayment(Order $order)
{
    $lockKey = "order_lock:{$order->id}";
    
    // Acquire lock with 30 second TTL
    $lock = Cache::lock($lockKey, 30);
    
    try {
        // Block for max 10 seconds waiting for lock
        if ($lock->block(10)) {
            $order->refresh(); // Reload fresh data
            
            if (!$order->isPaid()) {
                $this->chargeCustomer($order);
                $order->markAsPaid();
                $order->save();
            }
        } else {
            throw new LockTimeoutException('Could not acquire lock');
        }
    } finally {
        $lock->release();
    }
}

4. Idempotency Keys

Use when: Protecting against duplicate API requests (retries, network issues).

// Idempotency middleware
public function handle(Request $request, Closure $next)
{
    $idempotencyKey = $request->header('Idempotency-Key');
    
    if ($idempotencyKey) {
        $cacheKey = "idempotency:{$idempotencyKey}";
        
        // Check if request was already processed
        if ($cachedResponse = Cache::get($cacheKey)) {
            return response()->json(
                json_decode($cachedResponse, true),
                200,
                ['X-Idempotent-Replayed' => 'true']
            );
        }
        
        $response = $next($request);
        
        // Cache response for 24 hours
        Cache::put($cacheKey, $response->getContent(), 86400);
        
        return $response;
    }
    
    return $next($request);
}

Deadlock Prevention Strategies

Deadlocks occur when two or more transactions wait for each other to release locks. Prevention strategies:

  • Consistent Lock Ordering: Always acquire locks in the same order across all transactions
  • Lock Timeouts: Set reasonable timeouts and retry with exponential backoff
  • Minimize Lock Scope: Hold locks for the shortest time possible
  • Avoid User Interaction While Holding Locks: Never wait for external input while holding a lock
// Retry with exponential backoff
public function executeWithRetry(callable $operation, int $maxRetries = 3)
{
    $attempt = 0;
    
    while ($attempt < $maxRetries) {
        try {
            return $operation();
        } catch (DeadlockException $e) {
            $attempt++;
            if ($attempt >= $maxRetries) throw $e;
            
            // Exponential backoff: 100ms, 200ms, 400ms...
            usleep(100000 * pow(2, $attempt - 1));
        }
    }
}

Testing for Race Conditions

Race conditions are notoriously hard to test because they depend on timing. Here's a testing strategy:

// PHPUnit test for concurrent access
public function test_payment_handles_concurrent_requests()
{
    $order = Order::factory()->create(['status' => 'pending', 'amount' => 100]);
    
    $promises = [];
    
    // Simulate 10 concurrent payment attempts
    for ($i = 0; $i < 10; $i++) {
        $promises[] = async(function () use ($order) {
            return $this->paymentService->processPayment($order->id);
        });
    }
    
    $results = await($promises);
    
    // Only ONE payment should succeed
    $successCount = collect($results)->filter(fn($r) => $r->success)->count();
    $this->assertEquals(1, $successCount);
    
    // Customer should be charged exactly once
    $this->assertEquals(1, Payment::where('order_id', $order->id)->count());
}

Key Takeaways

  • Race conditions are expensive—invest in prevention upfront
  • Choose your locking strategy based on read/write ratio and scale requirements
  • Always use idempotency keys for payment and critical operations
  • Implement retry logic with exponential backoff for deadlock recovery
  • Test concurrency explicitly—don't assume single-threaded behavior

Remember: The cost of preventing race conditions is always less than the cost of fixing them in production.