Mastering MySQL Deadlocks in PHP: Essential Techniques for Detection and Prevention That Every Developer Needs

Hire a PHP developer for your project — click here.

by admin
detect_prevent_mysql_deadlocks_php

How Deadlocks Sneak Into Your PHP + MySQL Code

Friends, let’s start with a picture you probably know too well.

It’s late. The office is quiet. You’ve got a half‑finished energy drink on the table, some background playlist humming in your headphones. You run your test suite before pushing a feature and… everything passes.

Then tomorrow morning, production starts throwing:

Deadlock found when trying to get lock; try restarting transaction

And suddenly, some perfectly normal MySQL transaction is failing in ways that are hard to reproduce, hard to explain, and very easy to dismiss as “random”.

Deadlocks feel random.

They aren’t.

Underneath that one depressing MySQL error is a very simple idea: two or more transactions are waiting on each other in a loop, and nobody can move forward.

Let’s get concrete.

You’ve got two PHP requests:

  • Request A:
    • Updates orders
    • Then updates payments
  • Request B:
    • Updates payments
    • Then updates orders

Each transaction is holding a lock the other one wants. MySQL looks at this circle and says: nope. One transaction dies so the other can live.

From the outside, this shows up as occasional, unpredictable deadlock errors under load.

From the inside, it’s completely deterministic: inconsistent lock ordering, long‑running transactions, and hot rows clashing with each other.

That’s the good news: deadlocks are understandable, and therefore solvable.

But you have to stop treating them like ghosts in the system.

In this article, I want to walk you through how to detect deadlocks in a PHP / MySQL stack, how to read what the database is trying to tell you, and then how to design your PHP code so those errors turn from production fire drills into rare, well‑handled edge cases.

This isn’t about “perfect” systems.

It’s about honest systems that expect things to go wrong – and are ready for it.


Why Deadlocks Are Not A Bug, But A Signal

First thing: MySQL deadlocks are not a database failure. They are the engine saying “I resolved a conflict the only safe way I could, now you need to react”.

MySQL’s InnoDB engine:

  • Detects circular waits between transactions
  • Chooses one “victim” transaction to roll back
  • Lets the other transaction continue

From your PHP code’s point of view, this is a normal, documented behavior. It’s not that the database broke. It’s that your application logic created a situation where two transactions couldn’t both succeed at the same time.

So when you see:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction

MySQL is giving you a hint in plain language:

“Try restarting the transaction.”

The implication for PHP developers is huge:

  • Deadlocks must be handled in application code, not ignored
  • Your code needs to be ready to retry safely
  • Your transaction logic needs to be designed to minimize the chance of deadlocks happening in the first place

Let’s break it down in two big questions:

  1. How do I detect and understand deadlocks in MySQL?
  2. How do I prevent and handle them in PHP?

We’ll go through both.


How MySQL Tells You About Deadlocks

When a deadlock happens, MySQL doesn’t just throw an error and walk away. It keeps a story about what happened.

You have three main tools to read that story.

1. SHOW ENGINE INNODB STATUS

The classic one.

Run this in MySQL:

SHOW ENGINE INNODB STATUS;

You get a giant text dump describing:

  • The last deadlock that occurred
  • Which transactions were involved
  • Which queries they were running
  • Which locks each one held and requested

It’s verbose. It’s messy. But buried in that output is the answer to questions like:

  • Which tables were involved?
  • Which index did MySQL use?
  • Which rows and ranges were locked?

When you’re debugging deadlocks in a PHP app, this command is your “crime scene photo”. You want to read it closely.

2. innodb_print_all_deadlocks

The “remember every mistake” option.

By default, MySQL only keeps information about the most recent deadlock. That’s fine until you start seeing them frequently under load and you need a history, not a snapshot.

You can configure MySQL to log all deadlocks to the error log:

SET GLOBAL innodb_print_all_deadlocks = 1;

Or via my.cnf:

innodb_print_all_deadlocks = 1

After that, every deadlock will be recorded in the MySQL error log, not just the last one.

This is useful when:

  • You’re on a busy production system
  • Deadlocks don’t happen every minute, but enough to matter
  • You want to correlate deadlocks with application events or deploys

You don’t leave this turned on forever, but it’s perfect when you’re investigating.

3. INFORMATION_SCHEMA.INNODB_TRX

Deadlocks are the visible symptom.

Long‑running or stuck transactions are often the underlying disease.

MySQL gives you runtime visibility into active transactions:

SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX\G

Here you’ll find:

  • Each active InnoDB transaction
  • How long it’s been running
  • Whether it’s waiting for a lock
  • The query it’s currently executing

This helps answer:

  • Which requests are holding locks for too long?
  • Which code path is causing contention?

