# Bulkhead de filas no Laravel Horizon: como impedir que um job lento atrase os webhooks

> Separar filas por tipo de carga, com supervisors, timeouts e conexões próprias no Horizon, impede que um lote de conciliação ou um provedor lento trave o processamento de pagamentos.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-20 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/bulkhead-de-filas-no-laravel-horizon-como-impedir-que-um-job-lento-atrase-os-webhooks

Bulkhead de filas é isolar os workers por tipo de carga, para que um backlog numa fila não consuma a capacidade de outra. No Laravel com Horizon isso se resume a quatro decisões: filas separadas por criticidade, um supervisor dedicado para cada grupo com limite próprio de processos, `timeout` e `retry_after` coerentes com a duração de cada tipo de job e alertas de tempo de espera por fila. Sem isso, um relatório pesado ou um provedor de WhatsApp lento acaba atrasando a confirmação de um pagamento que já foi aprovado.

## O que é o padrão bulkhead e por que ele importa em filas?

O nome vem dos compartimentos estanques de um navio: se um compartimento inunda, os outros continuam secos. Em software, bulkhead significa dividir um recurso compartilhado (threads, conexões, workers) em pools independentes, de modo que a falha ou a sobrecarga de um consumidor fique contida.

Em sistemas de pagamento, o recurso que mais sofre com a falta de isolamento é o pool de workers de fila. A configuração padrão de muito projeto Laravel é um único supervisor ouvindo a fila `default`, e tudo cai ali: webhook do gateway, envio de mensagem no WhatsApp, e-mail de comprovante, geração de relatório em PDF, rotina de conciliação, sincronização de extrato via Pluggy.

Enquanto o volume é baixo, isso funciona. O problema aparece em situações bem típicas:

1. A conciliação noturna enfileira dezenas de milhares de jobs de uma vez e os webhooks que chegam nesse intervalo ficam esperando atrás.
2. O provedor de WhatsApp começa a responder em 20 segundos em vez de 200 ms. Cada job de notificação segura um worker esse tempo todo, e em poucos minutos todos os workers estão parados esperando HTTP.
3. Um reprocessamento manual ("reenvia os comprovantes do mês") ocupa a fila inteira em horário comercial.

Em nenhum desses casos alguma coisa "quebrou". Os jobs estão sendo processados. Só que o webhook que confirma um Pix pago, que deveria levar segundos, leva meia hora, e o lojista fica olhando a maquininha sem saber se recebeu.

## Fila com prioridade não resolve?

A primeira reação costuma ser usar prioridade: `php artisan queue:work --queue=webhooks,default`. O worker sempre pega primeiro da fila `webhooks` e só consome `default` quando ela está vazia.

Isso ajuda no cenário 1, mas não nos outros. Prioridade decide qual job o worker pega **quando ele está livre**. Se todos os workers estão presos em jobs de 20 segundos esperando o provedor de WhatsApp, não há worker livre para olhar a fila prioritária. A prioridade não interrompe nada que já está rodando.

Além disso, prioridade estrita causa starvation, que é quando a fila de baixa prioridade nunca é consumida porque a de alta nunca esvazia. Em dia de pico de vendas, os e-mails de comprovante simplesmente não saem.

O que resolve é capacidade reservada: um número de workers que só existe para webhooks e que nenhuma outra carga consegue ocupar.

## Como classificar os jobs antes de separar as filas?

Antes de mexer no `config/horizon.php`, vale classificar cada job por quatro critérios: latência tolerada, duração típica, dependência externa e o que acontece se falhar. O resultado costuma ficar parecido com isto:

| Fila | Exemplos | Latência tolerada | Duração típica | Dependência externa |
|---|---|---|---|---|
| `webhooks` | confirmação de pagamento, estorno, chargeback | segundos | curta | só banco |
| `financeiro` | lançamento no ledger, liberação de saldo, split | segundos | curta | banco |
| `whatsapp` | lembrete do Horalis, aviso de pagamento | minutos | variável | provedor de WhatsApp |
| `email` | comprovante, recibo | minutos | curta | SMTP/API |
| `integracoes` | sync de extrato via Pluggy, consulta de KYC no Sumsub | minutos | variável | APIs de terceiros |
| `lote` | conciliação, relatórios, exportações | horas | longa | banco e storage |

