Praca w tle jako architektura: Joby Laravel i eventual consistency

7 min. czytania

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
<?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
<?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
<?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
<?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.

php
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ą.

php
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.

php
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.

php
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.

php
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.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.