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.