Aprenda transações Postgres para fluxos multietapa: como agrupar atualizações com segurança, evitar gravações parciais, tratar retries e manter os dados consistentes.

sql\nBEGIN;\n\n-- reads that inform your decision\nSELECT balance FROM accounts WHERE id = 42 FOR UPDATE;\n\n-- writes that must stay together\nUPDATE accounts SET balance = balance - 50 WHERE id = 42;\nINSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');\n\nCOMMIT;\n\n-- on error (in code), run:\n-- ROLLBACK;\n\n\n### Mantenha curto (e passível de depuração)\n\nTransações seguram locks enquanto rodam. Quanto mais tempo abertas, mais você bloqueia outros trabalhos e maior a chance de timeouts ou deadlocks. Faça o essencial dentro da transação e mova tarefas lentas (enviar emails, chamar provedores de pagamento, gerar PDFs) para fora.\n\nQuando algo falha, registre contexto suficiente para reproduzir o problema sem vazar dados sensíveis: nome do fluxo, order_id ou user_id, parâmetros chaves (valor, moeda) e o código de erro do Postgres. Evite logar payloads completos, dados de cartão ou informações pessoais.\n\n## Noções básicas de concorrência: locks e isolamento sem jargão\n\nConcorrência é só duas coisas acontecendo ao mesmo tempo. Imagine dois clientes tentando comprar o último ingresso. Ambas telas mostram "1 restante", ambos clicam em Pagar, e agora sua app tem que decidir quem fica com ele.\n\nSem proteção, ambos requests podem ler o mesmo valor antigo e ambos escrever uma atualização. É assim que você acaba com estoque negativo, reservas duplicadas ou um pagamento sem pedido.\n\nLocks em linha são a proteção mais simples. Você bloqueia a linha específica que vai mudar, faz suas checagens e então atualiza. Outras transações que tocam a mesma linha precisam esperar até você commitar ou dar rollback, o que evita duplicações.\n\nUm padrão comum: iniciar a transação, selecionar a linha de inventário com FOR UPDATE, verificar se há estoque, decrementar e então inserir o pedido. Isso "segura a porta" enquanto você termina os passos críticos.\n\nNíveis de isolamento controlam o quanto de comportamento estranho você permite de transações concorrentes. O trade-off é geralmente segurança vs velocidade:\n\n- Read Committed (padrão): rápido, mas você pode ver mudanças cometidas por outros entre declarações.\n- Repeatable Read: sua transação vê um snapshot estável, bom para leituras consistentes, pode causar mais retries.\n- Serializable: segurança máxima, o Postgres pode abortar uma transação para manter resultados como se tivessem rodado uma a uma.\n\nMantenha locks curtos. Se uma transação ficar aberta enquanto você chama uma API externa ou espera ação do usuário, você vai criar esperas longas e timeouts. Prefira um caminho de falha claro: defina um lock timeout, capture o erro e retorne "por favor, tente novamente" em vez de deixar requests pendurados.\n\nSe você precisa fazer trabalho fora do banco (como cobrar um cartão), divida o fluxo: reserve rápido, commit, depois faça a parte lenta e finalize com outra transação curta.\n\n## Retries que não criam duplicatas\n\nRetries são normais em apps que usam Postgres. Um request pode falhar mesmo quando seu código está correto: deadlocks, statement timeouts, quedas de rede breves ou erro de serialização sob níveis de isolamento mais altos. Se você simplesmente reexecutar o mesmo handler, corre o risco de criar um segundo pedido, cobrar duas vezes ou inserir linhas de "evento" duplicadas.\n\nA solução é idempotência: a operação deve ser segura para rodar duas vezes com a mesma entrada. O banco deve conseguir reconhecer "isso é o mesmo request" e responder de forma consistente.\n\nUm padrão prático é anexar uma chave de idempotência (frequentemente um request_id gerado pelo cliente) a cada fluxo multietapa e armazená-la no registro principal, então adicionar uma constraint única nessa chave.\n\nPor exemplo: no checkout, gere request_id quando o usuário clicar em Pagar, então insira o pedido com esse request_id. Se houver um retry, a segunda tentativa esbarra na constraint única e você retorna o pedido existente em vez de criar outro.\n\nO que geralmente importa:\n\n- Use uma constraint única em (request_id) ou (user_id, request_id) para bloquear duplicatas.\n- Quando a constraint for violada, busque a linha existente e retorne o mesmo resultado.\n- Faça efeitos colaterais seguirem a mesma regra: apenas uma payment intent por pedido, apenas um evento "pedido confirmado" por pedido.\n- Logue o request_id para que o suporte consiga rastrear o que aconteceu.\n\nMantenha o loop de retry fora da transação. Cada tentativa deve começar uma nova transação e reexecutar a unidade de trabalho do topo. Repetir dentro de uma transação abortada não ajuda porque o Postgres marca a transação como abortada.\n\nUm pequeno exemplo: sua app tenta criar um pedido e reservar estoque, mas dá timeout logo após o COMMIT. O cliente reenvia. Com uma chave de idempotência, a segunda requisição retorna o pedido já criado e pula a segunda reserva em vez de duplicar o trabalho.\n\n## Use o banco para aplicar regras, não só seu código\n\nTransações mantêm um fluxo multietapa junto, mas não fazem os dados estarem corretos automaticamente. Uma forma forte de evitar estados parcialmente gravados é tornar estados "errados" difíceis ou impossíveis no banco, mesmo se um bug passar no código da aplicação.\n\nComece com trilhos básicos de segurança. Foreign keys garantem que referências são reais (uma linha de item não pode apontar para um pedido inexistente). NOT NULL evita linhas pela metade. CHECK constraints detectam valores sem sentido (por exemplo, quantity > 0, total_cents >= 0). Essas regras rodam em toda escrita, não importa qual serviço ou script mexa no banco.\n\nPara fluxos mais longos, modele mudanças de estado explicitamente. Em vez de muitas flags booleanas, use uma coluna de status única (pending, paid, shipped, canceled) e permita só transições válidas. Você pode aplicar isso com constraints ou triggers para que o banco recuse saltos ilegais como shipped -> pending.\n\nUnicidade é outra forma de correção. Adicione constraints únicas onde duplicatas quebrariam seu fluxo: order_number, invoice_number ou uma idempotency_key usada para retries. Aí, se sua app reenviar o mesmo request, o Postgres vai bloquear a segunda inserção e você pode retornar "já processado" em vez de criar outro pedido.\n\nQuando precisar de rastreabilidade, armazene isso explicitamente. Uma tabela de auditoria (ou history) que registra quem mudou o quê e quando transforma "updates misteriosos" em fatos que você pode consultar durante incidentes.\n\n## Erros comuns que causam gravações parciais\n\nA maioria das gravações parciais não vem de "SQL ruim." Vem de decisões de fluxo que facilitam o commit de apenas metade da história.\n\n### As armadilhas que aparecem em apps reais\n\n- Fazer trabalho externo lento enquanto a transação está aberta. Chamar um provedor de pagamento, enviar email ou fazer upload dentro da transação segura locks por mais tempo que o necessário. Se a API estiver lenta ou der timeout, outros usuários ficam na fila atrás da sua transação aberta.\n- Ler fora da transação e depois escrever mais tarde. Exemplo: você busca o saldo do usuário, mostra na tela e depois debita com base naquele valor antigo. Outra sessão pode ter alterado o saldo nesse meio tempo.\n- Capturar um erro mas ainda assim commitar algo. Um padrão comum é "tentar passo 1, tentar passo 2, logar o erro, retornar sucesso mesmo assim." Se o código chegar ao COMMIT após uma falha, você acabou de deixar o banco inconsistente de propósito.\n- Atualizar tabelas em ordens diferentes em caminhos de código distintos. Se um request atualiza accounts depois orders, mas outro faz orders depois accounts, você aumenta a chance de deadlocks sob carga.\n- Manter transações abertas por muito tempo. Transações longas podem bloquear escritas, atrasar limpeza do vacuum e criar timeouts confusos.\n\nUm exemplo concreto: no checkout, você reserva estoque, cria um pedido e então cobra o cartão. Se você cobrar dentro da mesma transação, pode segurar um lock de inventário enquanto espera a rede. Se a cobrança tiver sucesso mas sua transação depois der rollback, você cobrou o cliente sem criar um pedido.\n\nUm padrão mais seguro é: mantenha a transação focada no estado do banco (reservar estoque, criar pedido, registrar pagamento pendente), commit, depois chame a API externa, e então grave o resultado em uma nova transação curta. Muitas equipes implementam isso com um status pendente e um job em background.\n\n## Checklist rápido para operações tudo-ou-nada\n\nQuando um fluxo tem múltiplos passos (inserir, atualizar, cobrar, enviar), o objetivo é simples: ou tudo é registrado, ou nada é.\n\n### Limites de transação\n\nMantenha todas as escritas de banco necessárias dentro de uma transação. Se um passo falhar, faça rollback e deixe os dados exatamente como estavam.\n\nTorne a condição de sucesso explícita. Por exemplo: "Pedido criado, estoque reservado e status de pagamento registrado." Qualquer outra coisa é caminho de falha que deve abortar a transação.\n\n- Todas as escritas necessárias acontecem dentro de um único bloco BEGIN ... COMMIT.\n- Existe um estado final claro no banco (não só na memória da app).\n- Qualquer erro leva a ROLLBACK e o chamador recebe um resultado de falha claro.\n\n### Trilhos de segurança (para que retries não prejudiquem)\n\nPressuponha que o mesmo request pode ser reenviado. O banco deve ajudar a aplicar regras de apenas-uma-vez.\n\n- Proteja ações únicas com constraints únicas (uma linha de pagamento por pedido, ou uma reserva por item por pedido).\n- Faça retries seguros e repetíveis (mesma entrada produz o mesmo estado final, sem duplicatas).\n\n### Mantenha transações curtas\n\nFaça o mínimo necessário dentro da transação e evite esperar por chamadas de rede enquanto segura locks.\n\n- Mantenha transações curtas e defina timeout para que não fiquem penduradas.\n- Faça trabalho lento (como chamar um provedor de pagamento) fora da transação e depois registre o resultado em uma nova transação curta.\n\n### Observe falhas\n\nSe você não consegue ver onde quebra, vai continuar chutando.\n\n- Registre o passo do fluxo e um request id para cada falha.\n- Monitore taxas de rollback e timeouts de lock para detectar riscos de gravação parcial cedo.\n\n## Exemplo: um fluxo de checkout que se mantém consistente sob falhas\n\nUm checkout tem vários passos que devem avançar juntos: criar o pedido, reservar estoque, registrar a tentativa de pagamento e então marcar o status do pedido.\n\nImagine um usuário clica em Comprar para 1 item.\n\n### Um fluxo seguro (trabalho DB como uma unidade)\n\nDentro de uma transação, faça apenas mudanças no banco:\n\n- Insira uma linha em orders com status pending_payment.\n- Reserve estoque (por exemplo, decremente inventory.available ou crie uma linha em reservations).\n- Insira uma linha em payment_intents com um idempotency_key fornecido pelo cliente (único).\n- Insira uma linha em outbox como "order_created".\n\nSe qualquer instrução falhar (sem estoque, erro de constraint, queda), o Postgres faz rollback de toda a transação. Você não fica com um pedido sem reserva, ou uma reserva sem pedido.\n\n### E se o pagamento falhar no meio do caminho?\n\nO provedor de pagamento está fora do seu banco, então trate como passo separado.\n\nSe a chamada ao provedor falhar antes do commit, aborte a transação e nada é gravado. Se a chamada falhar depois do commit, rode uma nova transação que marca a tentativa de pagamento como falhada, libera a reserva e define o status do pedido como cancelado.\n\n### Retry sem criar um segundo pedido\n\nPeça ao cliente para enviar um idempotency_key por tentativa de checkout. Enforce isso com um índice único em payment_intents(idempotency_key) (ou em orders, se preferir). No retry, seu código busca as linhas existentes e continua em vez de inserir um novo pedido.\n\n### Emails e notificações\n\nNão envie emails dentro da transação. Grave um registro de outbox na mesma transação e deixe um worker em background enviar o email após o commit. Assim você nunca envia email por um pedido que foi rollbackado.\n\n## Próximos passos: aplique isso a um fluxo esta semana\n\nEscolha um fluxo que toque mais de uma tabela: cadastro + enfileiramento de email de boas-vindas, checkout + inventário, fatura + lançamento no razão, ou criar projeto + configurações padrão.\n\nEscreva os passos primeiro, depois as regras que devem ser sempre verdade (seus invariantes). Exemplo: "Um pedido ou está totalmente pago e reservado, ou não está pago e não está reservado. Nunca meio-reservado." Transforme essas regras em uma unidade tudo-ou-nada.\n\nUm plano simples:\n\n- Liste as operações SQL exatas na ordem (leitura, inserts, updates, deletes).\n- Adicione as constraints de banco que faltam primeiro (chaves únicas, foreign keys, check constraints).\n- Acrescente uma chave de idempotência para a requisição para que retries não criem duplicatas.\n- Envolva os passos em uma transação e torne o ponto de sucesso explícito (commit apenas quando todas as checagens passarem).\n- Decida como é um retry seguro (mesma idempotency key, mesmo resultado).\n\nDepois teste os casos feios de propósito. Simule uma queda depois do passo 2, um timeout bem antes do commit e um duplo envio da UI. O objetivo é resultados sem surpresas: sem linhas órfãs, sem cobranças duplas, sem pendências eternas.\n\nSe você está prototipando rápido, ajuda esboçar o fluxo em uma ferramenta de planejamento antes de gerar handlers e esquema. Por exemplo, Koder.ai tem um Modo de Planejamento e suporta snapshots e rollback, o que pode ser útil enquanto você itera sobre limites de transação e constraints.\n\nFaça isso para um fluxo esta semana. O segundo será muito mais rápido.