It’s also the tool you use when the system feels stuck but you don’t see actual deadlocks – a lot of “lock waits” with eventual timeouts can be just as painful as hard deadlocks.

In other words, use SHOW ENGINE INNODB STATUS to understand what deadlocked, and INNODB_TRX to understand how your system behaves under load.


Common Causes Of Deadlocks In PHP / MySQL Apps

Now that we know how to see deadlocks, we need to know why they keep happening.

The database folks will list a lot of reasons. Let’s translate them into PHP developer reality.

1. Inconsistent Lock Ordering

This is the big one.

If different parts of your code lock tables or rows in different orders, sooner or later, two transactions will lock them in opposite sequences and create a circular wait.[13]

Examples:

  • Code path A:
    • Select and update orders
    • Then select and update payments
  • Code path B:
    • Select and update payments
    • Then select and update orders

Or:

  • Bulk update script loops over IDs in descending order
  • API endpoint updates same rows in ascending order

Result: perfect recipe for deadlocks.

The fix is conceptually simple:

Always acquire locks in a consistent global order across your entire codebase.

That might mean:

  • Always update users before orders
  • Always update orders before payments
  • Always sort IDs before updating multiple rows

This sounds boring.

It’s not.

It’s the foundation of predictable concurrency.

2. Long‑Running Transactions

Every time you open a transaction and start doing work, MySQL is holding locks on rows you touch.

The longer you stay inside that transaction:

  • The more time other transactions have to collide with you
  • The higher the chance of deadlocks under load

Things that quietly stretch transactions:

  • Doing HTTP calls inside a transaction
  • Writing files
  • Sending emails
  • Running heavy business logic loops between queries

The database doesn’t care why you’re slow.

It only sees: “This transaction is holding locks for a long time.”

Deadlocks are far more common when transactions become long‑running chunks of logic instead of compact, focused operations.

3. Missing Or Bad Indexes

This one feels very “database admin”, but it hits PHP developers directly.

If you have queries like:

SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;

…and status is not indexed, MySQL might scan a lot of rows, grabbing locks all along the way.

Missing indexes cause:

  • Larger locked ranges
  • More rows locked
  • Longer lock duration

Deadlocks love wide, slow scans.

The fix is simple:

  • Add proper indexes on frequently filtered or updated columns
  • Use EXPLAIN SELECT to see how MySQL uses them

4. Hot Rows And Contended Tables

Your app has “hot spots”:

  • The single row that stores global counters
  • The “latest config” row
  • The main accounts table where everyone reads and writes the same few rows

Those places become lock contention magnets.[13]

If critical business logic keeps hammering the same rows, deadlocks become far more likely.

You can often reduce this by:

  • Spreading the data (partitioning, sharding, separate tables)[13]
  • Moving some operations into a queue and processing them sequentially[13]

But first you have to admit: “We are all fighting over the same two rows.”


Designing PHP Code That Expects Deadlocks

Now we get to the part you probably care about most as a PHP developer.

You don’t own MySQL’s internals.

You do own how your code talks to it.

1. Accept Deadlocks As Normal, Not As Catastrophe

MySQL’s own documentation says it plainly: deadlocks are a natural part of transactional systems; just retry.[14]

So in PHP, your rule of thumb should be:

Any transaction that can safely be retried should have built‑in retry logic for deadlock errors.

This changes your mindset:

  • You stop trying to “eliminate deadlocks forever”
  • You start designing for resilience instead of perfection
See also
Unlock PHP Performance: Master the Essential Basics for a Smooth User Experience

Deadlocks become another recoverable error, like a temporary network glitch.

2. Implement Transaction Retry Logic

Let’s say you have a function that runs some logic in a transaction:

Using PDO:

function runWithTransaction(PDO $pdo, callable $callback, int $maxRetries = 3)
{
    $attempt = 0;

    while ($attempt < $maxRetries) {
        $attempt++;

        try {
            $pdo->beginTransaction();

            $callback($pdo);

            $pdo->commit();
            return;
        } catch (PDOException $e) {
            $pdo->rollBack();

            // MySQL deadlock error code is usually 1213 or SQLSTATE 40001
            $message = $e->getMessage();
            if (!str_contains($message, 'Deadlock found') &&
                !str_contains($message, '40001')) {
                // Not a deadlock – rethrow
                throw $e;
            }

            // Deadlock: retry unless we exhausted attempts
            if ($attempt >= $maxRetries) {
                throw $e;
            }

            // Optional: sleep with backoff to avoid hammering
            usleep(100000 * $attempt); // 0.1s, 0.2s, 0.3s...
        }
    }
}