Dois princípios guiam o agrupamento. Jobs que dependem do mesmo serviço externo ficam juntos, porque é ali que a lentidão aparece em bloco. E jobs longos nunca dividem worker com jobs curtos e críticos.

Um detalhe que faz diferença: o webhook em si não deve fazer trabalho pesado. O controller valida a assinatura, grava o evento bruto e despacha o job para `webhooks`. Se você estiver depurando a validação HMAC de um provedor, o [testador de webhook](/ferramentas/testador-webhook) ajuda a conferir payload, segredo e assinatura sem subir nada. A deduplicação e a ordem dos eventos são outro assunto, que tratei em [como processar cada webhook de pagamento uma única vez](/blog/webhooks-de-pagamento-duplicados-e-fora-de-ordem-como-processar-cada-evento-uma-unica-vez-no-laravel).

## Como configurar supervisors separados no Horizon?

No Horizon, um supervisor é um processo que gerencia um grupo de workers com configuração própria: filas, número de processos, timeout, tentativas. O bulkhead é exatamente isso, um supervisor por grupo.

```php
// config/horizon.php
'defaults' => [
    'criticos' => [
        'connection' => 'redis',
        'queue' => ['webhooks', 'financeiro'],
        'balance' => 'auto',
        'autoScalingStrategy' => 'time',
        'minProcesses' => 2,
        'maxProcesses' => 8,
        'balanceMaxShift' => 2,
        'balanceCooldown' => 3,
        'tries' => 5,
        'timeout' => 20,
        'memory' => 128,
    ],
    'notificacoes' => [
        'connection' => 'redis',
        'queue' => ['whatsapp', 'email'],
        'balance' => 'auto',
        'minProcesses' => 1,
        'maxProcesses' => 4,
        'tries' => 3,
        'timeout' => 45,
    ],
    'integracoes' => [
        'connection' => 'redis',
        'queue' => ['integracoes'],
        'balance' => false,
        'maxProcesses' => 3,
        'tries' => 3,
        'timeout' => 60,
    ],
    'lote' => [
        'connection' => 'redis-lote',
        'queue' => ['lote'],
        'balance' => false,
        'maxProcesses' => 2,
        'tries' => 1,
        'timeout' => 900,
        'memory' => 512,
    ],
],
```

Algumas decisões aqui merecem explicação.

**Um supervisor por grupo, não um supervisor com todas as filas.** Com `balance => 'auto'`, o Horizon redistribui processos entre as filas **do mesmo supervisor** conforme a carga. Se você coloca `webhooks` e `lote` no mesmo supervisor, o auto-balance vai entregar quase todos os processos para `lote` quando a conciliação enfileirar milhares de jobs. É o oposto do que se quer. O isolamento acontece entre supervisors; dentro de um supervisor, o balanceamento é cooperativo.

**`minProcesses` nos críticos.** Mesmo em silêncio, a fila de webhooks mantém workers prontos. Assim o primeiro evento depois de um período parado não espera o Horizon escalar.

**`balance => false` no lote.** Lote não precisa de elasticidade. Dois processos fixos significam que a conciliação demora mais, mas nunca come memória nem conexões de banco de quem precisa responder rápido. Para integrações, número fixo também ajuda a respeitar o rate limit do terceiro.

**`tries => 1` no lote.** Job de conciliação geralmente é idempotente no nível do dia, mas reprocessar automaticamente um job de 15 minutos que falhou por bug só multiplica o problema. Prefiro que ele vá para `failed_jobs` e seja reexecutado de propósito. A lógica da rotina em si está no post sobre [conciliação diária de pagamentos no Laravel](/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia).

## Por que o job longo precisa de outra conexão de fila?

