Laravel Deadlocks: чому вони виникають і як їх усунути

1
Перекладено ШІ
Оригінал: Laravel News
Оновлено: 10 січня, 2026
Взаємні блокування (deadlocks) зазвичай виникають лише під навантаженням і майже не відтворюються локально. Дізнайтеся, як читати звіти MySQL, знаходити причину та зменшувати конфлікти в Laravel, не перевертаючи кодову базу.

Deadlocks з’являються, коли в додатку стільки трафіку, що запити перекриваються. У розробці їх майже не видно, тому перший випадок зазвичай бентежить. До того ж їх важко відтворити локально і ще важче діагностувати постфактум.

Laravel додає свою специфіку: фреймворк спростив паралельну роботу через queues, Horizon, scheduled tasks і event dispatching, тож одночасно кілька процесів можуть чіпати ті самі рядки. Локальний стенд з одним queue worker цього не покаже — продакшен із 10 Horizon workers покаже.

Цей гайд пояснює, що таке deadlock, чому він виникає в Laravel-навантаженнях і як його відладжувати та зменшувати, не перевертаючи кодову базу догори дригом.

# Table of contents

# What is a deadlock?

Deadlock — ситуація, коли дві транзакції утримують блокування, потрібні одна одній. Обидві чекають і не можуть просунутися, тому MySQL відриває одну транзакцію, щоб зруйнувати цикл і продовжити роботу сервера. Скасований запит отримає помилку deadlock, а інший продовжить виконання.

Простий приклад:

Вони зависають. Розблоковувати вручну не потрібно — MySQL автоматично завершує «дешевшу» транзакцію і логує цикл. Deadlocks — нормальне явище, не ознака руйнування бази; це побічний ефект конкуренції й того, як InnoDB забезпечує ізоляцію.

# Common deadlock patterns in MySQL

MySQL демонструє кілька повторюваних форм deadlock’ів. Кожна походить від того, як workers, queues і HTTP-запити одночасно працюють з тими самими таблицями під навантаженням.

Ось типи deadlock’ів, які зустрічаються найчастіше:

# What do developers see in Laravel?

Коли виникає deadlock, Laravel не показує цикл блокувань або конфліктний запит. Він лише передає помилку від MySQL:

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

Це повідомлення фігурує в логах, Horizon failed jobs, виводі queue worker’а, Sentry, Bugsnag або інших APM. Воно підтверджує, що одну транзакцію відкотили, але не дає відповідей на питання:

  1. З якою транзакцією був конфлікт?
  2. Яка таблиця чи індекс замішані?
  3. Які рядки були заблоковані?
  4. Чому порядок блокувань відрізнився?

Більшість deadlock’ів у Laravel походять із двох повторюваних шаблонів:

1. Запити блокують більше рядків, ніж очікували

Так буває, якщо запит сканує більшу частину таблиці — через відсутній або неселективний індекс, через те, що Eloquent виконує прихований SELECT перед UPDATE, або через особливості REPEATABLE READ, що розширюють діапазон блокувань.

2. Запити торкаються таблиць у різному порядку

Якщо два workers оновлюють ті самі моделі, але доходять до фізичних таблиць у різних послідовностях, MySQL отримує невідповідний порядок блокувань і відкидає одну транзакцію.

Наступний розділ показує, як ці патерни виникають у реальному Laravel-коді і які правки допомагають зменшити їх кількість.

# 6 Reasons why deadlocks happen in Laravel apps

Сам по собі Laravel не породжує deadlock’ів — він генерує простий SQL. Проблема починається, коли ті самі таблиці або рядки чіпають кілька workers одночасно. Невеликі зсуви в таймінгу створюють нові послідовності блокувань.

# 1. Jobs or requests update the same rows

Symptom

Якщо два workers змінюють один і той же рядок у різному порядку, може виникнути deadlock.

Example

// Worker A
Order::where('id', $id)->update(['status' => 'processing']);
 
