<DevDiary />
← Voltar para o blog

Girô: dois serviços, nenhum broker e o padrão Transactional Outbox

Como fiz um mercado e um estoque conversarem por eventos sem Kafka nem RabbitMQ, usando só PostgreSQL: outbox, inbox idempotente e FOR UPDATE SKIP LOCKED.

O Girô é um projeto de portfólio: um mercado virtual cujo estoque se repõe sozinho. São dois sistemas separados, o mercado-service (a vitrine, onde a venda acontece) e o estoque-service (o almoxarifado, que tem os lotes). Cada um tem seu próprio PostgreSQL e nenhum enxerga o banco do outro.

A regra de negócio é simples: uma venda debita a prateleira. Quando o saldo cai abaixo do mínimo, o mercado pede reposição ao estoque, que manda um lote até o nível ideal. Se o estoque está zerado, o produto vira esgotado e a demanda fica registrada. Quando chega lote novo, a reposição dispara sozinha.

Parece fácil. O que eu aprendi foi que a parte difícil não é a regra, é fazer dois sistemas concordarem sobre o que aconteceu.

O problema: salvar e avisar não são atômicos

A primeira ideia que vem à cabeça é algo assim:

@Transactional
public void checkout(...) {
    prateleira.debitar(qtd);           // grava no banco
    estoqueClient.pedirReposicao(...); // chama o outro serviço por HTTP
}

Isso quebra de dois jeitos:

  • O HTTP funciona, mas o commit falha depois. O estoque mandou um lote para uma venda que não existe.
  • O commit funciona, mas o HTTP falha (estoque fora do ar, timeout). A prateleira ficou abaixo do mínimo e ninguém vai pedir reposição.

O banco e a rede não participam da mesma transação. Não tem @Transactional que resolva isso.

A resposta comum é "coloca um Kafka". Só que um broker é mais uma peça para subir, monitorar e entender, e ele não resolve o problema sozinho: publicar no Kafka também é uma chamada de rede fora da transação do banco.

A solução: Transactional Outbox

A ideia do outbox é parar de tentar fazer duas coisas atômicas e fazer uma só: gravar no banco.

  1. Na mesma transação que debita a prateleira, grava uma linha na tabela outbox_event com status PENDING. Ou as duas coisas commitam, ou nenhuma.
  2. Um job agendado lê os eventos pendentes, entrega para o outro serviço e marca como SENT.

Se o estoque estiver fora do ar, o evento continua na tabela esperando. Nada se perde.

O publicador ficou assim (resumido):

@Transactional
public int publicarProntos() {
    var agora = relogio.instant();
    int enviados = 0;
    for (var evento : eventos.travarProntosParaEnvio(agora, propriedades.tamanhoLote())) {
        try {
            transporte.enviar(evento);
            evento.marcarEnviado(relogio.instant());
            enviados++;
        } catch (FalhaEnvioException e) {
            var espera = backoff(evento.getTentativas() + 1);
            evento.agendarNovaTentativa(e.getMessage(), agora.plus(espera));
        }
    }
    return enviados;
}

Quando o envio falha, o evento é reagendado com backoff exponencial (1s, 2s, 4s… até um teto), para não martelar um serviço que já está caído.

FOR UPDATE SKIP LOCKED

E se tiver duas instâncias do mercado rodando? Ou se uma rodada do job demorar e a próxima começar antes de ela terminar? As duas iam pegar os mesmos eventos e enviar em dobro.

Aqui aprendi um recurso do PostgreSQL que eu não conhecia:

SELECT * FROM outbox_event
WHERE status = 'PENDING' AND proxima_tentativa_em <= :agora
ORDER BY criado_em
LIMIT :limite
FOR UPDATE SKIP LOCKED

FOR UPDATE trava as linhas lidas até o fim da transação. SKIP LOCKED faz o outro publicador pular as linhas travadas em vez de ficar esperando. Resultado: vários publicadores trabalham em paralelo, cada um com o seu pedaço da fila, sem fila externa nenhuma.

Mas o evento ainda pode chegar duas vezes

O outbox garante que o evento sai. Ele não garante que sai uma vez só. Se o estoque recebe o evento, responde 204, e o commit do lado do mercado falha logo depois, o evento continua PENDING e vai ser enviado de novo.

