Ir para o conteúdo
DedicatedPHP Contato

Como projetar uma transactional outbox em PHP

O padrão transactional outbox em PHP evita a perda de eventos entre uma transação de negócio e uma fila. Aprenda a processá-los, fazer novas tentativas e monitorá-los.

Diagrama conceitual de uma transação PHP que grava uma alteração de negócio e um evento em uma tabela outbox para publicação assíncrona

Uma aplicação pode salvar corretamente um pedido e, ainda assim, não notificar o sistema de pagamentos, o serviço de estoque ou uma fila de jobs. Também pode publicar o aviso e falhar antes de confirmar o pedido. Em ambos os casos, os sistemas ficam em desacordo. O padrão transactional outbox em PHP resolve essa dupla gravação fazendo com que a alteração de negócio e o registro do evento sejam confirmados juntos na mesma transação do banco de dados.

A outbox não torna a entrega instantânea nem garante, por si só, que um consumidor processe um evento uma única vez. Seu objetivo é evitar que um evento confirmado dependa de uma segunda operação de gravação que poderia falhar separadamente. Para aplicá-la corretamente, também é preciso projetar a leitura concorrente, as novas tentativas, a idempotência e a operação diária.

O problema de gravar no banco de dados e publicar um evento

O problema de gravar no banco de dados e publicar um evento — guía visual de DedicatedPHP

Suponha que uma operação confirme uma compra e, em seguida, publique OrderPlaced em uma fila. Se o processo PHP terminar entre as duas ações, a compra existe, mas ninguém recebe o evento. Se o evento for publicado primeiro e a transação do banco de dados for revertida, o consumidor poderá agir sobre uma compra inexistente.

Uma transação SQL normalmente também não abrange uma chamada a uma fila, uma API externa ou um broker. Tentar coordenar manualmente as duas operações não elimina a janela de falha: apenas muda a ordem em que ela aparece. O padrão transactional outbox evita a dupla gravação não atômica ao incluir, em uma única transação, a alteração de negócio e uma linha que representa o evento a ser publicado.

Design mínimo da tabela outbox

A tabela deve armazenar informações suficientes para que outro processo possa identificar, entregar e rastrear o evento. Um design inicial pode incluir:

  • Identificador único: estável e utilizável como chave de idempotência, por exemplo, um UUID.
  • Tipo de evento: como OrderPlaced, idealmente com uma versão de schema se o formato evoluir.
  • Payload: dados serializados necessários ao consumidor, sem incluir informações confidenciais desnecessárias.
  • Data de criação: útil para ordenar, medir a antiguidade e diagnosticar atrasos.
  • Status e acompanhamento: por exemplo, pendente, reservado ou publicado; tentativas, último erro e data da última tentativa.
  • Dados da reserva: identificador do worker e vencimento do lease, caso um lease seja usado para coordenar consumidores.

O schema específico depende do mecanismo de banco de dados e do volume. Adicione índices que deem suporte à consulta de eventos pendentes e evite carregar a tabela inteira para encontrar trabalho. Defina também por quanto tempo manter linhas publicadas e erros; não transforme a outbox em um arquivo por tempo indeterminado por padrão.

Gravar o negócio e o evento em uma única transação

Em PHP, o código que modifica o estado de negócio e adiciona o evento deve compartilhar a mesma conexão e transação. Este exemplo mostra a sequência, não um schema SQL universal:

$pdo->beginTransaction();