Key ideas from this pattern:

  • Wrap all transaction work inside a single callable so it can be re‑run cleanly
  • Catch deadlock errors and retry up to N attempts
  • Use exponential backoff or at least a small delay between retries
  • Don’t run infinite loops – if deadlocks keep happening, you have a real design problem

Those same ideas appear in frameworks:

  • Laravel supports retries on DB::transaction(function () { ... }, $attempts)
  • Your own data access layer can apply similar logic consistently

PHP >= 5.3 closures make this kind of transactional callback pattern pleasant to express.

3. Keep Transactions Short And Focused

From the PHP side, “short transactions” mean:

  • All database work inside the transaction happens close together
  • No external I/O inside the transaction (HTTP, file system, complex API calls)
  • Business logic inside the transaction is kept to what strictly needs ACID guarantees

A good pattern:

  • Prepare data and perform validations before opening the transaction
  • Open transaction
  • Execute a small number of well‑understood queries
  • Commit as soon as possible

Bad pattern:

  • Open transaction
  • Call other services
  • Loop over huge arrays doing mixed work
  • Log things
  • Finally commit

The second style feels natural in big PHP apps with layered architectures. But it’s a magnet for deadlocks and timeouts.

When in doubt, shrink the transaction borders.


Enforcing Consistent Lock Ordering In Code

We talked theory. Let’s go practical.

Imagine you have two tables: orders and payments.

Different code paths touch them.

To avoid deadlocks, you define a rule:

Inside any transaction: always lock / update orders before payments.

That rule must be enforced at two levels.

1. In Query Order

In raw SQL or PDO:

$pdo->beginTransaction();

// Always lock orders first
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ? FOR UPDATE');
$stmt->execute([$orderId]);
$order = $stmt->fetch();

// Then lock payments
$stmt = $pdo->prepare('SELECT * FROM payments WHERE order_id = ? FOR UPDATE');
$stmt->execute([$orderId]);
$payment = $stmt->fetch();

// Business logic…

$pdo->commit();

Same rule, different context:

  • A background job
  • A web request
  • A CLI maintenance script

All of them must follow the same lock order.

2. In ID Ordering

When updating multiple rows, sort keys before updating.

Instead of:

foreach ($ids as $id) {
    // order is whatever the client sent
    $pdo->prepare('UPDATE items SET status = ? WHERE id = ?')
        ->execute([$status, $id]);
}

Do:

sort($ids);

foreach ($ids as $id) {
    $pdo->prepare('UPDATE items SET status = ? WHERE id = ?')
        ->execute([$status, $id]);
}

Sorting keys ensures that if two transactions update the same set of rows, they do it in the same order.

Again: boring. Powerful.


Indexes, Isolation Levels, And “Less Locking”

When you start reading SHOW ENGINE INNODB STATUS outputs, you’ll see terms like “gap lock”, “next‑key lock”, and long ranges covering many rows.

Some of that behavior is controlled by isolation levels and indexes.

Use Proper Indexes

For queries like:

SELECT * FROM orders
WHERE user_id = ? AND status = 'pending'
FOR UPDATE;

You want an index on (user_id, status) so MySQL only locks the relevant rows, not half the table.

Guidelines:

  • Index columns used in joins, WHERE, UPDATE, DELETE, and SELECT ... FOR UPDATE
  • Use EXPLAIN SELECT to see which index MySQL uses and whether it’s scanning too much

Better indexes mean:

  • Fewer rows locked
  • Shorter lock duration
  • Fewer deadlocks

Consider Isolation Level For Locking Reads

MySQL’s default isolation level (REPEATABLE READ) creates more gap locks than READ COMMITTED.[14]

For some workloads, switching to READ COMMITTED:

  • Reduces lock contention
  • Lowers deadlock frequency[14]

You can change it per connection or per session, for example in PHP:

$pdo->exec("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");

Just don’t do this blindly.

You need to understand what isolation guarantees your application truly needs. But if you find deadlocks around SELECT ... FOR UPDATE, READ COMMITTED is worth considering.[14]

Use Less Locking When You Can

Every SELECT ... FOR UPDATE is a conscious choice to grab locks.

Sometimes you don’t need that.

MySQL’s own docs recommend:

  • Avoid locking reads if you can safely read slightly stale data
  • Use LOCK IN SHARE MODE / FOR SHARE instead of full write locks when only read consistency is needed

Less locking, when it doesn’t break business rules, leads to fewer deadlocks.


Background Jobs, Queues, And Serializing Critical Sections

Sometimes you stare at deadlock logs for hours and realize something uncomfortable:

“Under our traffic patterns, some operations will collide no matter what.”

Two common cases:

  • High‑volume writes to the same table or rows
  • Bulk updates or migrations running while normal traffic hits the same data[13]