// Worker B (same row, different path)
Order::find($id)->update(['last_checked_at' => now()]);

Cause

Таблиці типу orders, invoices, carts, inventory часто отримують перекриваючі оновлення з веб-запитів, queue workers і scheduled tasks. Eloquent робить оновлення простими, але кожне оновлення може містити приховані читання або joins, які розширюють область блокувань.

Коли два шляхи виконання оновлюють той самий запис, але заходять до нього в різному порядку, MySQL отримує невідповідність у порядку блокувань. Якщо ви також завантажуєте пов’язані моделі (->with(...)), початковий SELECT може заблокувати більше рядків, ніж ви очікуєте.

Fix

# 2. Long transactions with multiple queries

Symptom

Deadlock’и виникають всередині DB::transaction(), хоча кожен запит поодинці здається безпечним.

Example

DB::transaction(function () use ($order, $inventory) {
    $order->update(['status' => 'paid']);
    $inventory->decrement('count');  // touches a second table
});

Інший worker може виконувати ті самі два оператори, але в зворотному порядку.

Cause

DB::transaction() зручний, але групує кілька операцій під одним блокуванням. Якщо один worker оновлює orders потім inventory, а інший — навпаки, порядок блокувань відрізняється і виникає deadlock. Чим більше кроків у транзакції, тим вища ймовірність.

Fix

# 3. Hidden locking from Eloquent

Symptom

Просте оновлення моделі deadlock’ить, хоча ви оновлюєте по primary key.

Example

$user = User::where('email', $email)->first();
$user->update(['last_login_at' => now()]);

Перший запит може просканувати більше рядків, ніж ви очікували, якщо стовпець не проіндексований.

Cause

Eloquent часто виконує SELECT перед UPDATE. Якщо SELECT торкається більше рядків (через відсутній чи не селективний індекс), транзакція тримає блокування довше й на ширшому діапазоні. Range locks дають можливість для deadlock’ів.

Fix

# 4. High concurrency on a busy queue or Horizon setup

Symptom

Deadlock’и з’являються лише при масштабуванні Horizon або кількості queue workers.

Example

$product->increment('views');
LogView::create([...]);

Коли те саме job виконується паралельно, кожен worker може звертатися до products і log_views у різних послідовностях залежно від навантаження, кешування чи гілок коду.

Cause

Queue workers, що виконують однакові jobs одночасно, можуть йти різними шляхами виконання. Код той самий, але порядок запитів непередбачуваний. Висока конкуренція виявляє проблеми з порядком блокувань, яких не видно локально.

Fix

# 5. Schema shape that increases lock ranges

Symptom

Deadlock’и вказують на несподівані рядки або діапазони в графі блокувань, хоча код торкається лише однієї моделі.

Example

Post::where('category_id', $id)->update(['updated_at' => now()]);

Пошук по не селективній колонці примушує range scan. Якщо category_id не проіндексований або має малу кардинальність, MySQL може заблокувати значно ширший діапазон.

Cause

Іноді deadlock виникає не через код, а через схему: відсутні або некоректні індекси змушують оптимізатор обирати неефективний план. Під REPEATABLE READ це може означати gap locks, next-key locks або широкі діапазони блокувань, які колізіюють з іншими оновленнями — навіть з не пов’язаними.

Fix

# 6. Model events, mutators, and touches() adding hidden queries

Symptom

update() deadlock’ить, хоча рядок індексований і запит виглядає простим.

Example

class Comment extends Model
{
    protected $touches = ['post'];
}
 
$comment->update(['body' => $newBody]);

Це одне оновлення тихо триггерить ще одне оновлення на пов’язаному рядку таблиці posts.

Cause

Model events (saving, saved, updating тощо), mutators і touches() генерують додаткові запити за лаштунками. Якщо два workers оновлюють різні коментарі одного поста, ви отримаєте приховані записи, що цільовують posts.id. Ці додаткові записи розширюють область блокувань і створюють невидимі шляхи порядку блокувань.