Este é o erro mais comum ao separar filas, e ele causa execução duplicada.

No driver Redis, `retry_after` é o tempo depois do qual um job reservado e não finalizado volta para a fila, assumindo que o worker morreu. Ele é definido **por conexão** em `config/queue.php`, não por fila. Se um job de conciliação tem `timeout` de 900 segundos, mas roda numa conexão com `retry_after` de 90, depois de 90 segundos outro worker pega o mesmo job enquanto o primeiro ainda está rodando.

A regra é: `retry_after` precisa ser maior que o maior `timeout` dos jobs daquela conexão, com folga. Como não faz sentido colocar `retry_after` de 16 minutos para webhooks (um worker que morreu de verdade deixaria o evento parado esse tempo todo), a solução é uma conexão separada:

```php
// config/queue.php
'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'queue',
        'queue' => 'webhooks',
        'retry_after' => 90,
        'block_for' => null,
        'after_commit' => true,
    ],
    'redis-lote' => [
        'driver' => 'redis',
        'connection' => 'queue',
        'queue' => 'lote',
        'retry_after' => 960,
        'block_for' => null,
        'after_commit' => true,
    ],
],
```

As duas apontam para o mesmo Redis, mas com semânticas de reentrega diferentes. Repare também no `after_commit`: em fluxo financeiro, despachar job antes do commit é outra fonte clássica de bug.

## Como garantir que cada job vá para a fila certa?

Se a escolha de fila fica espalhada em chamadas `->onQueue('whatsapp')` pelo código, alguém vai esquecer e o job cai na fila padrão da conexão. Eu centralizo em um enum e defino a fila no construtor do job:

```php
enum Fila: string
{
    case Webhooks = 'webhooks';
    case Financeiro = 'financeiro';
    case Whatsapp = 'whatsapp';
    case Email = 'email';
    case Integracoes = 'integracoes';
    case Lote = 'lote';

    public function conexao(): string
    {
        return $this === self::Lote ? 'redis-lote' : 'redis';
    }
}

final class ConciliarDia implements ShouldQueue
{
    use Queueable;

    public int $timeout = 900;
    public bool $failOnTimeout = true;

    public function __construct(public string $data)
    {
        $this->onConnection(Fila::Lote->conexao());
        $this->onQueue(Fila::Lote->value);
    }
}
```

E coloco a fila padrão da conexão `redis` como `webhooks`, não `default`. Parece estranho, mas a lógica é: se um job crítico novo for criado sem fila explícita, ele cai num lugar monitorado. Para pegar o caso contrário (job pesado esquecido na fila crítica), um teste simples resolve: percorrer as classes de `app/Jobs`, instanciar e conferir que `queue` não é nulo.

## Como evitar que um provedor lento ocupe todos os workers da fila dele?

O bulkhead entre filas contém o estrago, mas dentro da fila `whatsapp` o provedor lento ainda trava os quatro workers. Três camadas ajudam aqui.

1. **Timeout HTTP menor que o timeout do job.** Se o job tem `timeout` de 45 segundos, o cliente HTTP deve desistir bem antes, com `Http::timeout(10)->connectTimeout(3)`. Assim o job falha de forma controlada e libera o worker, em vez de ser morto pelo Horizon.
2. **Rate limit de saída.** Muitos provedores limitam requisições por segundo. Estourar o limite gera erro 429, retry, mais carga. O middleware `RateLimited` do Laravel segura isso no lado do consumidor.
3. **Circuit breaker.** Quando o provedor está claramente fora, nem vale tentar. Detalhei a implementação em [circuit breaker no Laravel com Redis](/blog/circuit-breaker-no-laravel-com-redis-como-isolar-um-gateway-de-pagamento-fora-do-ar); ele é o complemento natural do bulkhead.

