---
title: 'Laravel-Queues in Produktion: Timeouts und failed_jobs | DevSense'
description: 'Wie Sie Laravel-Queues für die Produktion konfigurieren: retry_after und --timeout, Retries und Backoff, failed_jobs, Speicher langlebiger Worker, schwere Importe per Batch, Unique Jobs, Supervisor und Horizon.'
faq:
    - { question: 'Worin unterscheidet sich retry_after von --timeout in Laravel-Queues?', answer: 'retry_after wird in config/queue.php gesetzt und teilt der Connection mit, nach wie vielen Sekunden ein reservierter Job als hängend gilt und erneut ausgeliefert wird. --timeout (oder das Attribut #[Timeout] am Job) begrenzt, wie viele Sekunden der Worker einem Job zur Ausführung gibt, bevor er sich mit einem Fehler beendet. Der Timeout muss einige Sekunden kleiner als retry_after sein, sonst kann ein Job doppelt ausgeführt werden.' }
    - { question: 'Warum wurde ein Queue-Job zweimal ausgeführt?', answer: 'Meistens, weil der Job länger als retry_after lief und die Connection ihn an einen zweiten Worker ausgeliefert hat, oder weil der Worker mitten in der Ausführung beendet wurde (Deployment, OOM, SIGKILL) und der Job in die Queue zurückkehrte. Deshalb müssen Jobs idempotent und die Timeouts aufeinander abgestimmt sein: HTTP-Client < Job-Timeout < retry_after.' }
    - { question: 'Warum wächst der Speicher eines Queue-Workers?', answer: 'Der Worker queue:work ist ein langlebiger Prozess, der das Framework zwischen den Jobs nicht neu lädt. Speicher sammeln statische Arrays und zustandsbehaftete Singletons, das Query-Log, Telescope, GD-Bilder und große Collections. Begrenzen Sie die Lebensdauer des Workers mit den Flags --max-jobs, --max-time und --memory und betreiben Sie ihn unter Supervisor, der den Prozess neu startet.' }
    - { question: 'Was tun mit den Einträgen in der Tabelle failed_jobs?', answer: 'Nicht blind neu starten. Zuerst die Exception ansehen (queue:failed, Horizon), die Ursache beheben und die Jobs dann mit queue:retry wiederholen. Für Benachrichtigungen die Methode failed() am Job oder das Event Queue::failing nutzen und alte Einträge regelmäßig per Scheduler mit queue:prune-failed löschen.' }
published: '2026-10-03'
---
# Laravel-Queues in Produktion: Timeouts, failed_jobs, Speicher und schwere Jobs

Lokal „funktionieren“ Queues einfach: `queue:work` im Nachbarterminal, Jobs laufen in einer Sekunde durch, keine Fehler. In Produktion beginnen Geschichten eines anderen Genres. Ein Kunde hat dieselbe Rechnungs-E-Mail zweimal bekommen. Der Import einer Preisliste mit 400 000 Zeilen läuft seit drei Stunden und hat zwei Gigabyte Speicher gefressen. In `failed_jobs` haben sich zwanzigtausend Einträge angesammelt, und niemand weiß, welche davon wichtig sind. Nach einem Deployment führen die Worker eine Woche lang Jobs mit altem Code aus.

In diesem Artikel geht es darum, Laravel-Queues so zu konfigurieren, dass sie Ausfälle, Deployments und große Datenmengen überstehen: wie `retry_after` und `--timeout` zusammenhängen, wie Retries funktionieren, was mit `failed_jobs` zu tun ist, warum der Speicher der Worker wächst und wie man schwere Importe aufteilt. Den lokalen Betrieb von Queues in Docker behandelt der Artikel [Sail: Queues und Worker](../tools/sail-queues), die Wahl des Brokers der [Vergleich von Message Queues](message-queues-compared).

**Verwandte Leitfäden:** [Ausfallsichere Integrationen](resilient-external-integrations) · [Zero-Downtime-Deployment](zero-downtime-deployment-laravel) · [Eloquent und große Datenmengen](eloquent-large-datasets) · [Observability und Monitoring](observability-monitoring-laravel)

