Deadlock — two transactions each waiting for the other to release a lock
Intermediate5 min read·eng-15-012
sqlinterview
Concept
Deadlock — two (or more) transactions each holding a lock that the other needs, resulting in a permanent standstill where neither can proceed.
Classic deadlock scenario:
- Transaction A locks row 1, then tries to lock row 2.
- Transaction B locks row 2, then tries to lock row 1.
- A waits for B to release row 2. B waits for A to release row 1. Neither can proceed — deadlock.
How databases handle deadlocks: Automatically. The database detects the circular wait, picks a "victim" transaction (usually the one that's easier to rollback), rolls it back, and lets the other proceed. The victim transaction receives an error.
MySQL error: SQLSTATE[40001]: Deadlock found when trying to get lock; try restarting transaction.
Common deadlock causes:
- Inconsistent lock ordering: Transaction A locks
usersthenorders. Transaction B locksordersthenusers. - Long-running transactions holding locks while doing other work.
- Missing indexes: Full table scans lock more rows than necessary.
- High concurrency on the same rows.
How to prevent deadlocks:
- Consistent lock ordering: Always lock tables/rows in the same order across all transactions.
- Keep transactions short: Acquire locks, do the work, commit fast. Don't make HTTP calls inside a transaction.
- Use indexes: Narrow the lock scope. Without an index, MySQL may lock the whole table range.
- Optimistic locking: Use a version column instead of row locks. Retry on conflict.
Laravel's retry: DB::transaction($fn, $attempts) — if the transaction fails with a deadlock, it automatically retries up to $attempts times.
Code Example
php
<?php
// DEADLOCK SCENARIO — two transactions locking rows in opposite order
// This code won't actually deadlock in sequence — needs concurrent execution
// Transaction A (Request 1):
\DB::transaction(function () {
\DB::table('accounts')->where('id', 1)->lockForUpdate()->first(); // lock row 1
usleep(100000); // simulate work — row 2 might be locked by B!
\DB::table('accounts')->where('id', 2)->lockForUpdate()->first(); // WAITS for B to release row 2
});
// Transaction B (Request 2, concurrent):
\DB::transaction(function () {
\DB::table('accounts')->where('id', 2)->lockForUpdate()->first(); // lock row 2
usleep(100000); // simulate work — row 1 might be locked by A!
\DB::table('accounts')->where('id', 1)->lockForUpdate()->first(); // WAITS for A to release row 1
// DEADLOCK → DB rolls back one of them
});
// PREVENTION: consistent lock ordering
function transferMoney(int $fromId, int $toId, float $amount): void
{
// Always lock lower ID first to ensure consistent ordering!
[$first, $second] = $fromId < $toId ? [$fromId, $toId] : [$toId, $fromId];
\DB::transaction(function () use ($first, $second, $fromId, $amount) {
\DB::table('accounts')->where('id', $first)->lockForUpdate()->first(); // always lower ID first
\DB::table('accounts')->where('id', $second)->lockForUpdate()->first(); // then higher ID
// No deadlock: both Transaction A and B will lock in the same order
\DB::table('accounts')->where('id', $fromId)->decrement('balance', $amount);
\DB::table('accounts')->where('id', ($fromId === $first ? $second : $first))->increment('balance', $amount);
});
}
// Laravel's automatic retry on deadlock
$order = \DB::transaction(function () {
return Order::create(['user_id' => 1, 'total' => 99.99]);
}, attempts: 3); // if deadlock → retry up to 3 times automatically
// Handling deadlock manually
try {
\DB::beginTransaction();
// ... operations
\DB::commit();
} catch (\Illuminate\Database\QueryException $e) {
\DB::rollBack();
if ($e->errorInfo[1] === 1213) { // MySQL deadlock error code
// Retry once
\DB::transaction(fn() => /* same operations */);
}
throw $e;
}
// Optimistic locking — avoid row locks entirely
class Account extends \Illuminate\Database\Eloquent\Model
{
// Add 'version' column to accounts table
protected static function booted(): void
{
static::saving(function (self $model) {
if ($model->isDirty('balance')) {
$updated = static::where('id', $model->id)
->where('version', $model->version)
->increment('balance', $model->balance - $model->getOriginal('balance'), ['version' => $model->version + 1]);
if (!$updated) throw new \RuntimeException('Concurrent modification detected — retry');
}
});
}
}