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

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.

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

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:

// 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:

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; ele é o complemento natural do bulkhead.
// 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:

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