## Inhalt

* [Lebenszyklus eines Jobs: Versuche, Rückgabe, Fehlschlag](#lifecycle)
* [retry_after und --timeout: die Timeout-Treppe](#timeouts)
* [Retries: tries, backoff, retryUntil](#retries)
* [failed_jobs: analysieren, wiederholen, aufräumen](#failed-jobs)
* [Speicher langlebiger Worker](#memory)
* [Schwere Importe und Exporte](#heavy-jobs)
* [Duplikate und Race Conditions: Unique Jobs und after_commit](#uniqueness)
* [Getrennte Queues und Prioritäten](#priorities)
* [Supervisor und Horizon](#supervisor)
* [Queue-Monitoring](#monitoring)
* [Häufige Fehler](#common-mistakes)
* [Checkliste](#checklist)
* [Quiz zur Selbstkontrolle](#self-test-quiz)

---

<a id="lifecycle"></a>
## Lebenszyklus eines Jobs: Versuche, Rückgabe, Fehlschlag

Sobald ein Worker einen Job übernimmt, beginnt ein **Versuch** (Attempt). Der Versuch wird verbraucht, auch wenn die Methode `handle()` nicht bis zum Ende gelaufen ist. Laut Laravel-Dokumentation „verbrauchen“ einen Versuch:

* eine nicht behandelte Exception im Job;
* eine manuelle Rückgabe in die Queue per `$this->release()`;
* Middleware wie `WithoutOverlapping` oder `RateLimited`, die keinen Lock bekommen und den Job zurückgegeben hat;
* eine Überschreitung des Timeouts;
* die erfolgreiche Ausführung von `handle()`.

**Standardmäßig unternimmt Laravel einen einzigen Versuch.** Schlägt der Job fehl, gilt er sofort als gescheitert und landet in `failed_jobs`. Wenn Sie `RateLimited` oder `WithoutOverlapping` verwenden, reicht ein Versuch fast sicher nicht: Der Job „verbrennt“ schon bei der ersten Rückgabe, ohne überhaupt mit der Arbeit begonnen zu haben.

```
dispatch → [queue] → worker reserves → handle()
                          │                 │
                          │         ┌───────┴────────┐
                          │      success      exception / timeout / release
                          │         │                │
                          │      delete      attempts < tries? ──yes──→ back to queue (after backoff)
                          │                          │
                          │                          no
                          │                          ▼
                          └── worker died ──→   failed() + failed_jobs
                              (job reappears after retry_after)
```

---

<a id="timeouts"></a>
## retry_after und --timeout: die Timeout-Treppe

Das sind zwei verschiedene Mechanismen, die man leicht verwechselt.

* **`retry_after`** ist ein Parameter der Connection in `config/queue.php`. Er beantwortet die Frage, „nach wie vielen Sekunden ein reservierter Job als aufgegeben gilt und an einen anderen Worker ausgeliefert wird“. Der Broker weiß nicht, ob der Worker noch lebt, und orientiert sich deshalb nur an der Zeit. Bei Amazon SQS übernimmt diese Rolle der Visibility Timeout in den Queue-Einstellungen.
* **`--timeout`** bei `queue:work` (oder das Attribut `#[Timeout]` am Job) gibt an, wie viele Sekunden der Worker einem Job zur Ausführung gibt. Standardmäßig sind es 60 Sekunden. Ist die Zeit abgelaufen, beendet sich der Worker-Prozess mit einem Fehler, und Supervisor startet einen neuen. Für Timeouts wird die Extension **pcntl** benötigt.

Ist `--timeout` größer als `retry_after`, wird ein Job, der länger als `retry_after` läuft, **an einen zweiten Worker ausgeliefert, während der erste ihn noch ausführt**. Daher kommen doppelte E-Mails, doppelte Abbuchungen und Race Conditions. Die Dokumentation formuliert die Regel unmissverständlich: `--timeout` muss mindestens einige Sekunden kürzer sein als `retry_after`.

In der Praxis muss eine ganze Treppe von Werten abgestimmt werden – jeder Wert größer als der vorherige:

| Ebene | Wo gesetzt | Beispiel |
|---------|--------------|--------|
| Timeouts von HTTP-Client und SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 s |
| Job-Timeout | `#[Timeout(120)]` oder `--timeout=120` | 120 s |
| `retry_after` der Connection | `config/queue.php` | 150 s |
| Wartezeit beim Stoppen des Workers | `stopwaitsecs` in Supervisor, `stop_grace_period` in Docker | 180 s |

```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', 150),
    'block_for' => 5,
    'after_commit' => true,
],
```

```php
// app/Jobs/SyncSupplierPrices.php
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(120)]
final class SyncSupplierPrices implements ShouldQueue
{
    use Queueable;

    public function handle(SupplierClient $client): void
    {
        // The HTTP client has its own, shorter timeout: blocking IO
        // (sockets, HTTP) may not be interrupted by the job timeout.
        $client->withTimeout(seconds: 20)->syncPrices();
    }
}
```

> [!WARNING]
> **Der Job-Timeout unterbricht keinen hängenden Socket.** Die Dokumentation warnt ausdrücklich: Blockierendes I/O (Sockets, ausgehende HTTP-Verbindungen) reagiert unter Umständen nicht auf den Worker-Timeout. Setzen Sie für den HTTP-Client und für Datenbankabfragen immer eigene Timeouts.

`block_for` teilt dem Redis-Treiber mit, wie viele Sekunden er im blockierenden Modus auf einen neuen Job warten soll. Der Wert `0` blockiert den Worker unbegrenzt – er verarbeitet dann Signale wie `SIGTERM` erst, wenn der nächste Job eintrifft, was das saubere Herunterfahren beim Deployment kaputt macht.

Darf ein Job nach einem Timeout nicht wiederholt werden (etwa weil er bereits Daten an ein externes System gesendet haben könnte), markieren Sie ihn mit dem Attribut `#[FailOnTimeout]`: Dann gilt der Job beim Timeout sofort als gescheitert, ohne Retries.

---

<a id="retries"></a>
## Retries: tries, backoff, retryUntil

In Laravel 13 lassen sich die Retry-Parameter bequem per PHP-Attribut direkt an der Job-Klasse setzen (in älteren Versionen über die Properties `$tries`, `$timeout`, `$backoff`):

```php
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;

#[Tries(10)]
#[MaxExceptions(3)]
#[Backoff([10, 60, 300])]
final class PushOrderToCrm implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        // Releases caused by rate limiting consume attempts, hence Tries(10),
        // while MaxExceptions(3) fails the job after three real errors.
        return [new RateLimited('crm')];
    }

    public function handle(CrmClient $crm, OrderSummaryQuery $orders): void
    {
        $crm->upsertOrder($orders->summary($this->orderId));
    }
}
```

* **`Tries`** – die maximale Anzahl an Versuchen. Der Wert an der Klasse hat Vorrang vor dem Flag `--tries` des Workers.
* **`MaxExceptions`** – nach wie vielen echten Exceptions der Job scheitert, auch wenn noch Versuche übrig sind. So kann man viele Rückgaben wegen Rate Limit erlauben, ohne eine kaputte API zehnmal zu bombardieren.
* **`Backoff`** – die Pause vor einem Retry nach einer Exception. Das Array `[10, 60, 300]` legt wachsende Pausen fest: 10 Sekunden, eine Minute, fünf Minuten für den dritten und alle weiteren Retries.
* **`retryUntil()`** – eine Alternative zur Anzahl der Versuche: beliebig oft wiederholen, aber nicht über den angegebenen Zeitpunkt hinaus. Sind sowohl `tries` als auch `retryUntil()` gesetzt, hat `retryUntil()` Vorrang.

```php
public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}
```

Für instabile externe APIs ist die Middleware `ThrottlesExceptions` nützlich: Nach einer Fehlerserie stellt sie Jobs für eine festgelegte Zeit zurück, statt Versuche an einem offensichtlich ausgefallenen Dienst zu verbrennen. Das ist eine Umsetzung des Circuit-Breaker-Patterns auf Queue-Ebene (ausführlich im Artikel über [ausfallsichere Integrationen](resilient-external-integrations)).

```php
public function middleware(): array
{
    return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}
```

> [!NOTE]
> **Ein Retry ist nur sicher, wenn der Job idempotent ist.** Ein Job kann nach einem Timeout, einem Absturz des Workers oder Ihrem `queue:retry` erneut ausgeführt werden. Verwenden Sie `upsert` statt `insert`, Idempotenzschlüssel bei Payment-APIs und eine Prüfung „schon erledigt?“ am Anfang von `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: analysieren, wiederholen, aufräumen

Sind die Versuche aufgebraucht, ruft Laravel die Methode `failed()` des Jobs auf und schreibt ihn zusammen mit Payload und Exception-Text in die Tabelle `failed_jobs`.

```php
public function failed(?Throwable $exception): void
{
    // Runs in the worker after the last attempt: notify, compensate, mark state.
    Order::whereKey($this->orderId)->update(['crm_sync_status' => 'failed']);

    report($exception);
}
```

Der Arbeitsablauf mit gescheiterten Jobs:

```bash
php artisan queue:failed                 # list failed jobs with exception summaries
php artisan queue:retry <uuid>           # retry one job after fixing the cause
php artisan queue:retry --queue=crm      # retry every failed job from one queue
php artisan queue:retry all              # retry everything (use with care)
php artisan queue:forget <uuid>          # delete one record
php artisan queue:prune-failed --hours=168
```

Regeln, die Nerven sparen:

* **Kein blindes `queue:retry all`.** Ist die Ursache nicht behoben, scheitern die Jobs erneut – und nicht idempotente richten vorher noch etwas doppelt an.
* **Räumen Sie die Tabelle per Scheduler auf.** Standardmäßig löscht `queue:prune-failed` Einträge, die älter als 24 Stunden sind; setzen Sie `--hours` passend zu Ihrem Analyseprozess.
* **Löschen Sie Jobs mit gelöschten Models.** Wurde ein Model gelöscht, während der Job in der Queue wartete, entfernt das Attribut `#[DeleteWhenMissingModels]` den Job stillschweigend, statt mit `ModelNotFoundException` zu scheitern.
* **Alarmieren Sie nicht bei jedem Fehler, sondern beim Trend.** Ein gescheiterter Job pro Stunde ist Rauschen, hundert pro Minute sind ein Incident. Das Event `Queue::failing()` eignet sich gut für einen Metrik-Zähler.

```php
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();
```

---

<a id="memory"></a>
## Speicher langlebiger Worker

`queue:work` ist ein Daemon: Er lädt die Anwendung einmal und führt Jobs in einer Schleife aus, ohne das Framework neu zu starten. Das ist schnell, aber alles, was ein Job im Speicher hinterlässt, bleibt dort bis zum Ende des Prozesses. Typische Ursachen für Wachstum:

* statische Arrays und Singletons, die Zustand ansammeln (ein „Cache“ in einer Service-Property);
* das Query-Log (`DB::enableQueryLog()`), Telescope und Debugbar in Produktion;
* GD-/Imagick-Ressourcen ohne `imagedestroy()` oder `clear()`;
* große Collections, die per `get()` komplett geladen werden statt mit `lazyById()`.

Der Schutz: die Lebensdauer des Workers begrenzen und den Prozessmanager ihn neu starten lassen:

```bash
php artisan queue:work redis --queue=default \
    --tries=3 --timeout=120 \
    --max-jobs=1000 --max-time=3600 --memory=256
```

* `--max-jobs` – nach N Jobs beenden;
* `--max-time` – nach N Sekunden Laufzeit beenden;
* `--memory` – beenden, wenn der Prozess nach einem Job mehr als N Megabyte belegt (Standard: 128).

Wichtig ist die Reihenfolge: `--memory` wird **zwischen** den Jobs geprüft. Überschreitet ein einzelner Job selbst das PHP-`memory_limit`, stürzt der Prozess mitten in der Ausführung mit einem Fatal Error ab, der Job kehrt erst nach `retry_after` in die Queue zurück – und stürzt erneut ab. Solche Jobs „heilt“ man nicht mit Limits, sondern schreibt sie um: Stream-Verarbeitung und Aufteilung in Teile.

---

<a id="heavy-jobs"></a>
## Schwere Importe und Exporte

Ein einzelner Job „Datei mit 400 000 Zeilen importieren“ vereint alles auf einmal: lange Laufzeit, Timeout-Risiko, wachsenden Speicher und null Fortschritt bei einem Absturz in Zeile 399 999. Ein funktionierendes Schema: die Arbeit in viele kleine idempotente Jobs aufteilen und sie zu einem **Batch** zusammenfassen.

```php
// app/Domain/Catalog/Actions/StartPriceImport.php
public function handle(string $path, int $userId): Batch
{
    $jobs = [];
    foreach ($this->reader->chunkOffsets($path, rowsPerChunk: 2000) as [$from, $to]) {
        $jobs[] = new ImportPriceChunk($path, $from, $to);
    }

    return Bus::batch($jobs)
        ->name("price-import:{$userId}")
        ->onQueue('imports')
        ->allowFailures()
        ->then(fn (Batch $batch) => PriceImportFinished::dispatch($userId, $batch->id))
        ->catch(fn (Batch $batch, Throwable $e) => report($e))
        ->finally(fn (Batch $batch) => Storage::delete($path))
        ->dispatch();
}
```

```php
// app/Jobs/ImportPriceChunk.php
use Illuminate\Bus\Batchable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(90)]
final class ImportPriceChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(
        public string $path,
        public int $fromRow,
        public int $toRow,
    ) {}

    public function handle(PriceFileReader $reader): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        $rows = $reader->rows($this->path, $this->fromRow, $this->toRow);

        // Idempotent: re-running the same chunk overwrites the same SKUs.
        DB::table('product_prices')->upsert($rows, uniqueBy: ['sku'], update: ['price_cents', 'updated_at']);
    }
}
```

Worauf es hier ankommt:

* **Der Job bekommt die Koordinaten der Arbeit, nicht die Daten.** Dateipfad und Zeilenbereich wiegen ein paar Bytes. Ein Array mit zweitausend Zeilen im Konstruktor bläht Payload, Redis-Speicher und die Tabelle `failed_jobs` auf.
* **Jeder Teil ist idempotent.** `upsert` über `sku` liefert bei einer Wiederholung dasselbe Ergebnis.
* **Der Fortschritt ist sichtbar.** `$batch->progress()`, `processedJobs()` und `failedJobs` lassen sich dem Nutzer anzeigen. Für Batches wird die Tabelle `job_batches` benötigt (`php artisan make:queue-batches-table`), und auch sie muss mit `queue:prune-batches` aufgeräumt werden.
* **`allowFailures()`** bricht nicht den gesamten Import wegen eines einzigen defekten Teils ab; ohne diese Option storniert der erste Fehler den Batch.
* **Eine eigene Queue `imports`** mit eigenen Workern verhindert, dass der Import E-Mails und Benachrichtigungen aufhält.

Dieselbe Logik gilt für Exporte: Der Job liest die Daten als Stream über `lazyById()` (siehe [Eloquent und große Datenmengen](eloquent-large-datasets#export)), schreibt die Datei in den Storage und schickt dem Nutzer einen Link.

Nimmt ein Job ein Model entgegen, serialisiert Laravel es samt geladener Relationen. Das Attribut `#[WithoutRelations]` (oder `$model->withoutRelations()`) lässt im Payload nur die ID übrig – das Model wird bei der Ausführung neu aus der Datenbank geladen.

---

<a id="uniqueness"></a>
## Duplikate und Race Conditions: Unique Jobs und after_commit

**Der Job startet, bevor die Daten committet sind.** Ein klassischer Bug: Der Job wird innerhalb einer Transaktion dispatcht, der Worker übernimmt ihn sofort, sucht die Bestellung per ID – doch die Transaktion ist noch nicht committet. `ModelNotFoundException` oder, schlimmer, der Job arbeitet mit veralteten Daten. Lösungen:

* `'after_commit' => true` in den Einstellungen der Connection – alle Jobs, Events in der Queue, E-Mails und Benachrichtigungen warten auf den Commit;
* das Interface `ShouldQueueAfterCommit` an einem konkreten Job;
* `->afterCommit()` beim Dispatch.

**Derselbe Job mehrfach in der Queue.** Ein Nutzer hat dreimal auf „Neu berechnen“ geklickt, und in der Queue stehen drei identische schwere Jobs. Das Interface `ShouldBeUnique` verhindert, dass ein zweiter eingereiht wird, solange der erste nicht abgeschlossen ist:

```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
final class RecalculateCustomerStats implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public int $customerId) {}

    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}
```

Die Eindeutigkeit beruht auf einem atomaren Lock im Cache (Redis, Datenbank, memcached). Der Lock wird nach der Ausführung oder dem endgültigen Scheitern des Jobs freigegeben; `ShouldBeUniqueUntilProcessing` gibt ihn vor Beginn der Ausführung frei, sodass der nächste Job eingereiht werden kann, während der aktuelle läuft. Innerhalb von Batches greift die Eindeutigkeit nicht.

**Zwei verschiedene Jobs ändern gleichzeitig dieselben Daten.** Hier hilft die Middleware `WithoutOverlapping` mit einem Schlüssel pro Entität: Jobs mit demselben Schlüssel laufen nacheinander.

```php
public function middleware(): array
{
    return [(new WithoutOverlapping($this->customerId))->releaseAfter(30)->expireAfter(180)];
}
```

`expireAfter` ist in der Praxis Pflicht: Stirbt der Worker mitten im Job, bleibt ein Lock ohne Ablaufzeit für immer bestehen.

---

<a id="priorities"></a>
## Getrennte Queues und Prioritäten

Eine einzige Queue `default` für alles ist ein häufiger Grund für Beschwerden wie „Die E-Mail zum Zurücksetzen des Passworts kam nach zwanzig Minuten“: Davor standen zehntausend Import-Jobs. Trennen Sie Jobs nach ihrer Lastcharakteristik:

* `high` – worauf der Nutzer wartet: E-Mails zum Zurücksetzen des Passworts, Benachrichtigungen, Zahlungs-Webhooks;
* `default` – gewöhnliche Hintergrund-Jobs;
* `imports` / `reports` – schwer und langlaufend, mit eigenen Timeouts und Speicherlimits.

Ein Worker verarbeitet Queues nach Priorität von links nach rechts: `--queue=high,default`. Für schwere Queues starten Sie eigene Worker mit anderen `--timeout`- und `--memory`-Werten und damit auch eine eigene Connection mit passendem `retry_after`.

In Laravel 13 lässt sich das Routing von Jobs auf Queues an einer Stelle bündeln, statt in jedem Aufruf `->onQueue()` zu schreiben:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route([
        SendPasswordResetLink::class => 'high',
        ImportPriceChunk::class => ['redis-long', 'imports'],
    ]);
}
```

---

<a id="supervisor"></a>
## Supervisor und Horizon

Worker müssen ständig laufen und nach einem Absturz, nach `--max-time` oder `queue:restart` wieder hochkommen. Ohne Prozessmanager passiert das nicht.

```ini
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --queue=high,default --tries=3 --timeout=120 --max-time=3600 --memory=256
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=180

[program:laravel-imports]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis-long --queue=imports --timeout=600 --memory=512
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/imports.log
stopwaitsecs=660
```

`stopwaitsecs` muss größer sein als der längste Job dieser Gruppe: Beim Stoppen sendet Supervisor `SIGTERM`, der Worker beendet den aktuellen Job, und erst nach Ablauf von `stopwaitsecs` wird der Prozess per `SIGKILL` beendet. In Docker übernimmt `stop_grace_period` dieselbe Rolle.

**Horizon** ist für Redis-basierte Queues komfortabler: Worker-Konfiguration im Code, automatisches Balancing der Prozesse zwischen Queues, ein Dashboard mit Metriken, gescheiterten Jobs und Tags.

```php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 120,
            'memory' => 256,
            'maxTime' => 3600,
            'maxJobs' => 1000,
        ],
        'supervisor-imports' => [
            'connection' => 'redis-long',
            'queue' => ['imports'],
            'balance' => false,
            'maxProcesses' => 2,
            'timeout' => 600,
            'memory' => 512,
        ],
    ],
],
```

Die Timeout-Regel gilt auch hier: Der `timeout` eines Horizon-Supervisors muss einige Sekunden kleiner sein als das `retry_after` der Connection und zugleich größer als der Timeout jedes einzelnen Jobs. Horizon-Metriken basieren auf Snapshots – ergänzen Sie `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Deployment.** Worker halten den Code im Speicher und sehen Änderungen erst nach einem Neustart. Führen Sie nach dem Umschalten des Releases `php artisan queue:restart` (oder `php artisan horizon:terminate`) aus: Die Worker beenden ihre aktuellen Jobs und fahren herunter, und Supervisor startet sie mit dem neuen Code. Das Neustart-Signal liegt im Cache, deshalb muss der Cache für alle Server gemeinsam sein. Die Kompatibilität des Payloads alter Jobs mit neuem Code ist ein eigenes Thema; sie wird im Artikel über [Deployment ohne Downtime](zero-downtime-deployment-laravel#queues) behandelt.

---

<a id="monitoring"></a>
## Queue-Monitoring

Queues gehen leise kaputt: Das Web läuft, keine 500er, aber seit drei Stunden geht keine E-Mail raus. Der minimale Satz an Signalen:

* **Queue-Länge und Wartezeit** – eine wachsende Queue bedeutet, dass es zu wenige Worker gibt oder sie stehen.
* **Fehlerrate** – die Anzahl der Einträge in `failed_jobs` pro Intervall.
* **Lebendigkeit der Worker** – Horizon zeigt den Status der Supervisoren; ohne Horizon überwachen Sie die Prozesse über Supervisor.

Der eingebaute Befehl `queue:monitor` prüft die Größe der Queues und löst beim Überschreiten eines Schwellenwerts das Event `QueueBusy` aus, an das man eine Benachrichtigung hängen kann:

```php
// routes/console.php
Schedule::command('queue:monitor redis:high,redis:default --max=500')->everyMinute();
```

Mehr zu Metriken, Logs und Alerts im Artikel über [Observability](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Häufige Fehler

**1. `--timeout` größer als `retry_after`.**
Ein langer Job wird an einen zweiten Worker ausgeliefert, während der erste ihn ausführt. Das Ergebnis: Duplikate und Race Conditions.

**2. Kein Timeout am HTTP-Client.**
Ein hängender Socket wird nicht immer durch den Job-Timeout unterbrochen. Der Worker steht, die Queue wächst.

**3. Ein einzelner Standardversuch zusammen mit `RateLimited` oder `WithoutOverlapping`.**
Schon die erste Rückgabe in die Queue lässt den Job scheitern. Erhöhen Sie `Tries` und begrenzen Sie die Fehler mit `MaxExceptions`.

**4. Nicht idempotente Jobs.**
Ein Retry nach Timeout, Worker-Absturz oder `queue:retry` verschickt eine zweite E-Mail oder löst eine zweite Abbuchung aus.

**5. Daten im Payload statt IDs.**
Große Arrays und Models mit Relationen blähen Redis und `failed_jobs` auf. Übergeben Sie IDs und Dateipfade.

**6. Job aus einer Transaktion ohne `after_commit`.**
Der Worker findet einen noch nicht committeten Datensatz nicht oder arbeitet mit veralteten Daten.

**7. Worker ohne begrenzte Lebensdauer.**
Der Speicher wächst wochenlang, bis der OOM Killer den Prozess mitten in einem Job beendet. Verwenden Sie `--max-jobs`, `--max-time`, `--memory`.

**8. `queue:restart` nach dem Deployment vergessen.**
Die Worker führen Jobs mit altem Code gegen das neue Datenbankschema aus.

**9. `queue:retry all` ohne Ursachenanalyse.**
Die Jobs scheitern erneut, und die nicht idempotenten wiederholen vorher noch ihre Seiteneffekte.

---

<a id="checklist"></a>
## Checkliste

1. Die Timeout-Treppe ist abgestimmt: HTTP/SQL < Job-Timeout < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. Die Extension pcntl ist installiert; bei Redis ist `block_for` nicht `0`.
3. Jeder Job hat bewusst gesetzte `Tries`, `Backoff` und bei Bedarf `MaxExceptions` oder `retryUntil()`.
4. Jobs sind idempotent; externe Aufrufe verwenden Idempotenzschlüssel.
5. Jobs werden nach dem Commit dispatcht (`after_commit` oder `ShouldQueueAfterCommit`).
6. Im Payload stehen IDs und Pfade statt großer Datenmengen; Models ohne Relationen.
7. Schwere Importe sind in Batches mit eigener Queue und eigenen Workern aufgeteilt.
8. Worker werden über `--max-jobs`, `--max-time`, `--memory` unter Supervisor oder Horizon neu gestartet.
9. `queue:restart` / `horizon:terminate` ist ein Pflichtschritt jedes Deployments.
10. `queue:prune-failed` und `queue:prune-batches` sind im Scheduler eingetragen, für Queue-Größe und Fehlerrate sind Alerts eingerichtet.

---

## Zusammenfassung

Queues in Produktion sind genau so zuverlässig, wie ihre Zahlen aufeinander abgestimmt und ihre Jobs idempotent sind. Timeouts werden als Treppe aufgebaut, Retries an die Art des Fehlers angepasst, schwere Arbeit wird in kleine wiederholbare Teile zerlegt, und Worker leben nur begrenzte Zeit und werden bei jedem Deployment neu gestartet. Dann wird `failed_jobs` vom Friedhof zur Arbeitsliste, die man abarbeiten und wiederholen kann.

---

<a id="self-test-quiz"></a>
## Selbsttest-Quiz

### Frage 1: Das `retry_after` der Connection beträgt 90 Sekunden, der Worker läuft mit `--timeout=300`. Ein Job läuft 200 Sekunden. Was passiert?
- A) Der Job wird problemlos abgeschlossen – der Worker hat beim Timeout genug Reserve.
- B) Nach 90 Sekunden liefert die Connection den Job an einen anderen Worker aus, und er wird ein zweites Mal parallel zum ersten ausgeführt.
- C) Der Worker bricht den Job nach 90 Sekunden ab.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
`retry_after` wird vom Broker gezählt, der nicht weiß, ob der Worker noch lebt. Nach 90 Sekunden gilt der Job als aufgegeben und wird erneut ausgeliefert. Deshalb muss `--timeout` einige Sekunden kleiner sein als `retry_after`.
</details>

### Frage 2: Ein Job verwendet die Middleware `RateLimited` und landet in `failed_jobs`, ohne `handle()` auch nur einmal ausgeführt zu haben. Warum?
- A) `RateLimited` funktioniert nicht mit dem Redis-Treiber.
- B) Die Rückgabe des Jobs in die Queue wegen des Limits verbraucht einen Versuch, und standardmäßig hat ein Job nur einen einzigen Versuch.
- C) Die Middleware wird nach `handle()` ausgeführt.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Jedes `release()` ist ein verbrauchter Versuch. Für Jobs mit Rate Limiting erhöht man `Tries` (oder verwendet `retryUntil()`) und begrenzt die Anzahl echter Fehler über `MaxExceptions`.
</details>

### Frage 3: Wie importiert man eine Datei mit 400 000 Zeilen richtig über die Queue?
- A) Ein einziger Job mit `#[Timeout(7200)]` und `--memory=4096` am Worker.
- B) Ein Batch aus kleinen idempotenten Jobs, von denen jeder seinen eigenen Zeilenbereich verarbeitet, in einer eigenen Queue mit eigenen Workern.
- C) Alle Zeilen der Datei an den Konstruktor des Jobs übergeben, damit der Worker die Datei nicht lesen muss.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Kleine Teile passen in Timeouts und Speicherlimits, werden unabhängig voneinander wiederholt und zeigen den Fortschritt. Variante A verliert bei einem Absturz in der letzten Zeile die gesamte Arbeit, Variante C bläht Payload, Redis-Speicher und `failed_jobs` auf.
</details>