```php
// AppServiceProvider::boot()
RateLimiter::for('whatsapp-provider', fn () => Limit::perSecond(20));

// no job
public function middleware(): array
{
    return [new RateLimited('whatsapp-provider')];
}

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

O `retryUntil` é importante: quando o rate limit libera o job de volta para a fila, isso conta como tentativa. Com `tries => 3`, um pico de tráfego faria o job falhar sem nunca ter chamado o provedor. Definir um prazo em vez de um número de tentativas resolve.

## Como monitorar se o isolamento está funcionando?

Throughput não diz muita coisa. A métrica que importa no bulkhead é o **tempo de espera**, quanto tempo um job fica na fila antes de um worker pegá-lo. O Horizon tem configuração própria para alertar sobre isso, por conexão e fila:

```php
// config/horizon.php
'waits' => [
    'redis:webhooks' => 15,
    'redis:financeiro' => 15,
    'redis:whatsapp' => 120,
    'redis:email' => 300,
    'redis-lote:lote' => 3600,
],
```

Com `Horizon::routeSlackNotificationsTo()` (ou e-mail/SMS), o alerta `LongWaitDetected` dispara quando a espera passa do limite. Os limites refletem a latência tolerada da tabela lá de cima, e é por isso que a classificação vem primeiro: sem ela, você não sabe qual número colocar.

Também agendo `horizon:snapshot` a cada cinco minutos para ter o histórico de espera e runtime por fila no painel. É com esse histórico que eu ajusto `maxProcesses`, em vez de chutar.

## Quais são os custos e trade-offs do bulkhead?

Isolar tem preço, e vale saber qual antes de sair criando dez supervisors.

| Custo | O que acontece | Como mitigar |
|---|---|---|
| Capacidade ociosa | `minProcesses` mantém workers parados em horário calmo | manter mínimos baixos e só nos críticos |
| Memória | cada processo PHP carrega o app inteiro | `memory` por supervisor e servidor dimensionado pela soma dos máximos |
| Conexões com o PostgreSQL | cada worker abre a própria conexão | somar todos os `maxProcesses` + pool do PHP-FPM e manter abaixo de `max_connections`, ou usar PgBouncer |
| Complexidade | mais configuração para entender e revisar | enum de filas, teste de roteamento, documentação curta no repositório |
| Lote mais lento | lote com poucos workers demora mais | agendar fora do horário de pico; se precisar, rodar o supervisor de lote em outra máquina |

O item das conexões com o banco é o que mais pega gente desprevenida. Se a soma de `maxProcesses` passa de `max_connections`, o auto-scaling do Horizon vai funcionar perfeitamente até o dia de pico, quando os workers novos não conseguirem conectar e os jobs críticos começarem a falhar. O bulkhead que deveria proteger vira o gatilho do incidente.

Outro ponto: o Redis continua sendo um recurso compartilhado. Um backlog de milhões de jobs de lote ocupa memória no mesmo Redis da fila de webhooks. Se o Redis também serve cache e sessão, vale separar ao menos em instâncias diferentes para fila e para cache, para que uma eviction por falta de memória não apague jobs.

## Por onde começar num projeto que já tem tudo na fila default?

Não precisa fazer tudo de uma vez. A ordem que funciona:

1. Liste os jobs e preencha a tabela de classificação, mesmo que de forma grosseira.
2. Tire primeiro os jobs longos para uma conexão própria com `retry_after` adequado. Isso já elimina o risco de execução duplicada.
3. Crie o supervisor dos críticos com `minProcesses` maior que zero e mova webhooks e lançamentos financeiros para ele.
4. Agrupe os jobs que dependem de terceiros por provedor, com timeout HTTP e rate limit.
5. Configure `waits` por fila e o snapshot do Horizon.
6. Some os `maxProcesses` e confira contra o limite de conexões do PostgreSQL.

O teste de que funcionou é simples de fazer em staging: enfileire alguns milhares de jobs de lote, simule o provedor de WhatsApp respondendo em 30 segundos e mande um webhook de pagamento. Se ele for processado em segundos enquanto o resto da casa está pegando fogo, o bulkhead está fazendo o trabalho dele. Se não, a tabela de classificação vai dizer onde está o vazamento.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
