Laravel MySQL Deadlock Nightmare: How a Single Misconfigured Join Slowed My VPS by 10× and Caused 48‑Hour Queue Back‑logs
If you’ve ever stared at a PHP‑FPM log screaming “lock wait timeout exceeded” while your Laravel queues crawl at a snail’s pace, you know the gut‑wrenching feeling of watching a production app grind to a halt. This is the story of a single JOIN that turned a healthy Ubuntu 20.04 VPS into a 48‑hour bottleneck, and exactly how I untangled the mess with Laravel‑first fixes, MySQL tuning, and a few VPS tricks.
LEFT JOIN caused a full‑table lock, multiplied by a queue:work pool of 30 workers, leading to a 10× slowdown. The fix? Add the index, adjust innodb_lock_wait_timeout, and let Redis handle job deduplication. Cheap secure hosting on Hostinger – click for a special deal!
Why This Matters
In a SaaS environment every millisecond of API latency equals lost revenue. A deadlocked MySQL query not only hurts response time; it stalls background jobs, breaks email campaigns, and can leave customers staring at “Processing…” for days. When a queue backs up for 48 hours, churn spikes and support tickets explode.
Common Causes of Laravel‑MySQL Deadlocks
- Missing or redundant indexes on joined tables.
- Long‑running transactions that hold locks while other queries need them.
- Inconsistent isolation levels (e.g.,
READ COMMITTEDvsREPEATABLE READ). - Queue workers spawning more connections than the DB can handle.
- Improper use of
SELECT ... FOR UPDATEinside Eloquent scopes.
Step‑by‑Step Fix Tutorial
1. Identify the offending query
Run the MySQL slow‑query log and look for queries lasting > 1 second. In our case:
SELECT users.id, orders.id, payments.amount
FROM users
LEFT JOIN orders ON orders.user_id = users.id
LEFT JOIN payments ON payments.order_id = orders.id
WHERE users.status = 'active'
AND payments.created_at > NOW() - INTERVAL 30 DAY;
2. Explain the plan
Use EXPLAIN ANALYZE to see missing indexes:
EXPLAIN ANALYZE SELECT ...;
The output shows a full table scan on orders because orders.user_id lacked an index.
3. Add the missing index
Run the migration:
php artisan make:migration add_user_id_index_to_orders --table=orders
public function up()
{
Schema::table('orders', function (Blueprint $table) {
$table->index('user_id');
});
}
4. Tweak InnoDB lock settings
Adding an index reduces lock time, but you should also raise the timeout to avoid spurious errors during peak bursts.
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
innodb_lock_wait_timeout = 50
innodb_rollback_on_timeout = ON
Restart MySQL:
sudo systemctl restart mysql
5. Offload queue deduplication to Redis
Configure Laravel Horizon (or Supervisor) to use Redis for job uniqueness. In .env:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
// In a job class
public function uniqueId()
{
return $this->order_id;
}
VPS or Shared Hosting Optimization Tips
queue:work concurrency to match the DB connection limit (often 10‑15). Use php artisan queue:restart after every deployment.
- CPU Pinning: Bind PHP‑FPM workers to specific cores in
/etc/php/8.2/fpm/pool.d/www.conf(pm.max_childrentuned to 70 % of available RAM). - OPcache: Enable
opcache.enable=1and setopcache.memory_consumption=256for Laravel’s Blade and Composer autoload files. - Swap Management: Disable swap on a VPS to force the kernel to kill runaway processes instead of silently degrading performance.
Real World Production Example
Our SaaS runs on a 2 vCPU, 4 GB RAM Ubuntu 22.04 VPS with Nginx, PHP‑FPM 8.2, and Redis 7.0. The problematic endpoint was /api/v1/report, which pulled user activity across three tables. After adding the index and adjusting the lock timeout, the endpoint went from 2.8 s → 0.3 s and queue latency dropped from 48 hours to under 2 minutes.
Before vs After Results
| Metric | Before | After |
|---|---|---|
| Avg API response | 2.8 s | 0.3 s |
| Queue backlog | 48 hrs | 2 min |
| DB CPU usage | 95 % | 45 % |
Security Considerations
While chasing performance, don’t open the door for SQL injection or data leakage.
- Enable
sql_mode=ONLY_FULL_GROUP_BYto force explicit grouping. - Restrict DB user privileges to
SELECT, INSERT, UPDATEonly where needed. - Run
php artisan config:cacheandphp artisan route:cachein production to eliminate runtime config leaks.
Bonus Performance Tips
covering index on (user_id, created_at) shaved another 0.07 s off the query.
- Use
SELECT id FROM ... LIMIT 1for existence checks instead of COUNT(*). - Enable
DB::listenin a service provider to log queries that exceed 500 ms. - Move heavy analytics to a read‑replica and point Laravel’s
readconnection to it. - Consider
MySQL 8.0’s invisible indexesfor experimental tuning without affecting production. - Switch to
php artisan horizonfor real‑time queue monitoring and auto‑scaling.
FAQ
Q: My VPS has only 1 GB RAM. Will these changes still help?
A: Absolutely. Indexes reduce CPU cycles, and loweringpm.max_childrenprevents OOM kills. Pair with Redis on a separate small instance for best results.
Q: Can I apply these fixes on a shared WordPress host?
A: Limited MySQL tuning is possible via phpMyAdmin, but you’ll need to ask your host to add the index. Otherwise, use a dedicated MySQL‑compatible plugin or move to a VPS.
Final Thoughts
A single mis‑configured JOIN turned a healthy Laravel‑MySQL stack into a 48‑hour nightmare. The cure was classic: add the missing index, adjust lock timeouts, and let Redis handle idempotent jobs. In production every millisecond counts, and a disciplined approach to query analysis, server tuning, and queue management pays off in happier users and lower cloud bills.
No comments:
Post a Comment