Deadlocks з’являються, коли в додатку стільки трафіку, що запити перекриваються. У розробці їх майже не видно, тому перший випадок зазвичай бентежить. До того ж їх важко відтворити локально і ще важче діагностувати постфактум.
Laravel додає свою специфіку: фреймворк спростив паралельну роботу через queues, Horizon, scheduled tasks і event dispatching, тож одночасно кілька процесів можуть чіпати ті самі рядки. Локальний стенд з одним queue worker цього не покаже — продакшен із 10 Horizon workers покаже.
Цей гайд пояснює, що таке deadlock, чому він виникає в Laravel-навантаженнях і як його відладжувати та зменшувати, не перевертаючи кодову базу догори дригом.
# Table of contents
- What is a deadlock?
- Common deadlock patterns in MySQL
- What do developers see in Laravel?
- 6 Reasons why deadlocks happen in Laravel apps
- Why deadlocks feel random in production
- How to debug deadlocks in production
- How to reduce and prevent deadlocks in Laravel
- Tools that help monitor and understand deadlocks
- Where this leaves you
# What is a deadlock?
Deadlock — ситуація, коли дві транзакції утримують блокування, потрібні одна одній. Обидві чекають і не можуть просунутися, тому MySQL відриває одну транзакцію, щоб зруйнувати цикл і продовжити роботу сервера. Скасований запит отримає помилку deadlock, а інший продовжить виконання.
Простий приклад:
- Transaction A оновлює рядок 1, потім чекає на рядок 2.
- Transaction B оновлює рядок 2, потім чекає на рядок 1.
Вони зависають. Розблоковувати вручну не потрібно — MySQL автоматично завершує «дешевшу» транзакцію і логує цикл. Deadlocks — нормальне явище, не ознака руйнування бази; це побічний ефект конкуренції й того, як InnoDB забезпечує ізоляцію.
# Common deadlock patterns in MySQL
MySQL демонструє кілька повторюваних форм deadlock’ів. Кожна походить від того, як workers, queues і HTTP-запити одночасно працюють з тими самими таблицями під навантаженням.
Ось типи deadlock’ів, які зустрічаються найчастіше:
- Update-Update: дві транзакції оновлюють пов’язані рядки в протилежному порядку.
- Select-for-Update conflicts: один worker блокує рядки для обробки, інший намагається їх змінити.
- Auto-increment inserts: конкуруючі вставки конфліктують із наступними оновленнями або записами в пов’язаних таблицях.
- Gap-lock collisions: range-запити або нечіткі індекси блокують більше рядків, ніж очікувалося.
- Insert intent conflicts: кілька workers вставляють в один і той же проміжок індексу одночасно.
- Same-PK inserts: два записувачі намагаються вставити однаковий PRIMARY KEY одночасно.
- Foreign-key interactions: оновлення й видалення батьків/дочок виконуються в різному порядку.
- Long transactions: широкі блокування колізіюють з швидкими записами.
# 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. Воно підтверджує, що одну транзакцію відкотили, але не дає відповідей на питання:
- З якою транзакцією був конфлікт?
- Яка таблиця чи індекс замішані?
- Які рядки були заблоковані?
- Чому порядок блокувань відрізнився?
Більшість 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
- Використовуйте послідовні шляхи оновлення для таблиць з високим трафіком.
- Додайте точний індекс, який використовує ваш пошук (id, id,status тощо), щоб звузити блокування.
- Розгляньте SELECT ... FOR UPDATE, якщо вам потрібен ексклюзивний доступ.
# 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
- Тримайте транзакції короткими й вузькими.
- Завжди торкайтеся таблиць в одному й тому ж порядку у всьому додатку.
- Винесіть повільні операції (API виклики, важкі SELECT) поза транзакцію.
# 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
- Додайте відсутній індекс (у прикладі — на email).
- Уникайте патерну first() + update() для неіндексованих колонок.
- Використовуйте whereKey() або прямі update() виклики, коли можливо.
# 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
- Виділіть дедиковані черги для важких записів, щоб виконувати їх послідовно.
- Аудитуйте jobs на предмет умовних гілок, які змінюють порядок запитів.
- Якщо записів багато, подумайте про батчеві оновлення або єдиний «writer» сервіс.
# 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
- Додайте правильні композитні індекси під ваші пошукові патерни.
- Уникайте великих range updates; за можливості розбийте їх на пачки.
- Перегляньте повільні запити, щоб переконатися, що оптимізатор має вузький шлях.
# 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
- Вимкніть touches() для таблиць з великою частотою записів.
- Проаудіть модельні івенти на предмет додаткових запитів і винесіть важку логіку в jobs.
- Використовуйте прямі DB::table()->update(), коли потрібен мінімальний запис.
# Why deadlocks feel random in production
Розробники кажуть, що deadlock’и «виникають нізвідки». Вони здаються випадковими, бо на таймінги впливають фактори, які не видно при локальному тестуванні. Послідовність блокувань трохи змінюється з кожним запитом, тому інциденти виглядають несуміжними, хоча походять із тієї ж проблеми.
Сценарії, які створюють це «випадкове» відчуття:
- Запити приходять у злегка іншому порядку
- Queue workers завершують задачі з різною швидкістю
- Плани запитів змінюються, коли MySQL коригує оцінки вартості
- Фонові jobs накладаються на звичайні записи
- Великі читання інколи сканують більше рядків, ніж очікувалось
Ці зрушення перепорядковують блокування в MySQL. Ви рідко побачите ту ж саму послідовність двічі, але корінна причина зазвичай стабільна — просто її прояви залежать від мікротаймінгу під навантаженням.
# How to debug deadlocks in production
Laravel-ексепшн допомагає виявити deadlock, але джерело істини — MySQL deadlock report. MySQL логує повний цикл блокувань, і чим більше ви читаєте ці сліди, тим швидше знаходите причину.
# 1. Pull the deadlock details from MySQL
Використайте один із методів:
-
SHOW ENGINE INNODB STATUS — повертає останній deadlock
-
Performance Schema:
- events_transactions_history_long
- data_locks
- data_lock_waits
-
MySQL error logs, якщо логування deadlock ввімкнено
# 2. Read the lock information
Дивіться уважно на три частини:
- victim query
- conflicting query
- заблокований індекс і діапазони рядків
- порядок, у якому транзакції набували блокувань
Реальний 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’и часто виникають через запити, що блокують багато рядків. Відсутні або слабкі індекси розширюють ці діапазони. Шукайте:
- Запити, що сканують всю таблицю, щоб знайти один запис
- Оновлення, що використовують неселективний індекс
- JOIN’и, що блокують ширші діапазони, ніж очікувалося
Кращі індекси — менше торкань рядків і менше колізій.
# 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’и, потрібна видимість з обох боків конфлікту:
- Які запити виконувалися
- Які таблиці й індекси були заблоковані
- Як змінився порядок блокувань у MySQL
Багато команд покладаються на логи, APM або трекери ексепшенів, щоб помітити deadlock’и. APM-системи (наприклад, Sentry) добре показують сам ексепшн, але не зберігають конфліктну транзакцію, індекс чи діапазон рядків — інформація неповна.
Моніторингові платформи для баз додають більше контексту:
- Percona Monitoring and Management (PMM) збирає події deadlock для MySQL і показує сирий InnoDB-звіт
- MONyog витягує деталі deadlock і надсилає алерти, але більше фокусується на виявленні подій, ніж на рекомендаціях.
- Releem фіксує кожен deadlock, зберігає повну історію, класифікує типи deadlock і дає поради, як усунути базовий патерн блокувань (як на скріншоті).

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