Fix

# Why deadlocks feel random in production

Розробники кажуть, що deadlock’и «виникають нізвідки». Вони здаються випадковими, бо на таймінги впливають фактори, які не видно при локальному тестуванні. Послідовність блокувань трохи змінюється з кожним запитом, тому інциденти виглядають несуміжними, хоча походять із тієї ж проблеми.

Сценарії, які створюють це «випадкове» відчуття:

Ці зрушення перепорядковують блокування в MySQL. Ви рідко побачите ту ж саму послідовність двічі, але корінна причина зазвичай стабільна — просто її прояви залежать від мікротаймінгу під навантаженням.

# How to debug deadlocks in production

Laravel-ексепшн допомагає виявити deadlock, але джерело істини — MySQL deadlock report. MySQL логує повний цикл блокувань, і чим більше ви читаєте ці сліди, тим швидше знаходите причину.

# 1. Pull the deadlock details from MySQL

Використайте один із методів:

# 2. Read the lock information

Дивіться уважно на три частини:

Реальний deadlock report виглядає так:

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s)
MySQL thread id 98, OS thread handle 140294
query id 5542 Update order_items set quantity = 3 where id = 42
 
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 27 page no 123 n bits 72
index `PRIMARY` of table `order_items` trx id 123456 lock mode X locks rec but not gap
Record lock, heap no 8 PHYSICAL RECORD: n_fields 5; (...)
 
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
5 lock struct(s)
MySQL thread id 99, OS thread handle 140310
query id 5543 Update orders set status = 'paid' where id = 10
 
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 27 page no 123 n bits 72
index `PRIMARY` of table `order_items` trx id 123457 lock mode X
 
*** WE ROLL BACK TRANSACTION (1)

Transaction 1 була відкотена — це victim query. Transaction 2 продовжилася — це conflicting (winning) query. query id показує, які саме операції брали участь у конфлікті.

Deadlock’и часто виникають тому, що база блокує більше рядків, ніж очікувалось. Якщо ви бачите великий діапазон на таблиці, яка мала б використовувати вузький індекс — це підказка.

# 3. Check the order of operations

Порівняйте порядок запитів між транзакціями. Якщо вони торкаються рядків у різній послідовності — це, швидше за все, пряма причина.

Наприклад, якщо один код оновлює батька перед дитиною, а інший — навпаки, порядок блокувань змінюється.

# 4. Try to reproduce locally

Невеличкий скрипт із паралельними workers часто виявляє неконсистентний порядок блокувань. Навіть якщо deadlock не відтворюється, відмінності в порядку запитів стануть видимими.

Якщо хочете глибше розібратися в тому, як MySQL фіксує deadlock’и і як інтерпретувати ці звіти — подивіться окремий гайд на MySQL deadlock detection.

# How to reduce and prevent deadlocks in Laravel

Повністю позбутися deadlockʼів неможливо, але можна зменшити їхню частоту і скоротити час на діагностику. Більшість рішень звужують транзакції і роблять порядок блокувань передбачуваним.

# 1. Keep transactions short

Обгорніть тільки ті запити, які справді мають виконуватись разом. Кожен зайвий запит розширює область блокувань.

DB::transaction(function () use ($id) {
    Order::where('id', $id)->update(['confirmed' => true]);
});

Уникайте завантаження звʼязків або важких SELECT всередині транзакції, коли це можливо.

# 2. Update rows in a consistent order

Якщо різні частини додатку модифікують повʼязані рядки, стандартизуйте порядок. Наприклад, завжди оновлюйте батька перед дитиною або завжди оновлюйте по primary key у зростаючому порядку. Коли порядок блокувань стабільний, кількість deadlock’ів різко падає.

# 3. Use row-level locks where needed

Query builder Laravel підтримує блокування на рівні рядка через lockForUpdate():