try {
    // Inserir ou atualizar o pedido usando esta conexão.
    $orderId = saveOrder($pdo, $orderData);

    $eventId = generateUuid();
    $payload = json_encode([
        'event_id' => $eventId,
        'order_id' => $orderId,
    ], JSON_THROW_ON_ERROR);

    $statement = $pdo->prepare(
        'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
         VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
    );
    $statement->execute([
        'id' => $eventId,
        'type' => 'OrderPlaced',
        'payload' => $payload,
        'status' => 'pending',
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $error;
}

Se a transação for confirmada, os dois registros serão gravados; se falhar, nenhum dos dois deverá ser considerado confirmado. Não publique a mensagem no broker dentro da transação na expectativa de que isso torne a operação atômica: a chamada externa não participa do commit SQL.

O UUID do exemplo identifica o evento criado nessa transação, mas não evita, por si só, duplicatas se a solicitação de negócio completa for repetida. Se esse risco for relevante — por exemplo, se uma solicitação puder ser repetida após um timeout —, use uma chave de idempotência da solicitação ou uma restrição de unicidade na operação de negócio. Ao reconhecer a repetição, recupere o resultado e o evento já associados à operação original, em vez de criar outro evento com um UUID diferente.

Processar eventos pendentes com concorrência e recuperação

Um worker consulta um lote limitado de eventos elegíveis, reserva-os e os publica no destino. A reserva deve impedir que vários workers processem simultaneamente o mesmo evento ou, pelo menos, permitir que essa duplicação seja tratada com segurança. As opções dependem do mecanismo: bloqueio de linhas, atualização condicional de status ou leases com vencimento. Não há uma consulta portável que resolva corretamente todos os casos; verifique a semântica de bloqueio e isolamento do banco de dados utilizado.

Uma estratégia comum é reservar linhas por um período limitado, publicar fora de uma transação longa e marcá-las como publicadas após receber a confirmação do destino. Se o processo cair depois de publicar, mas antes de atualizar o status, o lease vencerá e outro worker poderá publicar o evento novamente. Se o evento for marcado como publicado antes da publicação e o processo cair, ele poderá ser perdido. Por isso, as tentativas de publicação podem oferecer entrega pelo menos uma vez, não exatamente uma vez: podem gerar duplicatas, mas não garantem que o destino chegue a receber o evento. A entrega efetiva depende da política de novas tentativas e de o destino voltar a ficar disponível antes que elas se esgotem, ou de uma intervenção para recuperar o evento.

Use lotes pequenos ou configuráveis e limite o tempo de cada execução. Manter bloqueios SQL enquanto se aguarda uma rede externa pode degradar o banco de dados. O tamanho do lote, a duração do lease e a frequência de polling devem ser ajustados com base em medições: um lote grande nem sempre melhora o desempenho e pode aumentar a latência ou dificultar a recuperação.

Idempotência, novas tentativas e eventos travados

O consumidor deve tolerar que o mesmo evento chegue mais de uma vez. Para um efeito de negócio no mesmo banco de dados, é possível registrar o identificador do evento em uma tabela de eventos processados com uma restrição única e aplicar o efeito na mesma transação que registra esse identificador. Assim, se a transação for confirmada, o identificador e o efeito serão registrados juntos; se falhar, será possível tentar novamente sem deixar um deles confirmado sem o outro.

Esse mecanismo não torna automaticamente idempotentes os efeitos externos não transacionais. Se o consumidor chamar uma API, enviar um e-mail ou executar outra ação fora do próprio banco de dados, uma falha entre o efeito externo e o registro local poderá provocar uma repetição. Use uma chave de idempotência aceita pelo destino, uma coordenação específica ou um fluxo compensatório adequado; se nenhuma opção existir, reconheça e gerencie explicitamente o risco de duplicação.

Implemente novas tentativas com espera crescente e limites razoáveis, distinguindo erros transitórios — por exemplo, um timeout — de erros permanentes, como um payload inválido. Armazene o último erro de forma útil para investigação, mas sem despejar segredos nem dados pessoais desnecessários nos logs. Depois que as tentativas se esgotarem, mantenha o evento em um status de falha visível; não o exclua silenciosamente.

A recuperação deve ser explícita: identificar a causa, corrigi-la e decidir se o mesmo evento será tentado novamente ou se será necessária uma intervenção. Antes de retomar, confirme que o consumidor é idempotente. Um botão de “reprocessar” sem controles pode repetir efeitos externos ou alterar a ordem esperada. Documente quem pode fazer isso e o que fica registrado.

Monitoramento, retenção e proteção de dados

Monitore o número de eventos pendentes, a idade do evento pendente mais antigo, as tentativas com falha, a taxa de publicação e o tempo entre a criação e a entrega. Uma fila em crescimento pode indicar indisponibilidade do destino, consumidores parados, erros persistentes ou capacidade insuficiente. Um alerta baseado apenas no número total pode ocultar atrasos; a idade ajuda a entender se o trabalho está progredindo.

A tabela crescerá a cada evento. Defina uma política de retenção para os eventos publicados e com falha e exclua-os em lotes para evitar operações de manutenção muito pesadas. Antes de limpar os dados, preserve o que for necessário para auditoria, suporte ou investigação, conforme os requisitos do negócio. Restrinja o acesso ao payload, criptografe ou minimize os dados confidenciais quando apropriado e estabeleça uma política de exclusão coerente com a retenção.

Quando usar outbox e quando evitar sua complexidade

Quando usar outbox e quando evitar sua complexidade — guía visual de DedicatedPHP

A outbox agrega valor quando uma confirmação de negócio deve iniciar uma ação assíncrona confiável, há possibilidade de falhas entre sistemas e perder a notificação teria consequências. É especialmente útil quando a aplicação já tem um banco de dados transacional e precisa se integrar a uma fila ou a serviços externos sem vincular a disponibilidade deles a cada solicitação.

Ela pode ser desnecessária para tarefas dispensáveis ou que possam ser reconstruídas, quando basta consultar periodicamente o estado do negócio ou quando a fila e a persistência oferecem uma transação integrada adequada para esse fluxo. Avalie essa alternativa considerando seus limites reais: não presuma que dois serviços são atômicos apenas por fazerem parte do mesmo produto.

O padrão também tem custos: uma tabela adicional, um worker, novas tentativas, gerenciamento de estados, retenção e suporte operacional. Evite adicionar um sistema de estados complexo antes de conhecer as necessidades. Comece com identificadores, status, datas, tentativas e uma reserva segura; amplie o design apenas diante de requisitos concretos de ordenação, volume, auditoria ou recuperação. A decisão correta é a opção mais simples que preserve a consistência necessária e permita detectar e resolver falhas.

Deseja aplicar essas ideias ao seu projeto?Vamos discutir sua plataforma PHP.
Veja os serviços relacionados