پنجشنبه بعدازظهر بود که یک مشتری پیام داد لینک دانلودش نیامده. رفتیم سراغ جدول jobs و دیدیم ردیفها همانجا نشستهاند و تکان نمیخورند. صف لاراول اصلاً حرکت نمیکرد: ایمیلها، ساخت فاکتور، تولید لینک امضاشده — همه پشت هم مانده بودند. سایت هیچ خطایی نمیداد. فقط هیچ کاری انجام نمیشد.
علتش را میدانستیم. چند روز قبل با SSH وصل شده بودیم، nohup php artisan queue:work & زده بودیم و رفته بودیم. آن پردازه چند ساعت کار کرده بود و بعد مرده بود. روی هاست اشتراکی این اتفاق طبیعی است: پردازهای که بعد از بستهشدن نشست زنده بماند دیر یا زود جمع میشود، سهمیهی Entry Process اجازهی یک پردازهی همیشهبیدار را نمیدهد، و Supervisor هم اصلاً نصب نیست چون دسترسی root نداری که نصبش کنی.
راهحل این نیست که پردازه را سرسختتر کنیم؛ این است که اصلاً پردازهی دائمی نداشته باشیم. یک کرونجاب هر دقیقه یک worker بالا میآورد، آن worker تا خالیشدن صف کار میکند و بعد خودش تمام میشود — بهعلاوهی یک قفل، تا اگر اجرای قبلی هنوز تمام نشده بود اجرای بعدی بیسروصدا برگردد. این مقاله همان الگوست، با جزئیاتی که ما بهمرور و با ضرر یاد گرفتیم.
چرا پردازهی دائمی دوام نمیآورد و بهجای آن چه میسازیم#
روی سرور اختصاصی الگوی درست ساده است: Supervisor یک پردازهی queue:work را همیشه بالا نگه میدارد و اگر مُرد دوباره اجرایش میکند. روی هاست اشتراکی سیپنل، هیچکدام از فرضهای این الگو برقرار نیست:
- Supervisor نصب نیست و نصبش کار root است.
- پردازهای که از نشست SSH بالا میآید دیر یا زود کشته میشود؛
nohupوsetsidفقط عمرش را کمی طولانیتر میکنند. - هر پردازهی زنده یک Entry Process از سهمیهی شما میگیرد؛ اگر یکی همیشه بیدار باشد، در ساعت شلوغی خطای ۵۰۸ میگیرید.
- و بدترینش: وقتی این پردازه میمیرد هیچکس خبردار نمیشود.
پس چیزی که میسازیم این است:
- یک کرونجاب که هر دقیقه اجرا میشود.
- داخلش
queue:workبا--stop-when-empty— یعنی «کار کن تا صف خالی شود، بعد خودت تمام شو». - سقفهای
--max-timeو--max-jobsتا حتی اگر صف خالی نشد، پردازه بینهایت نماند. - یک قفل، تا دو نمونه همزمان بالا نیایند.
نتیجهاش بین صفر تا یک دقیقه تأخیر در شروع کار است، و هیچ پردازهی دائمیای که کسی بکشدش.
پیشنیازها کوتاه است: دسترسی به بخش Cron Jobs در سیپنل، یک ترمینال برای تست، و مسیر مطلق باینری PHP و پوشهی اپلیکیشن. اگر تازه دارید لاراول را روی هاست اشتراکی راه میاندازید، چکلیست هاست اشتراکی برای لاراول نقطهی شروع بهتری از این مقاله است.
تفاوت queue:work و queue:listen و چرا --once گزینهی خوبی نیست#
این سه شبیه هم به نظر میرسند و اصلاً شبیه هم نیستند.
queue:work یک بار کل فریمورک را بوت میکند و بعد داخل حلقه میماند: جاب بردار، اجرا کن، جاب بعدی. چون بوت یک بار انجام میشود سریع است؛ عوارضش این است که کد و کانفیگ در حافظه میماند و تغییرات بعدی را نمیبیند — پایینتر سراغش میرویم.
queue:listen برای هر جاب یک پردازهی تازه بالا میآورد، پس همیشه آخرین کد را میبیند و برای محیط توسعه خوب است. اما خودش یک پردازهی دائمی است و هیچوقت تمام نمیشود؛ یعنی دقیقاً همان چیزی که میخواهیم از آن فرار کنیم. گذاشتنش در کرون یعنی هر دقیقه یک پردازهی جاودانهی تازه.
--once هم وسوسهانگیز است: «یک جاب اجرا کن و برو». اما با کرونِ هر دقیقه سقف شما میشود ۶۰ جاب در ساعت، و برای هر یک جاب کل فریمورک از نو بوت میشود؛ بیشترین هزینه برای کمترین کار.
چیزی که میخواهیم ترکیب هر دوست: یک بار بوت، چند جاب پشت سر هم، بعد خروجِ داوطلبانه. این دقیقاً کار --stop-when-empty است.
درایور صف: database کِی منطقی است و sync کِی کافی است#
پیش از کرونجاب باید تکلیف درایور روشن باشد. مقدارش در .env است:
QUEUE_CONNECTION=databasesync یعنی «اصلاً صفی در کار نیست»؛ هر جاب همانجا داخل همان درخواست اجرا میشود. اگر ارسال ایمیل چهار ثانیه طول بکشد، کاربر چهار ثانیه به صفحهی سفید نگاه میکند؛ و اگر SMTP خطا بدهد، کاربر خطای ۵۰۰ میبیند نه صفی که بعداً دوباره تلاش کند. sync وقتی کافی است که کارهای پسزمینه سریع و بیخطر باشند.
database روی هاست اشتراکی انتخاب منطقی است چون هیچ سرویس تازهای لازم ندارد؛ همان MySQL صف را هم نگه میدارد. Redis معمولاً یا نیست یا تضمینی برای ماندگاریاش ندارید. هزینهاش یک کوئری برای هر بار برداشتن جاب است که در حد چند صد جاب در روز به چشم نمیآید.
اگر .env را با ویرایشگر ویندوزی دستکاری کردهاید و لاراول همچنان sync را میخواند، پیش از هر چیز پایانخط ویندوزی در فایل env را ببینید.
جدولهای jobs و failed_jobs#
درایور database به دو جدول نیاز دارد: jobs برای کارهای در انتظار و failed_jobs برای کارهایی که همهی تلاشهایشان شکست خورده. اینکه مایگریشنشان از کجا میآید بسته به نسخه فرق میکند: در نسخههای جدیدتر لاراول یک فایل مایگریشن در اسکلت پیشفرض پروژه هر سه جدول jobs، job_batches و failed_jobs را میسازد و فقط کافی است migrate بزنید. در نسخههای قدیمیتر باید خودتان بسازیدشان:
php artisan queue:table
php artisan queue:failed-table
php artisan migrate --forceسادهترین راه فهمیدنِ وضعیت، نگاهکردن به خود دیتابیس است:
SHOW TABLES LIKE 'jobs';
SHOW TABLES LIKE 'failed_jobs';اگر جدول jobs نباشد، هر dispatch با خطای «table not found» شکست میخورد؛ و چون این خطا داخل همان درخواست رخ میدهد، در لاگ اپلیکیشن مینشیند نه در لاگ صف.
کرونجاب اصلی: مسیر مطلق PHP و پرچمهای توقف#
اینجا رایجترین جایی است که کار میشکند و هیچ نشانهای هم نمیدهد.
توی SSH وقتی php84 artisan queue:work میزنید کار میکند. همان خط را در کرون میگذارید و هیچ اتفاقی نمیافتد. علتش این است که php84 معمولاً یک alias یا تابع شل است که در پروفایل شل تعاملی تعریف شده، و کرون شلِ غیرتعاملی اجرا میکند که آن پروفایل را نمیخواند. ما خودمان چند ساعت روی همین رفتیم و در تلهی alias در شل غیرتعاملی کرون شرحش دادهایم. خلاصهاش: در کرون فقط مسیر مطلق بنویسید.
command -v php84
readlink -f "$(command -v php84)"
ls -l /opt/cpanel/ea-php84/root/usr/bin/phpروی خیلی از سرورهای سیپنل مسیر /opt/cpanel/ea-php84/root/usr/bin/php است، ولی بسته به پیکربندی هاست فرق میکند. هر مسیری که پیدا کردید اول با -v امتحانش کنید تا مطمئن شوید همان نسخهای است که سایت رویش کار میکند:
/opt/cpanel/ea-php84/root/usr/bin/php -vحالا خودِ کرونجاب. سادهترین شکلش، بدون قفل:
* * * * * cd ~/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan queue:work database --stop-when-empty --max-time=50 --max-jobs=200 --tries=3 --backoff=30 >> ~/logs/queue.log 2>&1تکتک اجزایش:
cd ~/app &&— آرتیسان باید از ریشهی پروژه اجرا شود، وگرنه مسیرهای نسبیِ لاراول به هم میریزد. اگر~در کرونِ هاست شما باز نشد، مسیر کامل بنویسید:cd /home/USER/app &&.database— نام کانکشن. اگر پیشفرض.envهمین است میشود حذفش کرد، ولی صریح نوشتنش یک ابهام کمتر است.--stop-when-empty— قلب ماجرا. وقتی صف خالی شد پردازه تمام میشود.--max-time=50— سقف عمر پردازه به ثانیه. عمداً کمتر از ۶۰ تا در حالت عادی هر اجرا پیش از شلیک بعدی تمام شده باشد.--max-jobs=200— سقف تعداد جاب در یک اجرا؛ بیمهای در برابر نشت حافظه.--tries=3و--backoff=30— بخش بعدی.>> ~/logs/queue.log 2>&1— هم خروجی معمولی و هم خطاها. بدون این، اگر چیزی خراب شود هیچ ردی نمیماند.
اگر چند صف با اولویت متفاوت دارید، --queue=high,default,low را اضافه کنید؛ لاراول از چپ به راست اولویت میدهد.
قفل با flock و راه جایگزین وقتی flock نبود#
فرض کنید یک اجرا بهخاطر جابهای سنگین بیشتر از یک دقیقه طول بکشد. دقیقهی بعد کرون دوباره شلیک میکند و حالا دو worker همزمان روی یک صف کار میکنند. اگر ادامه پیدا کند، ده دقیقه بعد ده پردازه دارید، سهمیهی Entry Process پر میشود و سایت خطای ۵۰۸ میدهد. ما یک بار درست همینطور سایت را خواباندیم.
flock این را با یک خط حل میکند:
* * * * * /usr/bin/flock -n /home/USER/app/storage/app/queue.lock -c 'cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan queue:work database --stop-when-empty --max-time=50 --max-jobs=200' >> /home/USER/logs/queue.log 2>&1-n یعنی «منتظر نمان». اگر قفل در دست کسی باشد، flock بلافاصله با کد خروج ۱ برمیگردد و دستور را اصلاً اجرا نمیکند. فایل قفل را هم خودش میسازد. اینجا عمداً همهی مسیرها مطلقاند؛ برای چیزی که هر دقیقه اجرا میشود، ابهامنداشتن ارزشش بیشتر است.
اول ببینید flock اصلاً روی هاست هست یا نه:
command -v flockاگر نبود، دو جایگزین داریم.
جایگزین اول — قفل خود لاراول. زمانبند لاراول یک قفل اتمی روی درایور کش دارد:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:work database --stop-when-empty --max-time=50 --max-jobs=200')
->everyMinute()
->withoutOverlapping(5);withoutOverlapping(5) یعنی قفل حداکثر پنج دقیقه معتبر است؛ اگر پردازه بدون آزادکردن قفل کشته شود، بعد از پنج دقیقه خودبهخود باز میشود. یک شرط دارد: درایور کش باید بین پردازهها مشترک باشد. اگر CACHE_STORE روی array باشد قفل هیچ معنایی ندارد، چون هر پردازه کش خودش را دارد؛ file یا database هر دو کارند.
در این حالت schedule:run تا پنجاه ثانیه بلوکه میماند و بقیهی کارهای همان دقیقه عقب میافتند. ->runInBackground() این را حل میکند ولی به exec یا proc_open نیاز دارد که روی خیلی از هاستهای اشتراکی غیرفعال است — disable_functions روی هاست اشتراکی همین دردسر است. ما ترجیح دادیم صف را در کرونجاب جدا نگه داریم.
جایگزین دوم — قفل با mkdir. ساختن پوشه در سیستمفایل اتمی است: دو پردازه نمیتوانند همزمان یک پوشه را بسازند. یک اسکریپت کوچک بنویسید، مثلاً ~/bin/run-queue.sh:
#!/bin/bash
APP=/home/USER/app
PHP=/opt/cpanel/ea-php84/root/usr/bin/php
LOCK="$APP/storage/app/queue.lock"
# قفلِ جامانده از یک پردازهی کشتهشده را بعد از ۱۰ دقیقه آزاد کن
find "$APP/storage/app" -maxdepth 1 -type d -name queue.lock -mmin +10 -exec rmdir {} \; 2>/dev/null
mkdir "$LOCK" 2>/dev/null || exit 0
trap 'rmdir "$LOCK" 2>/dev/null' EXIT
cd "$APP" || exit 1
"$PHP" artisan queue:work database --stop-when-empty --max-time=50 --max-jobs=200با chmod +x ~/bin/run-queue.sh اجراییاش کنید و در کرون صدایش بزنید:
* * * * * /home/USER/bin/run-queue.sh >> /home/USER/logs/queue.log 2>&1آن خط find مهم است: trap فقط وقتی کار میکند که پردازه عادی تمام شود؛ اگر با kill -9 کشته شود پوشهی قفل میماند و صف تا ابد خاموش میشود. flock این مشکل را ندارد چون قفل به عمر پردازه بسته است — پس اگر هست، همان بهتر است.
--tries، --backoff و --timeout — و فرق --timeout با max_execution_time#
این سه پرچم رفتار صف را در برابر خطا میسازند و اغلب با هم اشتباه گرفته میشوند.
--tries تعداد تلاشهای یک جاب پیش از رفتن به failed_jobs است. پیشفرضش معمولاً ۱ است، یعنی اولین استثنا کار را مستقیم شکستخورده میکند. برای کارهای وابسته به شبکه — ارسال ایمیل، صداکردن درگاه پرداخت — عدد ۱ بیرحمانه است.
--backoff فاصلهی بین تلاشها به ثانیه است. بدون آن هر سه تلاش در چند ثانیه پشت هم اتفاق میافتند و اگر مشکل قطعیِ موقتِ سرویس طرف مقابل باشد، هر سه شکست میخورند. برای رفتار پلکانی میتوانید داخل خود کلاس جاب بنویسید:
public $tries = 3;
public array $backoff = [10, 60, 300];--timeout سقف زمان اجرای یک جاب است. اگر جابی بیشتر از این طول بکشد، worker پردازه را متوقف میکند. دو نکته:
- اجرای این سقف به افزونهی
pcntlنیاز دارد. اگر روی هاست نباشد، worker عملاً نمیتواند جاب طولانی را قطع کند و شما یک سقف کاغذی دارید. باphp -mوجودش را چک کنید. --timeoutباید کمتر ازretry_afterکانکشن درconfig/queue.phpباشد.retry_afterمیگوید بعد از چند ثانیه یک جابِ گرفتهشده دوباره «آزاد» حساب شود. اگر جاب ۱۲۰ ثانیه طول بکشد وretry_afterروی ۹۰ باشد، worker بعدی همان جاب را دوباره برمیدارد و کار دوبار انجام میشود.
و اما max_execution_time: این تنظیم php.ini است و عمر کل اسکریپت PHP را محدود میکند، نه عمر یک جاب را. PHP در حالت CLI معمولاً این مقدار را صفر (بینهایت) میگذارد، پس بر خلاف تصور رایج جلوی worker را نمیگیرد؛ چیزی که ممکن است بگیرد محدودیت خود هاست روی زمان CPU پردازههای کرون است. به max_execution_time تکیه نکنید و --max-time را صریح بنویسید.
| تنظیم | چه چیزی را محدود میکند | کجا بررسی میشود |
|---|---|---|
--timeout | مدت اجرای یک جاب | داخل worker، نیازمند pcntl |
--max-time | عمر کل پردازهی worker | بعد از هر جاب |
--max-jobs | تعداد جاب در یک اجرا | بعد از هر جاب |
--memory | حافظهی مصرفی به مگابایت | بعد از هر جاب |
max_execution_time | عمر اسکریپت PHP | در CLI معمولاً صفر است |
بعد از هر استقرار: چرا باید queue:restart زد#
queue:work کد و کانفیگ را یک بار در حافظه بارگذاری میکند و تا آخر عمرش همان را نگه میدارد. یعنی اگر وسط اجرای یک worker فایلهای جدید را آپلود کنید، آن worker همچنان کد قدیمی را اجرا میکند.
با الگوی ما هر worker حداکثر پنجاه ثانیه عمر دارد، پس این پنجره کوچک است — اما صفر نیست، و دقیقاً همانجایی است که خطاهای عجیب متولد میشوند: جابی که با ساختار قدیمی سریالایز شده ولی کلاسش عوض شده، یا کانفیگی که هنوز کش قدیمی را میخواند.
راه درست، php artisan queue:restart در انتهای اسکریپت استقرار است. این دستور جاب را قطع نمیکند؛ یک زمانمهر در کش مینویسد و هر worker بعد از تمامشدن جاب جاری، اگر آن زمانمهر جدیدتر از شروع خودش بود، تمیز خارج میشود. یک شرط دارد که زیاد از قلم میافتد: این علامت در کش ذخیره میشود، پس اگر CACHE_STORE روی array باشد به هیچ پردازهای نمیرسد و دستور بیاثر است.
ترتیب کارها در استقرار برای ما این شکلی است:
cd /home/USER/app
php artisan down
# آپلود یا کشیدن کد جدید
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
php artisan upqueue:restart بعد از config:cache میآید، نه قبلش؛ وگرنه worker تازه ممکن است کانفیگ نیمهکاره را بردارد.
کارهای شکستخورده: queue:failed، queue:retry و پاکسازی دورهای#
جابی که همهی tries خود را مصرف کند به failed_jobs میرود و دیگر خودبهخود اجرا نمیشود؛ اگر کسی سراغش نرود همانجا میماند. ما یک بار بعد از دو هفته فهمیدیم دهها ایمیل هرگز ارسال نشدهاند. فهرستشان با php artisan queue:failed میآید.
پیش از هر تلاش دوباره، دلیل شکست را بخوانید. queue:retry all زدن بدون فهمیدن علت یعنی همان خطاها را دوباره تولید کنید:
SELECT id, queue, failed_at, LEFT(exception, 300) AS err
FROM failed_jobs
ORDER BY failed_at DESC
LIMIT 5;بعد از رفع علت، php artisan queue:retry all همه را برمیگرداند، یا با شناسهای که در خروجی queue:failed دیدهاید فقط یکی را:
php artisan queue:retry 9f1c8a3e-...-b2برای حذف بدون اجرای دوباره queue:forget هست و برای خالیکردن کل جدول queue:flush. ستون exception در failed_jobs متن کامل استکتریس را نگه میدارد، پس چند هزار ردیف میتواند حجم قابلتوجهی بگیرد. پاکسازی دورهای را به زمانبند بسپارید:
Schedule::command('queue:prune-failed --hours=336')->weekly();یعنی هرچه بیش از دو هفته مانده حذف شود. عدد را متناسب با اینکه واقعاً هر چند وقت به این جدول سر میزنید انتخاب کنید — نه بیشتر.
زمانبند لاراول با همان یک کرونجاب هر دقیقه#
زمانبند لاراول فارغ از تعداد کارها فقط یک کرونجاب لازم دارد:
* * * * * cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1schedule:run هر دقیقه بیدار میشود، نگاه میکند کدام کارها زمانشان رسیده و همانها را اجرا میکند. بقیهی زمانبندی داخل کد است؛ در نسخههای جدید در routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-failed --hours=336')->weekly();
Schedule::command('backup:run')->dailyAt('03:30');پس در مجموع دو کرونجاب دارید: یکی برای صف و یکی برای زمانبند. میشد صف را هم داخل زمانبند برد، ولی جدا نگهداشتنشان یک مزیت عملی دارد: اجرای پنجاهثانیهای صف بقیهی کارهای آن دقیقه را عقب نمیاندازد.
یک نکتهی ریز ولی دردسرساز: کاراکتر % در کرون معنی خاص دارد و به خط جدید تفسیر میشود. اگر در دستورتان چیزی مثل date +%F هست باید به شکل \%F نوشته شود، وگرنه دستور از همانجا بریده میشود.
تلههایی که خودمان خوردیم#
حافظه. هر جاب چیزی در حافظه جا میگذارد: مدلها، کانکشنها، هرچه ایستا نگه داشتهاید. یک worker که هزار جاب پشت هم اجرا کند ممکن است وسط کار به سقف حافظه بخورد. --max-jobs=200 و --memory=128 دو بیمهی سادهاند؛ worker بعد از هر جاب مصرفش را چک میکند و اگر رد شده باشد تمیز خارج میشود. در این الگو تمامشدن worker اصلاً بد نیست؛ دقیقهی بعد یکی تازه میآید.
سقف Entry Process. با قفل، در هر لحظه حداکثر یک worker زنده است. بدون قفل، یک جاب گیرکرده کافی است تا در نیم ساعت دهها پردازه روی هم جمع شود. اگر سایتتان بیدلیل ۵۰۸ میدهد، اول تعداد پردازههای PHP را ببینید:
ps -u $USER -o pid,etime,cmd | grep artisanلاگی که بزرگ میشود. دو فایل رشد میکنند: storage/logs/laravel.log و همان فایلی که خروجی کرون را در آن ریختهاید. queue:work برای هر جاب یک خط چاپ میکند و روی هاست اشتراکی، پرشدن سهمیهی دیسک یعنی خطای نوشتن در MySQL و از کار افتادن کل سایت. برای لاگ اپلیکیشن، کانال روزانه با سقف نگهداری:
LOG_CHANNEL=daily
LOG_DAILY_DAYS=14و برای لاگ کرون، یک خالیکردن دورهای:
0 4 * * * : > /home/USER/logs/queue.logتست: از کجا بفهمیم واقعاً دارد کار میکند#
اول مطمئن شوید کرون اصلاً اجرا میشود و PHP درست را صدا میزند. یک کرون موقت بگذارید:
* * * * * /opt/cpanel/ea-php84/root/usr/bin/php -v >> /home/USER/logs/phpcheck.log 2>&1اگر بعد از دو دقیقه این فایل ساخته نشد، مشکل شما هنوز صف نیست؛ مسیر PHP یا خود کرون است.
بعد مسیر واقعی:
- کاری در سایت انجام دهید که جاب میسازد (مثلاً درخواست بازیابی رمز).
SELECT COUNT(*) FROM jobs;باید بزرگتر از صفر باشد.- یک دقیقه صبر کنید و دوباره بشمارید — باید صفر شود.
tail -n 30 /home/USER/logs/queue.logرا ببینید؛ باید خطوط پردازش جابها باشد.
در آخر قفل را تست کنید: دقیقاً همان دستور کرون را در دو ترمینال همزمان اجرا کنید. دومی باید فوراً و بدون خروجی برگردد و echo $? با flock باید ۱ بدهد. اگر دومی هم شروع به کار کرد، قفل شما کار نمیکند و پیش از هر چیز باید همین را درست کنید.
جمعبندی#
روی هاست اشتراکی، دنبال نگهداشتن یک پردازهی همیشهزنده نگردید؛ محیط ساخته شده که چنین پردازهای را بکشد. بهجایش کاری کنید که مرگ پردازه بیاهمیت باشد: هر دقیقه یکی بالا میآید، صف را خالی میکند و میرود.
سه چیز این الگو را از یک ترفند به راهحلی قابلاتکا تبدیل میکند: مسیر مطلق PHP در کرون، یک قفل واقعی، و سقفهای صریح روی زمان و تعداد جاب و حافظه. بقیهاش — queue:restart بعد از استقرار، پاکسازی failed_jobs، مهار لاگ — نگهداری است، و همان است که تفاوت «کار میکند» و «شش ماه بعد هم کار میکند» را میسازد.