Isso tem nome: entrega at-least-once. Não dá para evitar, então o lado que recebe precisa aguentar duplicata. É o padrão Inbox, ou consumidor idempotente:

INSERT INTO inbox_event (event_id, processado_em)
VALUES (:eventId, :processadoEm)
ON CONFLICT (event_id) DO NOTHING

Cada evento tem um eventId (UUID). Antes de processar, o consumidor tenta registrar esse id. Se o INSERT afetou 1 linha, o evento é novo. Se afetou 0, é duplicata e é ignorado. O registro na inbox e o efeito do evento (creditar a prateleira, por exemplo) commitam juntos. E se duas entregas do mesmo evento chegarem ao mesmo tempo, a chave primária faz uma esperar a outra: só uma insere.

Um detalhe que gostei: a Inbox e a Outbox usam @Transactional(propagation = MANDATORY). Se alguém chamar uma delas fora de uma transação de negócio, dá erro na hora. Assim o código não deixa eu esquecer a regra.

Contratos que quebram a compilação

Os eventos são records do Java que implementam uma interface selada:

public sealed interface EventoIntegracao
        permits ReposicaoSolicitada, ReposicaoEnviada, ReposicaoNegada, ProdutoClassificado {
    UUID eventId();
    Instant ocorridoEm();
}

Como a interface é sealed, todo switch sobre ela pode ser exaustivo. Se eu criar um evento novo e esquecer de tratar em algum lugar, o projeto não compila. Prefiro mil vezes descobrir isso no build do que em produção.

Os contratos ficam duplicados nos dois serviços, de propósito: assim eles continuam independentes de verdade. Para não divergirem, os dois têm os mesmos arquivos JSON de exemplo em src/test/resources/contratos/, e testes de contrato conferem que ambos leem e escrevem o mesmo formato.

LLM só na periferia

O projeto tem um terceiro serviço, o tag-worker, em Python com FastAPI, que usa um LLM para classificar produtos ("COCA COLA 2L PET" vira bebida, refrigerante, 2l). Uma decisão importante: IA não decide nada no núcleo. Quantidade de reposição é regra determinística e auditável. Se o worker estiver fora do ar, o produto é salvo sem tags e a venda segue normalmente.

O bug que só um teste de verdade pegaria

O estoque chama o tag-worker com o HttpClient do JDK. Funcionava nos testes com servidor falso, mas quebrava contra o worker real: o corpo da requisição chegava vazio.

O motivo: por padrão o HttpClient do Java tenta fazer upgrade para HTTP/2 em texto puro (h2c), e o uvicorn, que roda o FastAPI, descartava o corpo nesse caminho. A correção foi uma linha, forçar HttpClient.Version.HTTP_1_1. Quem pegou o bug foi um teste de contrato que sobe o tag-worker de verdade a partir do Dockerfile, com Testcontainers. Virou regra do projeto: todo cliente HTTP Java usa HTTP/1.1.

O que levo daqui

  • Rede e banco não são atômicos. Toda vez que um sistema grava algo e avisa outro, vale perguntar o que acontece se um dos dois falhar no meio.
  • Outbox resolve o "sai sempre", inbox resolve o "processa uma vez". Um sem o outro fica pela metade.
  • PostgreSQL faz mais do que eu imaginava. SKIP LOCKED e ON CONFLICT substituíram uma infraestrutura inteira para esse tamanho de projeto.
  • Deixe o compilador e os testes cobrarem as regras. Interface selada, propagation = MANDATORY e teste de contrato contra o serviço real pegaram erros que revisão de código deixaria passar.

Ainda falta

Nem tudo está pronto, e anotar o que falta também faz parte do diário:

  • Um evento que recebe 400 é reenviado para sempre (com backoff de até 5 minutos). Falta um status FAILED, uma dead-letter, para tirar da fila o que nunca vai dar certo.
  • A reserva feita no checkout ainda não expira, e não existe confirmação de pagamento.
  • Os endpoints entre serviços ainda não têm autenticação.
PeçaTecnologia
mercado-service e estoque-serviceJava 25 + Spring Boot 4
BancosPostgreSQL 17, um por serviço, migrations com Flyway
TestesJUnit 5 + Testcontainers
tag-workerPython 3.12 + FastAPI
FrontsReact + Vite