RE:NODE

Laravel queue-ები Valkey-ზე: worker-ები, retry-ები, failed job-ები

ავტორი RE:NODE-ის გუნდი
21 წუთის საკითხავი


Laravel Valkey-სთან redis queue driver-ით ურთიერთობს, ამიტომ გამართვა .env-ში ოთხი ხაზია და ერთი გრძელვადიანი პროცესი: php artisan queue:work redis. Driver-ი, retry-ის წესები და failed-jobs ცხრილი ადვილი ნაწილია. ის ნაწილი, რომელიც job-ებს გიკარგავს, სამ რიცხვს შორის ურთიერთქმედებაა - job-ის timeout, კავშირის retry_after და ის, თუ რამდენ ხანს რჩება worker-ის პროცესი ცოცხალი - და კითხვა, რა გადატვირთავს ამ worker-ს, როცა ის მოკვდება. ერთ container-ში ამაზე აშკარა პასუხი არ არსებობს. ეს პოსტი ყველაფერს ფარავს იმ თანმიმდევრობით, როგორითაც დაგჭირდება.

პოსტი განზრახ არ იმეორებს ორ წინა პოსტს. Deploy Laravel to production განიხილავს ძირითად deploy-ს და პირველ queue:work-ს database driver-ით. Job queues with Valkey ადარებს BullMQ-ს, Celery-ს, RQ-ს და Laravel-ს და განმარტავს სერვერის იმ პარამეტრებს, რომლებზეც queue არის დამოკიდებული. აქ კი Laravel-ის სპეციფიკური სიღრმეა: კონფიგურაციის გასაღებები, retry-ის ქცევა, failed job-ების დამუშავება და პროცესების ზედამხედველობა.

რატომ გადავიტანოთ queue ბაზიდან Valkey-ზე#

database driver-ი კარგი ნაგულისხმევია და პატარა საიტისთვის სწორი არჩევანია: job იწერება იმავე transaction-ში, რომელშიც მისი გამომწვევი მონაცემები, დამატებით არაფერი მუშაობს და queue-ს SQL-ით ამოწმებ. ტკივილი სამი მიზეზით იწყება. Worker-ები poll-ს აკეთებენ, ამიტომ ყოველი უქმი worker რამდენიმე წამში ერთხელ query-ს უშვებს. ბევრი worker ერთსა და იმავე ჩანაწერებზე ეჯიბრება ერთმანეთს. და jobs ცხრილი დისკსა და კავშირებს დანარჩენ აპლიკაციასთან იზიარებს.

Valkey job-ების ტრაფიკს შენი რელაციური ბაზისგან იშორებს. push ერთი list ოპერაციაა, სამუშაოს მომლოდინე worker poll-ის ნაცვლად blocking კავშირს ინახავს, ათასობით job-ის დაგროვება კი ცხრილის გაბერვას კი არა, მეხსიერებას ხარჯავს. გიწევს უარი თქვა transaction-ულ გარანტიაზე: redis driver-ით job შეიძლება push-დეს იმ ბაზის transaction-ის commit-მდე, რომელმაც მისი მონაცემები შექმნა, და სწრაფი worker მას ჯერ არარსებულ ჩანაწერებზე გაუშვებს. ამას ქვემოთ განყოფილება "Dispatching inside transactions" განიხილავს და თუ job მხოლოდ დროდადრო ვარდება, პირველად სწორედ ეს უნდა შეამოწმო.

აირჩიე Valkey, როცა cache-ისთვის ან session-ებისთვის ის უკვე გაქვს, როცა job-ების მოცულობა იმდენად მაღალია, რომ poll-ი ბაზის დატვირთვაში ჩანს, ან როცა Horizon გინდა. დარჩი database-ზე, როცა მოცულობა დაბალია და transaction-ების სისწორე throughput-ზე მნიშვნელოვანია. Background jobs on a small server პატიოსნად იცავს მარტივ ვარიანტს.

გამართე redis queue driver-ი#

Laravel-ის Redis მხარდაჭერა Valkey-თან უცვლელად მუშაობს, რადგან Valkey იმავე პროტოკოლზე ლაპარაკობს. ჯერ client ბიბლიოთეკა გჭირდება. Laravel მხარს უჭერს phpredis PHP გაფართოებას და წმინდა PHP-ის predis პაკეტს, რომლებიც REDIS_CLIENT-ით შეირჩევა. შეამოწმე, რა აქვს შენს PHP-ს:

bash
$ php -m | grep -i redis$ composer require predis/predis

თუ php -m სიაში redis ჩანს, გამოიყენე REDIS_CLIENT=phpredis. თუ არ ჩანს, predis-ს გაფართოება საერთოდ არ სჭირდება და ისევე მუშაობს, ოდნავ ნელა. შემდეგ კავშირის მონაცემები ჩაწერე .env-ში:

.env
QUEUE_CONNECTION=redisREDIS_CLIENT=phpredisREDIS_HOST=203.0.113.20REDIS_PORT=6380REDIS_PASSWORD=a-long-generated-passwordREDIS_PREFIX=shop-

REDIS_HOST, REDIS_PORT და REDIS_PASSWORD იმ მნიშვნელობებია, რომლებსაც Valkey-ს პროვაიდერი გაძლევს. ამის ნაცვლად შეგიძლია ერთი REDIS_URL გადასცე ფორმით redis://:password@host:port; ის ცალკეულ გასაღებებზე წინ არის. REDIS_PREFIX ხელით დასაყენებელია. ნაგულისხმევად Laravel prefix-ს APP_NAME-დან აგებს, ანუ აპლიკაციის სახელის შეცვლა prefix-ს ცვლის და ძველი prefix-ის ქვეშ მომლოდინე ყველა job-ს ობოლს ტოვებს.

