Kolejka służy nie tylko do szybszej odpowiedzi HTTP. Tworzy granicę asynchroniczną: użytkownik widzi komunikat, że faktura jest przygotowywana, choć PDF, eksport księgowy i e-mail jeszcze nie istnieją. Worker może zginąć po wywołaniu dostawcy, ale przed zapisem odpowiedzi. Deploy może podmienić kod, gdy zarezerwowany payload nadal odnosi się do starej klasy. To normalne stany pracy systemu, a nie rzadkie błędy brzegowe.
Dobry job ma więc kontrakt: trwałe dane wejściowe, definicję sukcesu, zasady powtarzania efektów ubocznych, deadline retry i ścieżkę trwałej porażki.
Kolejka gwarantuje co najmniej raz
Drivery kolejek zapewniają dostarczenie co najmniej raz. Po utracie workera job może wrócić do kolejki. „Dokładnie raz” nie jest ustawieniem Laravel, bo awaria sieci zawsze może pozostawić nieznany rezultat. Zamiast tego używaj stabilnego klucza biznesowego, unikalnego ograniczenia bazy oraz klucza idempotencji dla zewnętrznych API.
Przez kolejkę przekazuj skalarne identyfikatory, a bieżący stan ładuj w
handle(). Nie serializuj requestu, zalogowanego użytkownika ani dużego grafu
Eloquent: request już nie istnieje, relacje mogą być stare, a zależność od bazy
staje się ukryta.
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Order;
use App\Services\InvoiceGenerator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class GenerateInvoice implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 120;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [10, 60, 300];
public function __construct(public readonly int $orderId)
{
}
public function handle(InvoiceGenerator $invoiceGenerator): void
{
$order = Order::query()->findOrFail($this->orderId);
if (! $order->is_paid || $order->invoice_path !== null) {
return;
}
$invoiceGenerator->generateFor($order);
}
}
Guard pomaga, lecz nie jest gwarancją. Dwa workery mogą równocześnie odczytać
invoice_path jako null. Prawdziwy invariant umieść przy zapisie: unikalny
wiersz faktury po order_id, unikalny numer albo atomowa aktualizacja warunkowa.
Job ogranicza okazję do duplikacji; baza rozstrzyga, czy dwa rekordy istnieją.
Zatwierdź przyczynę przed wysłaniem jej konsekwencji
W przeciwnym wypadku worker może wygrać wyścig z transakcją, która utworzyła
jego dane wejściowe. Szuka niezatwierdzonego zamówienia, rzuca
ModelNotFoundException i marnuje próbę. Dispatchuj po commicie, gdy job zależy
od wiersza zapisanego w bieżącej transakcji.
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Jobs\GenerateInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
final class MarkOrderPaid
{
public function execute(Order $order): void
{
DB::transaction(function () use ($order): void {
$order->update(['paid_at' => now()]);
GenerateInvoice::dispatch($order->getKey())->afterCommit();
});
}
}
afterCommit() porządkuje dispatch po udanym commicie; nie zastępuje transakcji
ani nie zamraża stanu biznesowego. Job nadal musi obsłużyć późniejsze anulowanie.
To jest eventual consistency: system przechodzi przez widoczne stany zamiast
udawać, że każdy podsystem zmienił się atomowo.
Idempotencja wykracza poza bazę danych
Twoja baza może odrzucić zduplikowany klucz. API księgowe pozostaje poza tą transakcją. Nadaj mu klucz idempotencji pochodzący od operacji biznesowej (nie numeru próby), aby pierwsza i trzecia próba oznaczały to samo.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Order;
use Illuminate\Http\Client\Factory as HttpFactory;
final class AccountingExporter
{
public function __construct(private readonly HttpFactory $http)
{
}
public function export(Order $order): void
{
$this->http->acceptJson()
->timeout(15)
->connectTimeout(5)
->withHeaders([
'Idempotency-Key' => "order:{$order->getKey()}:accounting-export",
])
->post(config('services.accounting.url').'/invoices', [
'order_id' => $order->getKey(),
'total_cents' => $order->total_cents,
])
->throw();
}
}
Gdy dostawca nie potrafi deduplikować, zapisz rekord outboxa z unikalnym kluczem biznesowym przed dispatchowaniem i oznacz go jako wysłany dopiero po potwierdzonej odpowiedzi. Jeśli odpowiedź zaginie po udanym żądaniu, nieznany rezultat ma trwały zapis do uzgodnienia, zamiast stać się niewidoczną duplikacją.
Retry powinno wynikać z terminu biznesowego
Retry nie może bombardować awarii ani trwać, gdy dalsza próba przestaje być
użyteczna. Ustaw retry_after połączenia kolejkowego wyraźnie powyżej timeout
joba; w przeciwnym razie drugi worker może dostać ten sam job, gdy pierwszy
wciąż pracuje. Dla uzgadniania płatności, które rozsądnie może czekać do końca
dnia roboczego, użyj deadline'u zamiast arbitralnej liczby prób.
<?php
declare(strict_types=1);
namespace App\Jobs;
use DateTimeInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
final class ReconcilePayment implements ShouldQueue
{
use Dispatchable;
use Queueable;
public int $tries = 0;
/** @var array<int, int> */
public array $backoff = [60, 300, 900, 1800];
public function __construct(public readonly string $providerPaymentId)
{
}
public function retryUntil(): DateTimeInterface
{
return now()->endOfDay();
}
}
Błędy walidacji, usunięte zamówienia i odpowiedź API „nieprawidłowe konto” nie
są przejściowe. Ponawianie jedynie odracza decyzję. W failed() zapisz stan,
na którym można działać, aby operator widział obiekt biznesowy, a nie tylko
nieudany payload kolejki.
public function failed(\Throwable $exception): void
{
Order::query()->whereKey($this->orderId)->update([
'invoice_status' => 'failed',
]);
report($exception);
}
Unikalny dispatch nie jest invariantem danych
ShouldBeUnique zapobiega powtórnemu dispatchowi dla jednego uniqueId(),
gdy istnieje jego blokada. Pasuje do jednego synchronizowania CRM na klienta.
ShouldBeUniqueUntilProcessing zwalnia blokadę wraz z rozpoczęciem obsługi,
co ma sens tylko wtedy, gdy nowa synchronizacja może czekać za obecną.
final class SyncCustomerFromCrm implements ShouldBeUnique, ShouldQueue
{
use Dispatchable;
use Queueable;
public int $uniqueFor = 900;
public function __construct(public readonly int $customerId)
{
}
public function uniqueId(): string
{
return (string) $this->customerId;
}
}
Nie ochroni to tej samej operacji wywołanej przez komendę, webhook i stary job.
Dla krótkiej sekcji krytycznej zastosuj jawny Cache::lock(), ale zachowaj
invariant bazy danych, ponieważ blokada może wygasnąć podczas wolnej pracy.
Blokady serializują pracę, więc są słabym domyślnym wyborem dla szerokich importów
albo chorego upstreamu; wykorzystuj je do rzadkiej koordynacji per encja.
Cache::lock("invoice:{$order->getKey()}", 180)->block(5, function () use ($order): void {
app(InvoiceGenerator::class)->generateFor($order);
});
Grupuj niezależną pracę, łańcuchuj zależności
Fragmenty CSV mogą biec niezależnie, dlatego zamodeluj je jako batch i pokaż postęp batcha. Generowanie faktury, eksport księgowy i powiadomienie mają ścisłą kolejność, dlatego zamodeluj je jako chain.
Bus::batch($chunkPaths->map(fn (string $path) => new ImportCustomersChunk($path))->all())
->name('September customer import')
->then(fn () => FinalizeCustomerImport::dispatch($importId))
->catch(fn ($batch, \Throwable $exception) => MarkImportFailed::dispatch($importId))
->dispatch();
Bus::chain([
new GenerateInvoice($orderId),
new ExportInvoiceToAccounting($orderId),
new SendInvoiceReadyNotification($orderId),
])->dispatch();
Żadne z nich nie jest transakcją rozproszoną. Chain może pomyślnie wyeksportować
fakturę i zatrzymać się przed powiadomieniem. Zapisuj obserwowalne kamienie
milowe, takie jak exported_at i notification_sent_at, a następnie pozwól
komendzie naprawczej bezpiecznie wznowić brakujący krok, zamiast ślepo uruchamiać
wszystko od nowa.
Workery są wdrażanym kodem aplikacji
Workery są długotrwałymi procesami i nie wczytają automatycznie nowego PHP.
Gdy zgodny kod i schemat są już dostępne, użyj php artisan queue:restart,
aby standardowe workery zakończyły działanie po obecnym jobie; podczas wdrożenia
Horizon zakończ Horizon, aby jego manager wymienił workery. Zachowaj zgodność
starych, zarezerwowanych payloadów z wydaniem: dodanie opcjonalnego pola jest
bezpieczniejsze niż usunięcie klasy joba albo zmiana nazwy serializowanej
właściwości.
Horizon świetnie rozdziela kolejki high, default i low, obserwuje czas
oczekiwania, błędy, pamięć i balans procesów. Nie potrafi jednak ustalić, czy
zewnętrzne wywołanie powiodło się tuż przed crashem.
Testuj obserwowalne bezpieczeństwo
Testuj, że action dispatchuje job, że ukończona praca jest no-opem oraz że
zewnętrzne żądanie otrzymuje stabilny klucz. Bus::fake() pasuje na granicy
dispatchu; bezpośredni test joba z mockowanym generatorem weryfikuje gałąź
idempotentną. Dodaj test integracyjny z prawdziwym driverem kolejki, gdy sama
serializacja ma znaczenie.
it('does not generate an invoice twice', function (): void {
$order = Order::factory()->create([
'paid_at' => now(),
'invoice_path' => 'invoices/123.pdf',
]);
$generator = Mockery::mock(\App\Services\InvoiceGenerator::class);
$generator->shouldNotReceive('generateFor');
(new GenerateInvoice($order->getKey()))->handle($generator);
});
Nie kolejkuj małego, krytycznego dla użytkownika zapisu tylko dlatego, że kolejki
wyglądają architektonicznie: checkout powinien rezerwować stan magazynowy
synchronicznie. Nie kolejkuj pracy, która musi być widoczna w tej samej odpowiedzi
HTTP. Dla krótkiej, niekrytycznej pracy po odpowiedzi na jednym serwerze
defer() może być czytelniejsze. Kolejka jest właściwą granicą, gdy praca jest
wolna, niezależnie ponawialna, a jej opóźnienie jest bezpieczne i widoczne dla
użytkownika.