$item = Inventory::where('sku', $sku)->lockForUpdate()->first();
$item->decrement('quantity');

Важливо: це вимагає унікального індексу на 'sku', щоб заблокувати один рядок. Без нього MySQL може заблокувати діапазон рядків і підвищити ризик deadlock.

Це корисно, коли паралельно змінюють один і той же inventory, balance або counter — блокування звузиться до точного рядка замість великого діапазону.

# 4. Add the right indexes

Deadlock’и часто виникають через запити, що блокують багато рядків. Відсутні або слабкі індекси розширюють ці діапазони. Шукайте:

Кращі індекси — менше торкань рядків і менше колізій.

# 5. Avoid unnecessary reads inside writes

Якщо запис потребує читання, спробуйте винести читання поза транзакцію або покладатися на відомі primary keys замість сканування.

# 6. Implement retry logic for deadlock-prone operations

Deadlock — транзієнтна помилка: відкотена транзакція зазвичай вдається при повторній спробі. Laravel підтримує автоматичні спроби в транзакціях:

// Laravel 8+
DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);
    $order->inventory()->decrement('count');
}, attempts: 3);
 
For more control, use manual retry logic:
 
use Illuminate\Database\QueryException;
 
retry(3, function () {
    DB::transaction(function () {
        // your logic here
    });
}, sleepMilliseconds: 100, when: function ($exception) {
    return $exception instanceof QueryException
        & str_contains($exception->getMessage(), 'Deadlock found');
});

Цей підхід особливо корисний для queue jobs, що працюють із висококонкурентними рядками, як-от inventory counts або user balances.

# Tools that help monitor and understand deadlocks

Щоб правильно відладжувати deadlock’и, потрібна видимість з обох боків конфлікту:

Багато команд покладаються на логи, APM або трекери ексепшенів, щоб помітити deadlock’и. APM-системи (наприклад, Sentry) добре показують сам ексепшн, але не зберігають конфліктну транзакцію, індекс чи діапазон рядків — інформація неповна.

Моніторингові платформи для баз додають більше контексту:

# Where this leaves you

Перший deadlock у Laravel часто дивує. Він зазвичай з’являється при масштабуванні: Horizon workers, queued jobs і scheduled tasks одночасно чіпають ті самі рядки. Поки ви не навчитесь читати MySQL deadlock report, це виглядає непередбачувано — навіть коли корінна причина стабільна.

Щоб запобігати патернам, потрібна постійна видимість у те, як транзакції накладаються і які запити створюють тиск.

Деякі команди використовують інструменти на кшталт Releem, щоб тримати цю видимість постійною і виявляти патерни deadlock раніше, ніж вони стануть повторюваними інцидентами.

Популярні

Logomark Logotype

Створення CLI-додатка за допомогою Laravel та Docker

Зазирніть у світ Laravel, де потужний CLI-фреймворк відкриває нові можливості для розробки командного інтерфейсу. Дізнайтеся, як створити просту утиліту для перевірки акцій, яка працює з Docker, та які переваги це може принести у вашому проєкті!

Logomark Logotype

Як задокументувати кілька API в Laravel за допомогою Scramble

Ви знали, що в одному додатку Laravel можна реалізувати кілька API? У нашій статті ви дізнаєтеся, як за допомогою Scramble легко документувати різні версії API та налаштувати доступ до документації, щоб зробити її публічною або приватною. Читайте далі, щоб дізнатися більше

Logomark Logotype

"SQLSTATE[HY000] [2002] Connection refused" у Laravel в GitHub Actions

Чи стикалися ви з помилкою «SQLSTATE[HY000] [2002] Connection refused» під час налаштування GitHub Actions для вашого додатку на Laravel? У нашій статті ми розглянемо три поширені причини цієї помилки та надамо рішення для їх усунення. Читайте далі, щоб дізнатися, як ваш CI/CD потік може працювати бездоганно!