თვით queue driver-ი config/queue.php-ში კონფიგურირდება:

config/queue.php
'redis' => [    'driver' => 'redis',    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),    'queue' => env('REDIS_QUEUE', 'default'),    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),    'block_for' => null,    'after_commit' => false,],
გასაღებინაგულისხმევირას აკეთებს
connectiondefaultconfig/database.php-ში redis-ის ქვეშ რომელი ჩანაწერი გამოიყენოს
queuedefaultრომელ queue-ში მიდის job, როცა სახელი არ არის მითითებული
retry_after90რამდენ წამში უბრუნდება დაჯავშნილი job queue-ს
block_fornullრამდენ წამს ელოდება worker job-ს blocking რეჟიმში
after_commitfalsepush-მდე ღია transaction-ების დაცდა

ამ გასაღებებიდან ორზე გადაწყვეტილება გჭირდება. block_for-ის 5-ზე დაყენება უქმ worker-ს ციკლში poll-ის ნაცვლად ხუთ წამამდე blocking pop-ზე ალოდინებს, რაც ამცირებს დაგვიანებასაც და Valkey-ს ტრაფიკსაც. null დატოვე მხოლოდ მაშინ, თუ მიზეზი გაქვს. after_commit კი ის transaction-ის გამოსწორებაა, რომელიც ქვემოთ აღწერილია.

რეალური job-ების წერამდე დარწმუნდი, რომ ყველაფერი თავიდან ბოლომდე მუშაობს:

bash
$ php artisan tinker --execute="dispatch(fn () => logger('queue ok'));"$ php artisan queue:work redis --once$ tail -n 3 storage/logs/laravel.log

--once ერთ job-ს ამუშავებს და გადის, ტესტისთვის ეს სწორი გზაა. თუ log-ში ხაზი გამოჩნდა, შენი driver-ი, credentials და prefix სწორია.

რას ინახავს Laravel Valkey-ში#

როცა რაღაც არასწორად გამოიყურება, გამოგადგება იცოდე, რა დევს ბაზაში. თითოეული queue-ის სახელისთვის Laravel შენი prefix-ის ქვეშ სამ სტრუქტურას ინახავს:

გასაღებიტიპირას შეიცავს
queues:defaultlistგასაშვებად მზა job-ები
queues:default:delayedsorted setjob-ები მომავალი გაშვების დროით
queues:default:reservedsorted setjob-ები, რომლებიც worker-მა აიღო, მაგრამ ჯერ არ დაუმთავრებია

Worker list-იდან იღებს job-ს და იმავე ატომურ ნაბიჯში ამატებს მას reserved სიმრავლეში ქულით "ახლა plus retry_after". job-ის დასრულებისას ის reserved სიმრავლიდან იშლება. ის job, რომლის ქულაც გადავადებულია, შემდეგი worker-ის მიერ, რომელიც შეხედავს, ისევ list-ში გადაიტანება. სწორედ ეს ერთადერთი მექანიზმია retry-after-ის მთელი ქცევა და ამიტომაა შემდეგი განყოფილება მნიშვნელოვანი.

მისი პირდაპირ ნახვაც შეგიძლია. --scan ფორმა prefix-ის გამოცნობას გაცილებს:

bash
$ valkey-cli -h 203.0.113.20 -p 6380 --askpass --scan --pattern '*queues:*'$ valkey-cli -h 203.0.113.20 -p 6380 --askpass LLEN shop-queues:default$ valkey-cli -h 203.0.113.20 -p 6380 --askpass ZCARD shop-queues:default:reserved

მზარდი reserved მთვლელი ცარიელ list-თან ერთად ნიშნავს, რომ worker-ები job-ებს იღებენ და ვერ ამთავრებენ. მზარდი list და უცვლელი reserved ნიშნავს, რომ საერთოდ არაფერი მუშაობს. თუ Laravel-ს REDIS_PREFIX იმისგან განსხვავებული აქვს, რასაც ამოწმებ, ცარიელ ბაზას დაინახავ და დაასკვნი, რომ queue გაფუჭდა. არ გაფუჭებულა.

Timeout-ები და retry_after: ბაგი, რომელიც job-ს ორჯერ უშვებს#

ყოველი Laravel queue-ის ბაგის რეპორტი, რომელიც ამბობს "ჩემი job ორჯერ გაეშვა", ორიდან ერთია. ან job მართლა შუაში ჩავარდა და ხელახლა სცადა, რაც at-least-once მიწოდების სწორი ქცევაა, ან retry_after job-ზე მოკლეა. მეორე კონფიგურაციის შეცდომაა.

სამი დროითი ლიმიტი არსებობს და ისინი სწორად უნდა ჩაიდგას ერთმანეთში:

  1. თვით job-ის ლიმიტი: $timeout თვისება job კლასზე ან --timeout worker-ზე (ნაგულისხმევი 60 წამი). უფრო დიდხანს მომუშავე job-ს worker pcntl გაფართოების გამოყენებით კლავს.
  2. კავშირის retry_after (ნაგულისხმევი 90 წამი). ამ დროის შემდეგ დაჯავშნილი job დაკარგულად ითვლება და სხვა worker-ისთვის ბრუნდება queue-ში.
  3. Worker-ის --max-time, რომლის შემდეგაც ის job-ებს შორის გადის.

