Deadlocks з’являються, коли в додатку стільки трафіку, що запити перекриваються. У розробці їх майже не видно, тому перший випадок зазвичай бентежить. До того ж їх важко відтворити локально і ще важче діагностувати постфактум.
Laravel додає свою специфіку: фреймворк спростив паралельну роботу через queues, Horizon, scheduled tasks і event dispatching, тож одночасно кілька процесів можуть чіпати ті самі рядки. Локальний стенд з одним queue worker цього не покаже — продакшен із 10 Horizon workers покаже.
Цей гайд пояснює, що таке deadlock, чому він виникає в Laravel-навантаженнях і як його відладжувати та зменшувати, не перевертаючи кодову базу догори дригом.
Deadlock — ситуація, коли дві транзакції утримують блокування, потрібні одна одній. Обидві чекають і не можуть просунутися, тому MySQL відриває одну транзакцію, щоб зруйнувати цикл і продовжити роботу сервера. Скасований запит отримає помилку deadlock, а інший продовжить виконання.
Простий приклад:
Вони зависають. Розблоковувати вручну не потрібно — MySQL автоматично завершує «дешевшу» транзакцію і логує цикл. Deadlocks — нормальне явище, не ознака руйнування бази; це побічний ефект конкуренції й того, як InnoDB забезпечує ізоляцію.
MySQL демонструє кілька повторюваних форм deadlock’ів. Кожна походить від того, як workers, queues і HTTP-запити одночасно працюють з тими самими таблицями під навантаженням.
Ось типи deadlock’ів, які зустрічаються найчастіше:
Коли виникає deadlock, Laravel не показує цикл блокувань або конфліктний запит. Він лише передає помилку від MySQL:
SQLSTATE[40001]: Deadlock found when trying to get lock; try restarting transaction
Це повідомлення фігурує в логах, Horizon failed jobs, виводі queue worker’а, Sentry, Bugsnag або інших APM. Воно підтверджує, що одну транзакцію відкотили, але не дає відповідей на питання:
Більшість deadlock’ів у Laravel походять із двох повторюваних шаблонів:
1. Запити блокують більше рядків, ніж очікували
Так буває, якщо запит сканує більшу частину таблиці — через відсутній або неселективний індекс, через те, що Eloquent виконує прихований SELECT перед UPDATE, або через особливості REPEATABLE READ, що розширюють діапазон блокувань.
2. Запити торкаються таблиць у різному порядку
Якщо два workers оновлюють ті самі моделі, але доходять до фізичних таблиць у різних послідовностях, MySQL отримує невідповідний порядок блокувань і відкидає одну транзакцію.
Наступний розділ показує, як ці патерни виникають у реальному Laravel-коді і які правки допомагають зменшити їх кількість.
Сам по собі Laravel не породжує deadlock’ів — він генерує простий SQL. Проблема починається, коли ті самі таблиці або рядки чіпають кілька workers одночасно. Невеликі зсуви в таймінгу створюють нові послідовності блокувань.
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
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
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
Symptom
Deadlock’и з’являються лише при масштабуванні Horizon або кількості queue workers.
Example
$product->increment('views');
LogView::create([...]);
Коли те саме job виконується паралельно, кожен worker може звертатися до products і log_views у різних послідовностях залежно від навантаження, кешування чи гілок коду.
Cause
Queue workers, що виконують однакові jobs одночасно, можуть йти різними шляхами виконання. Код той самий, але порядок запитів непередбачуваний. Висока конкуренція виявляє проблеми з порядком блокувань, яких не видно локально.
Fix
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
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
Розробники кажуть, що deadlock’и «виникають нізвідки». Вони здаються випадковими, бо на таймінги впливають фактори, які не видно при локальному тестуванні. Послідовність блокувань трохи змінюється з кожним запитом, тому інциденти виглядають несуміжними, хоча походять із тієї ж проблеми.
Сценарії, які створюють це «випадкове» відчуття:
Ці зрушення перепорядковують блокування в MySQL. Ви рідко побачите ту ж саму послідовність двічі, але корінна причина зазвичай стабільна — просто її прояви залежать від мікротаймінгу під навантаженням.
Laravel-ексепшн допомагає виявити deadlock, але джерело істини — MySQL deadlock report. MySQL логує повний цикл блокувань, і чим більше ви читаєте ці сліди, тим швидше знаходите причину.
Використайте один із методів:
SHOW ENGINE INNODB STATUS — повертає останній deadlock
Performance Schema:
MySQL error logs, якщо логування deadlock ввімкнено
Дивіться уважно на три частини:
Реальний 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’и часто виникають тому, що база блокує більше рядків, ніж очікувалось. Якщо ви бачите великий діапазон на таблиці, яка мала б використовувати вузький індекс — це підказка.
Порівняйте порядок запитів між транзакціями. Якщо вони торкаються рядків у різній послідовності — це, швидше за все, пряма причина.
Наприклад, якщо один код оновлює батька перед дитиною, а інший — навпаки, порядок блокувань змінюється.
Невеличкий скрипт із паралельними workers часто виявляє неконсистентний порядок блокувань. Навіть якщо deadlock не відтворюється, відмінності в порядку запитів стануть видимими.
Якщо хочете глибше розібратися в тому, як MySQL фіксує deadlock’и і як інтерпретувати ці звіти — подивіться окремий гайд на MySQL deadlock detection.
Повністю позбутися deadlockʼів неможливо, але можна зменшити їхню частоту і скоротити час на діагностику. Більшість рішень звужують транзакції і роблять порядок блокувань передбачуваним.
Обгорніть тільки ті запити, які справді мають виконуватись разом. Кожен зайвий запит розширює область блокувань.
DB::transaction(function () use ($id) {
Order::where('id', $id)->update(['confirmed' => true]);
});
Уникайте завантаження звʼязків або важких SELECT всередині транзакції, коли це можливо.
Якщо різні частини додатку модифікують повʼязані рядки, стандартизуйте порядок. Наприклад, завжди оновлюйте батька перед дитиною або завжди оновлюйте по primary key у зростаючому порядку. Коли порядок блокувань стабільний, кількість deadlock’ів різко падає.
Query builder Laravel підтримує блокування на рівні рядка через lockForUpdate():
$item = Inventory::where('sku', $sku)->lockForUpdate()->first();
$item->decrement('quantity');
Важливо: це вимагає унікального індексу на 'sku', щоб заблокувати один рядок. Без нього MySQL може заблокувати діапазон рядків і підвищити ризик deadlock.
Це корисно, коли паралельно змінюють один і той же inventory, balance або counter — блокування звузиться до точного рядка замість великого діапазону.
Deadlock’и часто виникають через запити, що блокують багато рядків. Відсутні або слабкі індекси розширюють ці діапазони. Шукайте:
Кращі індекси — менше торкань рядків і менше колізій.
Якщо запис потребує читання, спробуйте винести читання поза транзакцію або покладатися на відомі primary keys замість сканування.
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.
Щоб правильно відладжувати deadlock’и, потрібна видимість з обох боків конфлікту:
Багато команд покладаються на логи, APM або трекери ексепшенів, щоб помітити deadlock’и. APM-системи (наприклад, Sentry) добре показують сам ексепшн, але не зберігають конфліктну транзакцію, індекс чи діапазон рядків — інформація неповна.
Моніторингові платформи для баз додають більше контексту:

Перший deadlock у Laravel часто дивує. Він зазвичай з’являється при масштабуванні: Horizon workers, queued jobs і scheduled tasks одночасно чіпають ті самі рядки. Поки ви не навчитесь читати MySQL deadlock report, це виглядає непередбачувано — навіть коли корінна причина стабільна.
Щоб запобігати патернам, потрібна постійна видимість у те, як транзакції накладаються і які запити створюють тиск.
Деякі команди використовують інструменти на кшталт Releem, щоб тримати цю видимість постійною і виявляти патерни deadlock раніше, ніж вони стануть повторюваними інцидентами.
Зазирніть у світ Laravel, де потужний CLI-фреймворк відкриває нові можливості для розробки командного інтерфейсу. Дізнайтеся, як створити просту утиліту для перевірки акцій, яка працює з Docker, та які переваги це може принести у вашому проєкті!
Ви знали, що в одному додатку Laravel можна реалізувати кілька API? У нашій статті ви дізнаєтеся, як за допомогою Scramble легко документувати різні версії API та налаштувати доступ до документації, щоб зробити її публічною або приватною. Читайте далі, щоб дізнатися більше
Чи стикалися ви з помилкою «SQLSTATE[HY000] [2002] Connection refused» під час налаштування GitHub Actions для вашого додатку на Laravel? У нашій статті ми розглянемо три поширені причини цієї помилки та надамо рішення для їх усунення. Читайте далі, щоб дізнатися, як ваш CI/CD потік може працювати бездоганно!