In those situations, smart developers reach for another tool: serialization.

1. Use Queues For Heavy Writes

Instead of letting every PHP request perform complex write operations immediately, you can:

  • Write “events” or “jobs” into a queue
  • Run a single worker (or a small, controlled set of workers) that applies those changes to the database sequentially[13]

This is essentially event sourcing lite:

  • High‑traffic endpoints append events
  • A dedicated process updates rows in order, without competing writers[13]

Result:

  • Fewer concurrent writers
  • Fewer conflicting transactions
  • Fewer deadlocks, especially on hot data

This isn’t magic, and it adds architectural complexity. But for some systems, it’s the cleanest way to avoid constant lock fights.

2. Table‑Level Locks As A Last Resort

MySQL allows you to manually lock entire tables:

SET autocommit = 0;
LOCK TABLES orders WRITE, payments WRITE;

-- Critical operations...

COMMIT;
UNLOCK TABLES;

When you do this, you’re essentially saying:

“For this short window, nobody else touches these tables.”

MySQL docs are clear: this is last resort territory.

It:

  • Guarantees exclusive access
  • Completely blocks other sessions touching those tables
  • Must be tightly scoped and used for low‑volume, critical sections

In a PHP app, this might show up as a maintenance script or a very rare admin operation, not general request logic.

3. Auxiliary “Semaphore” Tables

Another clever trick from the MySQL manual:

Create a single‑row “semaphore” table:

CREATE TABLE critical_section_lock (
    id INT PRIMARY KEY,
    token INT NOT NULL
);
INSERT INTO critical_section_lock (id, token) VALUES (1, 0);

Then in your PHP transaction:

$pdo->beginTransaction();

// Acquire semaphore
$pdo->exec("UPDATE critical_section_lock SET token = token + 1 WHERE id = 1");

// Now touch other tables
// ...

$pdo->commit();

This forces all transactions that need this critical section to line up behind a single locked row, effectively serializing that part of your logic.

It’s not glamorous. It is extremely effective when used judiciously.


Testing For Deadlocks Before Production Teaches You

One of the cruel things about deadlocks is that they hide during local development and small test data.

They come alive:

  • Under concurrency
  • Under load
  • Under messy, real inputs

So if you care about stability (and if you’re reading this, you probably do), you need to deliberately test for them.

Ideas:

  • Run load tests that hit transactional endpoints with high concurrency
  • Spin up multiple workers that perform conflicting operations on shared data
  • Watch SHOW ENGINE INNODB STATUS and INNODB_TRX while tests run

The goal is not to create a lab where deadlocks never occur.

The goal is to see:

  • How often they appear
  • How your code responds
  • Whether retries and timeouts behave as expected

When a deadlock happens in test, don’t just shrug.

Read the deadlock report.

Ask the uncomfortable questions:

  • Why did these two transactions touch the same rows at the same time?
  • Could we reorder, reindex, or simplify the transaction?
  • Are we doing too much work inside that transaction?

The answers shape the architecture, not just the code.


The Quiet Discipline Behind Stable PHP / MySQL Systems

If you’ve made it this far, I suspect you’ve experienced at least one production incident where deadlocks were involved.

Maybe it was a checkout process that occasionally failed under peak traffic.

Maybe it was a recurring job that crashed randomly every few hours.

Deadlocks are rarely the headline bug. They are the side effect of how we think about concurrency.

When you work with PHP and MySQL long enough, you start to see a pattern in teams that build stable systems:

  • They take database errors seriously, not personally
  • They read the tools MySQL gives them – SHOW ENGINE INNODB STATUS, INNODB_TRX, error logs
  • They enforce boring but powerful rules:
    • consistent lock ordering
    • short transactions
    • good indexes
  • They treat deadlocks as a design feedback loop, not a mysterious curse

And at some point, you notice your own mindset shift.

Instead of thinking “How do I avoid ever seeing this error again?”, you start thinking:

“How do I make this code honest about the fact that reality is concurrent, partial, and sometimes conflicting?”

Deadlocks become part of that reality.

They remind you that your PHP code isn’t alone. It’s negotiating with other requests, other processes, other humans clicking at the same time.

There’s a kind of quiet satisfaction the first time you see a deadlock log, trace it, fix the lock ordering, tighten a transaction, add an index, and watch the system breathe easier under load.

You didn’t just squish a bug.

You made the whole thing a bit more graceful under pressure.

And in the glow of that monitor, coffee going cold, you realize that this is the craft you signed up for: not chasing perfection, but learning, slowly, how to make complex things behave with a little more kindness when the world pushes on them.
перейти в рейтинг

Related offers