წესი ასეთია: job-ის timeout retry_after-ზე მოკლე უნდა იყოს, ზღვრით. ნაგულისხმევი მნიშვნელობებით ეს 60-ია 90-ის წინააღმდეგ, რაც კარგია. თუ ანგარიშის ექსპორტისთვის job-ის $timeout-ს 300-მდე გაზრდი და retry_after-ს 90-ზე დატოვებ, მეორე worker იმავე job-ს 90-ე წამზე აიღებს, სანამ პირველი ჯერ კიდევ მუშაობს. ორივე დასრულდება. მომხმარებელი ორ ანგარიშს მიიღებს.

app/Jobs/BuildExport.php
class BuildExport implements ShouldQueue{    use Queueable;    public int $timeout = 300;    public bool $failOnTimeout = true;    // config/queue.php retry_after must be larger than 300, say 330}

ამასთან შესაბამისობაში .env-ში დააყენე REDIS_QUEUE_RETRY_AFTER=330. $failOnTimeout = true job-ს timeout-ის დროს ხელახლა ცდის ნაცვლად failed-ად ნიშნავს, რაც ჩვეულებრივ სწორედ ის არის, რაც გინდა job-ისთვის, რომელიც უკვე ხუთი წუთი მუშაობდა.

ხარჯიანი ალტერნატივაა ყველაფრისთვის ერთი გიგანტური retry_after, ვთქვათ ერთი საათი. ის დუბლიკატებისგან დაცულია და აღდგენაში საშინელია: თუ worker job-ის შუაში მოკვდა, ეს job საათის განმავლობაში უხილავად ჰკიდია reserved სიმრავლეში, სანამ ვინმე ხელახლა სცდის. უკეთესი დიზაინია retry_after ზომიერი დატოვო, ხოლო მართლაც ნელი job-ები ცალკე queue-ში, ცალკე worker-ით, უფრო დიდი --timeout-ით გაუშვა, retry_after კი ამ timeout-ზე მაღლა დააყენო. config/queue.php-ში ორი კავშირი, redis და redis-long, თითოეული საკუთარი retry_after-ით, სწორედ ამისთვის კანონიერი გზაა.

Timeout-ების აღსასრულებლადაც კი worker-ებს pcntl PHP გაფართოება სჭირდება. თუ job სამუდამოდ ჩერდება და --timeout არაფერს აკეთებს, შეამოწმე php -m | grep pcntl.

Tries, backoff და middleware#

რამდენჯერ სცდება job და რამდენი ხანი ელოდება ცდებს შორის, worker-ზეც შეიძლება დაყენდეს და job-ზეც. ორივეს არსებობისას job იმარჯვებს.

app/Jobs/SendInvoice.php
class SendInvoice implements ShouldQueue{    use Queueable;    public int $tries = 5;    public int $maxExceptions = 3;    public function backoff(): array    {        return [10, 60, 300, 900];    }    public function handle(Mailer $mailer): void    {        $mailer->send($this->invoiceId);    }}

მნიშვნელოვანი ატრიბუტები:

თვისება ან პარამეტრიმნიშვნელობა
$tries / --triesცდების მაქსიმალური რაოდენობა, პირველის ჩათვლით
$maxExceptionsამდენი exception-ის შემდეგ ჩავარდნა, თუნდაც tries დარჩენილი იყოს
backoff() / --backoffწამები retry-მდე; მასივი ცდიდან ცდამდე იზრდება
retryUntil()რაოდენობის ნაცვლად ვადა
$timeout / --timeoutjob-ის მოკვლა ამდენი წამის შემდეგ

რამდენიმე წერტილი, რომელზეც ხალხი ტყდება. --tries=0 ნიშნავს შეუზღუდავს, რაც job-ზე, რომელიც ყოველთვის ვარდება, უსასრულო ციკლია და failed_jobs-ამდე არასოდეს აღწევს. $this->release(30)-ით გათავისუფლებული job ცდად ითვლება. $tries და retryUntil() ერთად ნიშნავს, რომ რომელიმე ლიმიტი პირველი მიიღწევა, ის მოქმედებს. და job, რომელიც იმიტომ სცდება ხელახლა, რომ მისი worker მოკლეს, ცდას ითვლის, თუმცა შენს კოდს exception არ აუგდია, სწორედ ამიტომ არსებობს $maxExceptions.

Middleware retry-ის ლოგიკას job-ის სხეულიდან გამოაქვს. სამი ღირს, რომ იცოდე. WithoutOverlapping lock-ს იღებს, რომ ერთი და იმავე გასაღების ორი job არასოდეს გაეშვას ერთად. RateLimited job-ებს აკავებს, როცა დასახელებული limiter ამოიწურა, რაც მესამე მხარის API-ის კვოტის პატივისცემის სწორი გზაა. ThrottlesExceptions არასტაბილური სერვისის განმეორებითი ჩავარდნების შემდეგ უკან იხევს და ყველა tries-ს წუთში არ წვავს. პირველი ორიდან ორივე cache-ს იყენებს, ამიტომ საერთო cache store მნიშვნელოვანია, თუ ოდესმე ერთზე მეტ worker პროცესს გაუშვებ ცალკე მანქანებზე; უკვე არსებული Valkey კარგი არჩევანია.

php
public function middleware(): array{    return [(new WithoutOverlapping($this->invoiceId))->releaseAfter(60)];}

სამუშაოსთვის, რომელიც არასოდეს უნდა მოხდეს ერთდროულად ორჯერ, job კლასზე ShouldBeUnique ხელს უშლის მეორე ასლის queue-ში ჩადებას, სანამ პირველი ელოდება. ისიც cache lock-ს ეყრდნობა.

Dispatch transaction-ის შიგნით#

ეს ჩავარდნა მხოლოდ დატვირთვისას ჩნდება. Controller ხსნის ბაზის transaction-ს, ქმნის შეკვეთას, აგზავნის ProcessOrder-ს და რამდენიმე მილიწამში commit-ს აკეთებს. Valkey-ზე მყოფი worker job-ს მილიწამზე ნაკლებში იღებს, შეკვეთა 812-ს ეძებს და ვერაფერს პოულობს, რადგან commit ჯერ არ მომხდარა. Job ModelNotFoundException-ს აგდებს და ხელახლა სცდება; მეორე ცდაზე ჩანაწერი უკვე არსებობს და გადის. log-ში ჩავარდნილ ცდას ხედავ და ახსნას ვერა.

სამი გამოსწორებაა, უპირატესობის მიხედვით:

  1. config/queue.php-ში redis კავშირზე დააყენე 'after_commit' => true, ან REDIS_QUEUE_AFTER_COMMIT=true, თუ შენი კონფიგურაცია მას კითხულობს. მაშინ Laravel dispatch-ს აჩერებს გარემომცველი transaction-ის commit-მდე და ტოვებს, თუ ის rollback-ს გადიოდა.
  2. ერთ dispatch-ზე გამოიძახე ->afterCommit(): ProcessOrder::dispatch($order)->afterCommit();
  3. Job კლასზე განახორციელე ShouldQueueAfterCommit.

მთელი აპლიკაციისთვის პირველი ამჯობინე, რადგან ერთ dispatch-ზე მეორის დავიწყება არის გზა, რომლითაც წყვეტილი ბაგი ბრუნდება. ერთი გაფრთხილება: after_commit ჩართულია და dispatch transaction-ს გარეთ ჩვეულებრივად იქცევა, ანუ გავრცელებულ შემთხვევაში არაფერი ღირს.

Failed job-ები და failed_jobs ცხრილი#

როცა job tries-ს ამოწურავს, Laravel მას failed-jobs საცავში წერს. ეს საცავი ბაზის ცხრილია queue driver-ის მიუხედავად, ამიტომ redis queue-საც ჩავარდნებისთვის ბაზა სჭირდება. თანამედროვე ინსტალაციაში migration უკვე არსებობს; თუ არა:

bash
$ php artisan make:queue-failed-table$ php artisan migrate --force

config/queue.php-ის შესაბამისი ბლოკი ირჩევს driver-ს, ჩვეულებრივ database-uuids-ს, და ცხრილის სახელს:

config/queue.php
'failed' => [    'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),    'database' => env('DB_CONNECTION', 'mysql'),    'table' => 'failed_jobs',],

თითოეული ჩანაწერი შეიცავს კავშირს, queue-ს, სერიალიზებულ payload-ს, exception-ს stack trace-ით და ჩავარდნის დროს. ბრძანებები, რომლებსაც გამოიყენებ:

ბრძანებარას აკეთებს
php artisan queue:failedაჩვენებს failed job-ებს მათი id-ებით
php artisan queue:retry <id>ერთ failed job-ს უკან აბრუნებს მის queue-ში
php artisan queue:retry allყველა failed job-ს უკან აბრუნებს
php artisan queue:retry --queue=highერთი queue-ს ჩავარდნებს ხელახლა სცდის
php artisan queue:forget <id>შლის ერთ failed job-ს
php artisan queue:flushშლის ყველას
php artisan queue:prune-failed --hours=168შლის ერთ კვირაზე ძველ ჩავარდნებს

queue:retry all-მდე წაიკითხე, რა ჩავარდა. თუ ორმოცი job იმიტომ ჩავარდა, რომ მესამე მხარის API არ მუშაობდა, ხელახლა ცდა სწორია. თუ ორმოცი შენი კოდის ბაგის გამო ჩავარდა, ჯერ გამოსწორება გააკეთე deploy, თორემ ყველა ორმოცს ისევ ჩავარდნილს ნახავ, ახალი დროის ნიშნულებით ცხრილში დაბრუნებულს.

Prune ავტომატური არ არის. ცხრილი, რომელსაც არავინ ასუფთავებს, ყოველ ინციდენტთან იზრდება და სერიალიზებულ payload-ებს ინახავს, რომლებშიც პირადი მონაცემებიც შეიძლება იყოს. გაასუფთავება დაგეგმე, რომ ხელით დამახსოვრების გარეშე მოხდეს:

routes/console.php
use Illuminate\Support\Facades\Schedule;Schedule::command('queue:prune-failed --hours=168')->daily();Schedule::command('queue:prune-batches --hours=48')->daily();

თითოეულ job-ზე რეაგირებისთვის განსაზღვრე failed მეთოდი. ის ერთხელ ეშვება, ბოლო ცდის შემდეგ, და სწორედ აქ აცნობებ ვინმეს ან აუქმებ ნახევრად დასრულებულ მდგომარეობას:

php
public function failed(?Throwable $exception): void{    Log::error('Invoice send gave up', ['invoice' => $this->invoiceId, 'error' => $exception?->getMessage()]);}

მთელი აპლიკაციის დონეზე გაფრთხილებისთვის service provider-ში Queue::failing() ყოველ ჩავარდნაზე ყველა queue-ში ირთვება. გაფრთხილება სიხშირეზე დააყენე და არა ცალკეულ მოვლენაზე: ერთი ჩავარდნა retry-ია, რომელიც თავის საქმეს აკეთებს; საათში ოცდაათი ინციდენტია. php artisan queue:monitor redis:default,redis:high --max=100 QueueBusy მოვლენას ისვრის, როცა queue ზღვარს გადააჭარბებს, და ყოველ წუთში დაგეგმვით dashboard-ის გარეშე დაგროვების სიგნალს იღებ.

გაუშვი worker: მნიშვნელოვანი flag-ები#

პირველი განყოფილების ბრძანება სრულია, მაგრამ ყოველ flag-ს თავისი მიზეზი აქვს. გონივრული production ხაზი:

bash
$ php artisan queue:work redis --queue=high,default --tries=3 --backoff=10 \    --timeout=60 --max-time=3600 --max-jobs=1000 --memory=128 --sleep=3
Flagნაგულისხმევირატომ ადგენ
--queueკავშირის queueპრიორიტეტის თანმიმდევრობა; high default-მდე ცარიელდება
--max-time0 (არასოდეს)გასვლა N წამის შემდეგ, რომ მეხსიერების გაჟონვა შემოსაზღვრული იყოს
--max-jobs0 (არასოდეს)გასვლა N job-ის შემდეგ, იმავე მიზნით
--memory128გასვლა, როცა worker ამ MB-ს გადააჭარბებს
--sleep3რამდენ წამს ელოდება, როცა queue ცარიელია
--rest0პაუზა job-ებს შორის წამებში
--stop-when-emptyგამორთულიაგასვლა queue-ის დაცლისას; ტესტებისთვის გამოსადეგია

გასვლები განზრახულია. PHP worker პროცესია, რომელიც შენს აპლიკაციას ერთხელ ტვირთავს და შემდეგ job-ებს მის შიგნით უშვებს, ამიტომ პაკეტის ნებისმიერი გაჟონვა, ნებისმიერი static cache და container-ის service binding-ების ნებისმიერი ზრდა გროვდება. --max-time, --max-jobs და --memory ნელ გაჟონვას დროდადრო სუფთა გასვლად აქცევენ, ხოლო supervisor მას ახალი პროცესით გადატვირთავს. შედეგი გადამწყვეტია: worker, რომელიც განზრახ გადის, მხოლოდ მაშინ მუშაობს, თუ რაღაც მას ისევ გაუშვებს. ეს შემდეგი განყოფილებაა.

იმავე გრძელვადიანი ქცევის გამო deploy-ს worker-ები უნდა გადაატვირთვინოს. php artisan queue:restart cache-ში დროის ნიშნულს წერს; ყოველი worker მას job-ებს შორის ამოწმებს, მიმდინარე job-ს ამთავრებს და გადის. მას მუშა cache store სჭირდება, ხოლო production-ში CACHE_STORE=array ბრძანებას ჩუმად არაფრის კეთებისკენ მიჰყავს.

ერთ container-ში worker-ის ცოცხლად შენახვა#

ჩვეულებრივ Linux სერვერზე systemd-ს ან Supervisor-ს გამოიყენებდი. ერთ container-ში ერთი მთავარი პროცესი გაქვს და დანარჩენი ყველაფერი მის გვერდით ან ქვეშ უნდა ცხოვრობდეს. უზედამხედველო worker განსაკუთრებული გზით ვარდება: ის გადის --max-time-ის, პაკეტში exception-ის ან მეხსიერების გაჩერების გამო, web პროცესი გვერდებს აგრძელებს, job-ები კი Valkey-ში გროვდება, სანამ საიტი ჯანმრთელად გამოიყურება.

RE:NODE-ზე ყოველი სერვერი ერთი container-ია მკაცრი CPU და მეხსიერების ლიმიტით. მეხსიერების ლიმიტზე container ჩერდება და swap-ის ნაცვლად სუფთად ირთვება, ამიტომ worker, რომელიც იმაზე მეტად გაიზრდება, რაც დაუტოვე, მთელ სერვერს კლავს, web პროცესის ჩათვლით. პლატფორმა crash loop-საც აკვირდება: საათში სამი გადატვირთვა სერვერის გვერდზე გაფრთხილებას აჩენს და ავტომატურად ხსნის ticket-ს, ექვსი კი სერვერს აჩერებს. ამის გამო ქვემოთ მოცემული ზომის შერჩევა ღირს, და worker, რომელიც განზრახ გადის და თვითონ ირთვება, container-ის დამხობაზე უკეთესი ნიმუშია.

სამი პატიოსანი ვარიანტია, უმარტივესიდან ყველაზე შესაძლებლამდე.

ვარიანტი 1: worker-ისთვის მეორე სერვერი

რადგან queue Valkey-შია და არა container-ში, worker-ს web პროცესთან ერთ container-ში ყოფნა არ სჭირდება. იმავე repository-ს გაუშვი მეორე Laravel / PHP სერვერზე, რომლის ერთადერთი საქმე queue:work-ია, ბაზისა და Valkey-ის იმავე .env მნიშვნელობებით. მაშინ web ტრაფიკი და job-ების ტრაფიკი ერთმანეთს ვერ დაშიმშილებს და თითოეულს საკუთარი მეხსიერებისა და CPU-ს ლიმიტი აქვს. ეს ზედამხედველობის ყველაზე სუფთა სქემასაც იძლევა: პროცესი, რომელსაც სერვერი უშვებს, worker-ია და როცა ის გადის, პლატფორმა სერვერს ისევ რთავს. Laravel / PHP app plan თვეში $4-დან იწყება და Valkey სერვერს ორივე ქსელით უკავშირდება. ფასი მეორე .env-ის სინქრონში შენახვაა, ხოლო მეორე სერვერის start command web სერვერის ნაცვლად worker-ს უნდა უშვებდეს - Startup ტაბში ნახე, რის შეცვლას გაძლევს შენი plan-ი.

ვარიანტი 2: loop სკრიპტი web პროცესის გვერდით

როცა ერთ სერვერს ორივეს გაკეთება უწევს, პატარა shell loop worker-ს ყოველ გასვლაზე გადატვირთავს და გაჩერების სიგნალს ამუშავებს, რომ გადატვირთვამ job შუაში არ გადაჭრას.

bash
#!/bin/sh# worker-loop.sh - restart queue:work whenever it exits, stop cleanly on SIGTERMstop=0trap 'stop=1; kill -TERM "$pid" 2>/dev/null' TERM INTwhile [ "$stop" -eq 0 ]; do  php artisan queue:work redis --queue=high,default --tries=3 --backoff=10 \    --max-time=3600 --memory=128 &  pid=$!  wait "$pid"  [ "$stop" -eq 0 ] && sleep 3donewait "$pid"

პირველი wait ბრუნდება, როცა worker ნებისმიერი მიზეზით გადის, ხოლო sleep 3 ხელს უშლის მაშინვე ჩავარდნილ worker-ს CPU-ს დატვირთვაში. როცა გაჩერების სიგნალი მოდის, trap SIGTERM-ს worker-ს გადასცემს და ბოლო wait მიმდინარე job-ის დასრულების საშუალებას აძლევს. Laravel-ის worker SIGTERM-ს ამუშავებს ისე, რომ ჯერ მიმდინარე job-ს ამთავრებს და მერე გადის. სკრიპტი web პროცესის გვერდით უნდა გაეშვას, რაც ნიშნავს, რომ შენმა start command-მა ორივე უნდა გაუშვას; სადაც მის შეცვლას ვერ ახერხებ, ეს ვარიანტი მიუწვდომელია და პასუხი ვარიანტი 1-ია.

ვარიანტი 3: Supervisor container-ის შიგნით

თუ container-ის start command-ს supervisord foreground-ში შეუძლია გაუშვას, ერთი კონფიგურაციის ფაილი ყველა პროცესს უშვებს და გადატვირთავს. ეს Laravel-ის production-ის სტანდარტული გამართვაა და container-შიც ისევე მუშაობს, როგორც სერვერზე.

supervisord.conf
[supervisord]nodaemon=true[program:laravel-worker]process_name=%(program_name)s_%(process_num)02dcommand=php /app/artisan queue:work redis --queue=high,default --tries=3 --max-time=3600autostart=trueautorestart=truestopasgroup=truekillasgroup=truenumprocs=2redirect_stderr=truestdout_logfile=/app/storage/logs/worker.logstopwaitsecs=3600

autorestart=true worker-ს ნებისმიერ გასვლაზე გადატვირთავს, --max-time-ის განზრახ გასვლის ჩათვლით. stopwaitsecs შენს ყველაზე გრძელ job-ზე დიდი უნდა იყოს, თორემ Supervisor გაჩერებისას მიმდინარე job-ს კლავს. numprocs=2 ორ worker-ს უშვებს; მეტი უკეთესი არ არის, როგორც შემდეგი განყოფილება აჩვენებს. თვით Supervisor-ი image-ში ან შენი პაკეტების მენეჯერით უნდა დაყენდეს და ის პროცესს ამატებს, რომლის ცოცხლად შენახვაც ასევე შენ გევალება - ამიტომ ვარიანტები 1 და 2 პატარა plan-ზე ხშირად უფრო მარტივია.

თუ შენი queue-ის სამუშაო მნიშვნელოვანია, Laravel Horizon worker-ის ბრძანებას php artisan horizon-ით ცვლის. ის საკუთარ worker პროცესებს თვითონ აკონტროლებს, queue-ებს შორის ანაწილებს და dashboard-ს ამატებს, ამიტომ რამდენიმეს ნაცვლად ერთ პროცესს უშვებ. მას redis driver-ი სჭირდება, ამიტომ მხოლოდ მას შემდეგ გამოიყენება, რაც Valkey-ზე ხარ. deploy სკრიპტში გაუშვი php artisan horizon:terminate, იმავე ადგილას, სადაც queue:restart.

Graceful shutdown and health checks განმარტავს, რატომ არის გაჩერების სიგნალი ყველა პლატფორმაზე მნიშვნელოვანი, ხოლო PM2 process manager guide იგივე ზედამხედველობის პრობლემას Node-ისთვის განიხილავს.

Worker-ების ზომის შერჩევა პატარა container-ში#

PHP worker შენი framework-ის სრულ ასლს ინახავს. შიშველი Laravel worker ჩვეულებრივ 40-დან 80 MB-მდეა, მძიმე პაკეტიანი ან მეხსიერებაში დიდი collection-იანი worker რამდენჯერმე მეტი, ხოლო რეალური რიცხვი ის არის, რასაც ps შენს აპლიკაციაზე გეტყვის. ცხრილის ნდობის ნაცვლად გაზომე:

bash
$ ps -o pid,rss,cmd -C php | sort -k2 -n -r | head

rss კილობაიტებშია. აიღე ყველაზე დიდი worker, გაამრავლე worker-ების რაოდენობაზე, დაამატე web პროცესი (FrankenPHP ან PHP-FPM worker-ებიც თითოეული საკუთარ ასლს ინახავს) და ჯამი plan-ის მეხსიერების ლიმიტს შეადარე. 1 GB plan-ში თავისუფლად ეტევა web პროცესი და ორი worker ჩვეულებრივი job-ებისთვის. ოთხი worker, რომელთაგან თითოეული 150 MB-ზე აღწევს პიკს, 600 MB-ია მანამ, სანამ web პროცესს ერთი მოთხოვნა მოემსახურება.

--memory დააყენე იმაზე დაბლა, რისი ტარებაც შეგიძლია, და არა მაღლა. container-ის ლიმიტზე მთელი სერვერი ირთვება, მიმდინარე web მოთხოვნები და ყველა მომუშავე job იკარგება. worker-ის საკუთარ --memory ლიმიტზე მხოლოდ ის worker გადის და ირთვება, Laravel კი ჯერ მიმდინარე job-ს ამთავრებს.

CPU მეორე ლიმიტია. container-ის CPU მკაცრი შეზღუდვაა იმ წილზე, რომელიც იყიდე, ამიტომ ნახევარ ბირთვიანი plan, რომელზეც ორი CPU-ხარჯიანი worker მუშაობს, მეორესთან უფრო სწრაფი არ ხდება; იგივე CPU ორად იყოფა. სურათების ზომის შეცვლის queue-ს worker-ები გინდა იმდენი, რამდენი ბირთვიც გაქვს. API გამოძახებებისა და ელფოსტის queue ქსელს ელოდება და არა CPU-ს, და ერთ პროცესს მათი რამდენიმე სასარგებლოდ შეუძლია დაიტიოს - მაგრამ მაშინ PHP-ში კონკურენტულობისთვის მეტი worker პროცესია გზა, რადგან worker ერთ დროს ერთ job-ს ასრულებს.

Valkey-ს მხარის ცალკე სერვერზე გატანა იმასაც ნიშნავს, რომ მისი მეხსიერება შენი დასახარჯი არ არის. Valkey plan ცალკე სერვერია, რომელსაც საკუთარი host-ით და port-ით უკავშირდები, 256 MB-დან, პაროლით დაცული და დისკზე შენახული AOF-ითაც და snapshot-ებითაც, ამიტომ მომლოდინე job-ები ორივე მხარის გადატვირთვას უძლებს. პარამეტრები, რომლებიც განსაზღვრავს, დაკარგავს თუ არა queue job-ებს, ყველაზე მეტად კი maxmemory-policy noeviction, აღწერილია პოსტში Valkey memory and eviction policies, persistence-ის რეჟიმები კი პოსტში Valkey persistence: AOF and RDB. შენი შეამოწმე ბრძანებით valkey-cli ... CONFIG GET maxmemory-policy და თუ ის noeviction არ არის, შეცვალე ან სთხოვე ვინც სერვერს უძღვება.

Deploy-ები და queue#

Deploy queue-ს ორი გზით ეხება: ახალი კოდი worker-ებამდე უნდა მივიდეს, და Valkey-ში უკვე მყოფი job-ები ახალ კოდთან მიმართებით ისევ უნდა დესერიალიზდეს. მეორეს ყველაზე ხშირად ივიწყებენ.

queue-ში ჩადებული job თავისი კლასის სახელს და თვისებებს ინახავს. თუ კლასს გადაარქმევ ან თვისებას წაშლი, სანამ მისი job-ები ელოდება, worker მათ ვეღარ დესერიალიზებს და ისინი failed_jobs-ში ხვდება. უსაფრთხო თანმიმდევრობაა ჯერ დამატებითი ცვლილებების deploy: დაამატე ახალი კლასი ან თვისება, დაელოდე queue-ს ძველი job-ებისგან დაცლას და ძველი მხოლოდ მომდევნო release-ში წაშალე. თუ აუცილებლად გადარქმევა გჭირდება, ძველი კლასი ერთი-ორი deploy-ის განმავლობაში თხელ subclass-ად შეინახე.

RE:NODE-ზე deploy-ის სქემა პირველ პრობლემაში გეხმარება. GitHub deploy on push-ით push მომუშავე სერვერს გადატვირთავს (და გაჩერებულს ჩართავს), ამიტომ worker-ები ახალ კოდზე ისევ იწყებენ; გადამრთველებისთვის იხილე deploy გზამკვლევი. გადატვირთვა მაინც პაუზა არ არის: სუფთა გაჩერება მომუშავე job-ს ამთავრებინებს, kill - არა, და ასე შეწყვეტილი job queue-ში retry_after წამის შემდეგ ბრუნდება. ეს ისევ at-least-once ქცევაა და ამიტომ უნდა იყოს job-ები განმეორებისთვის უსაფრთხო. მაინც შეინახე php artisan queue:restart შენს deploy სკრიპტში; არაფერი ღირს და იმ host-ებსაც ფარავს, სადაც deploy პროცესს არ ერთმევს. deploy თანმიმდევრობის დანარჩენი ნაწილი Deploy Laravel to production-შია.

პრობლემების გადაჭრა#

Job-ები push-დება და არაფერი ხდება. Worker არ მუშაობს, ან სხვა queue-ს ან prefix-ს უკავშირდება. გაუშვი php artisan queue:work redis --once ხელით და უყურე გამოსავალს. შემდეგ შეადარე QUEUE_CONNECTION და REDIS_PREFIX web პროცესზე და worker-ზე; შეუსაბამობა კოდიდან უხილავია.

`Connection refused` ან `NOAUTH Authentication required`. პირველი host-ის ან port-ის ან firewall-ის საქმეა; მეორე - გამოტოვებული REDIS_PASSWORD. გაითვალისწინე, რომ cache-ირებული კონფიგურაცია შესწორებულ .env-ს უგულებელყოფს: გაუშვი php artisan config:clear.

Job ორჯერ გადის. retry_after job-ის შესრულების დროზე მოკლეა. გაზარდე ის job-ის $timeout-ზე მაღლა, როგორც ზემოთ იყო აღწერილი, ან job გადაიტანე უფრო გრძელი ლიმიტის მქონე queue-ში.

`ModelNotFoundException` მხოლოდ ზოგიერთ job-ზე. Job push-და მისი transaction-ის commit-მდე. კავშირზე after_commit დააყენე true-ზე.

Worker deploy-ის შემდეგ ძველ კოდს უშვებს. ის არ გადაიტვირთა. გაუშვი php artisan queue:restart და დარწმუნდი, რომ cache store, რომელსაც ის ეყრდნობა, მუშაობს.

მეხსიერება იზრდება, სანამ container არ გადაიტვირთება. გაჟონვა პაკეტში ან შეუზღუდავი collection. დაამატე --max-jobs და --memory და ps-ით შეამოწმე რეალური ზომა. დიდი შედეგების სიმრავლე ჩატვირთვის ნაცვლად lazy()-ით ან cursor()-ით დაამუშავე.

`MaxAttemptsExceededException` და job-ს თავისი failure კოდი არ გაუშვია. Worker-ი ხელახლა მოკლეს ან timeout-მა დასცა და ცდები ამოიწურა. შეამოწმე, რომ $timeout retry_after-ზე მოკლეა და რომ worker-ს container-ის მეხსიერების ლიმიტი არ კლავს.

`OOM command not allowed when used memory > 'maxmemory'`. Valkey სავსეა და noeviction ჩაწერებს უარყოფს. Worker-ები გათიშულია, ძალიან ნელია, ან queues:*:reserved გაჭედილი job-ებითაა სავსე. მიზეზი გამოასწორე; შეცდომის გასაქრობად eviction policy-ს ნუ შეცვლი.

FAQ#

მჭირდება თუ არა Horizon Laravel queue-ების Valkey-ზე გასაშვებად?

არა. php artisan queue:work საკმარისია და უბრალო worker-ები პატარა container-ში უფრო ადვილი სამართავია. Horizon-ის დამატება ღირს, როცა dashboard, queue-ზე დაფუძნებული დაბალანსება და ისეთი მეტრიკები გინდა, როგორიცაა ლოდინის დრო და throughput, მათი თვითონ წერის გარეშე.

შემიძლია failed jobs ცხრილიც Valkey-ში შევინახო?

მხარდაჭერილი გზით - არა. Failed job-ები config/queue.php-ის failed ბლოკით ინახება, რომლის driver-ებია database, database-uuids, dynamodb და file. შეინახე ისინი შენს ჩვეულებრივ ბაზაში, სადაც query-ს და prune-ს გააკეთებ.

რამდენი worker გავუშვა ერთ container-ში?

დაიწყე ერთით ან ორით და გაზომე. თითოეული worker ერთ დროს ერთ job-ს ასრულებს, ამიტომ I/O-ზე დამოკიდებული სამუშაოსთვის მეორე worker throughput-ს გაორმაგებს, მაგრამ მეხსიერება და CPU web პროცესთან საზიაროა და რაოდენობასთან ერთად არ იზრდება. Worker მხოლოდ მაშინ დაამატე, როცა ყველაზე ძველი მომლოდინე job იმაზე ძველია, რასაც ითმენ.

რატომ ბრუნდება queue:retry all-ით იგივე job-ები ისევ failed-ად?

რადგან მიზეზი ისევ არსებობს. Retry იმავე payload-ს მიმდინარე კოდსა და გარემოზე უშვებს. ჯერ ბაგი გამოასწორე ან დამოკიდებულება აღადგინე, მერე სცადე ხელახლა; სანამ რამეს უკან დააბრუნებ, წაიკითხე exception queue:failed-ში ან exception სვეტში.

უსაფრთხოა თუ არა ერთი Valkey-ს გაზიარება queue-ს, cache-სა და session-ებს შორის?

მუშაობს, მაგრამ ფორმა არასწორია. Cache ძველ გასაღებებს უნდა გამოდევნიდეს, queue კი არცერთს არ უნდა კარგავდეს. queue-ს საკუთარი Valkey მიეცი, ან მინიმუმ noeviction-ით იმუშავე და მიიღე, რომ სავსე cache მაშინ ჩაწერებს უარყოფს. Sessions in Valkey იმავე კომპრომისს session-ების მხრიდან განიხილავს.

რა ემართება მომლოდინე job-ებს container-ის გადატვირთვისას?

მომლოდინე job-ები Valkey-შია და არა container-ში, ამიტომ გადარჩებიან. Job-ები, რომლებიც worker-მა დაჯავშნა, retry_after წამის შემდეგ queue-ში ბრუნდება და ისევ გადის, ამიტომ job განმეორებისთვის უსაფრთხო უნდა იყოს. თუ თვით Valkey გადაიტვირთება, დანარჩენს დისკზე შენახვა იცავს.

გამოგადგათ?