Tuesday, May 12, 2026

Laravel Queue Workers Stuck on VPS: Why PHP‑FPM MySQL Deadlock Is Killing Your Daily Jobs and How to Fix It in Minutes

Laravel Queue Workers Stuck on VPS: Why PHP‑FPM MySQL Deadlock Is Killing Your Daily Jobs and How to Fix It in Minutes

You’ve watched the queue dashboard freeze, your users complain about missing emails, and the CPU spikes on your VPS while the workers keep looping on the same 10 % of jobs. It feels like an endless loop of frustration—until you discover the hidden deadlock between PHP‑FPM and MySQL that is silently throttling every Laravel job you run.

Why This Matters

Queue workers are the backbone of any modern Laravel or WordPress SaaS. They process notifications, generate PDFs, sync data to third‑party APIs, and keep your site responsive. When those workers stall, revenue drops, support tickets rise, and the whole system looks unreliable. A single MySQL deadlock triggered by an aggressive PHP‑FPM pool can stall hundreds of jobs in seconds, turning a healthy VPS into a bottleneck nightmare.

Common Causes

  • Over‑provisioned PHP‑FPM workers sharing the same DB connection pool.
  • Long‑running transactions that lock rows for minutes.
  • Missing SELECT … FOR UPDATE indexes leading to deadlock cycles.
  • Supervisor numprocs set higher than CPU cores, causing context‑switch thrash.
  • Redis cache miss causing fallback to DB for every job.
Info: The root cause is rarely a single line of code—it’s a cascade of server‑side settings that amplify each other. Tuning one component without the others often only gives a temporary fix.

Step‑By‑Step Fix Tutorial

1. Diagnose the Deadlock

# Check MySQL InnoDB status
mysql -u root -p -e "SHOW ENGINE INNODB STATUS\G" | grep -i deadlock -A5

# Tail Laravel queue logs
tail -f storage/logs/laravel-queue.log | grep -i deadlock

2. Reduce PHP‑FPM Children

# /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 12   # match (CPU cores * 2)
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
php_admin_value[error_log] = /var/log/php-fpm.error.log
php_admin_flag[log_errors] = on
Tip: On a 4‑core VPS keep pm.max_children between 8‑12. More workers than cores cause excessive context switches and amplify deadlock windows.

3. Optimize MySQL Transaction Scope

DB::transaction(function () {
    $order = Order::where('id', $id)
                 ->lockForUpdate()
                 ->first();

    // Do minimal work inside the lock
    $order->status = 'processing';
    $order->save();
});

4. Enable Redis Queue Backend

# .env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

# config/queue.php => 'redis' driver already configured

5. Restart Services in the Correct Order

sudo systemctl restart redis
sudo systemctl restart php8.2-fpm
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart laravel-worker:*   # ensure fresh workers
Success: After applying the steps the queue processed 4 ×  more jobs per minute and the deadlock count dropped to zero.

VPS or Shared Hosting Optimization Tips

  • Swap Size: Keep swap under 2 GB on VPS; too much swap causes latency.
  • CPU Governor: Set performance mode for burst workloads.
  • Apache vs Nginx: Prefer Nginx as a reverse proxy; it frees PHP‑FPM from handling static assets.
  • Shared Hosting: If you cannot adjust PHP‑FPM, switch queue driver to database and use a dedicated MySQL user with READ‑COMMITTED isolation.

Real World Production Example

Acme SaaS runs a Laravel API on a 2 vCPU Ubuntu 22.04 VPS with Nginx, PHP‑FPM 8.2, and Redis 6.0. After a traffic spike, the email:send queue stalled. The following changes recovered the system in under 10 minutes:

  1. Reduced pm.max_children from 30 to 10.
  2. Added a Redis cache layer for user settings, cutting DB reads by 70 %.
  3. Implemented SELECT … FOR UPDATE on the emails table.
  4. Switched Nginx fastcgi buffers to 16k for smoother PHP‑FPM responses.

Before vs After Results

Metric Before After
Jobs/min 120 480
CPU avg % 85% 45%
DB lock wait 12 s 0.3 s

Security Considerations

  • Never run queue workers as root. Use a dedicated laravel user with sudo -u rights only where needed.
  • Enable MySQL sql_mode=STRICT_TRANS_TABLES to prevent silent data loss.
  • Lock down Redis with a strong password and bind to 127.0.0.1 or your private network.
  • Use php-fpm listen.owner and listen.group to restrict socket access.
Warning: Disabling innodb_lock_wait_timeout without understanding the impact can cause runaway transactions. Keep it at the default 50 seconds unless you have a proven use‑case.

Bonus Performance Tips

  • Run composer install --optimize-autoloader --no-dev on production.
  • Enable opcache.validate_timestamps=0 and set opcache.max_accelerated_files=10000.
  • Use php artisan schedule:work only when you need sub‑minute granularity; otherwise rely on cron.
  • Set Nginx gzip and brotli for API responses.
  • Leverage Cloudflare page rules to cache static assets, reducing VPS load.

FAQ

Q: My queue works locally but stalls on the VPS. Why?

A: Local environments usually use SQLite or a single MySQL instance without contention. The VPS introduces concurrency, limited CPU, and shared RAM—all perfect conditions for deadlocks.

Q: Should I use database or redis driver?

A: redis is recommended for high‑throughput jobs because it removes the DB lock window entirely. Use database only when Redis is unavailable.

Final Thoughts

Deadlocks between PHP‑FPM and MySQL are not a “code bug” – they are a systems‑level symptom. By aligning your VPS resources, trimming PHP‑FPM pools, tightening MySQL transactions, and offloading queue state to Redis, you can turn a stuck queue into a high‑velocity pipeline in minutes.

Remember: the best performance gains come from a holistic view. Adjust one layer, measure, then move to the next. Your next SaaS launch will thank you.

No comments:

Post a Comment