DevGuideTK
← Trilha Backend

Transações e ACID

As garantias que impedem um sistema de ficar com dados pela metade quando algo falha, e de vender o mesmo item duas vezes.

Os exemplos usam a mesma tabela de produtos do nível Conceito, com CHECK (estoque >= 0), e o driver mais comum de cada linguagem para o PostgreSQL.

Comprar com gravação condicional

A compra baixa o estoque com uma gravação condicional e só cria o pedido se a baixa funcionou. Tudo numa transação: se a criação do pedido falhar, a baixa de estoque é desfeita junto.

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export class EstoqueEsgotado extends Error {}

export async function comprar(clienteId: number, produtoId: number) {
  const conexao = await pool.connect();
  try {
    await conexao.query("BEGIN");

    const baixa = await conexao.query(
      `UPDATE produtos SET estoque = estoque - 1
        WHERE id = $1 AND estoque >= 1`,
      [produtoId],
    );
    if (baixa.rowCount === 0) {
      throw new EstoqueEsgotado();
    }

    const { rows } = await conexao.query(
      `INSERT INTO pedidos (cliente_id, produto_id, status)
       VALUES ($1, $2, 'pago') RETURNING id`,
      [clienteId, produtoId],
    );

    await conexao.query("COMMIT");
    return rows[0].id;
  } catch (erro) {
    await conexao.query("ROLLBACK");
    throw erro;
  } finally {
    conexao.release();
  }
}

O que muda de uma linguagem para outra

Linhas alteradasComo a transação é desfeita
NoderowCount no resultadoROLLBACK explícito no catch
Pythoncursor.rowcountAutomático, se o bloco with lançar exceção
GoRowsAffected() no resultadodefer tx.Rollback(), sem efeito depois do Commit
JavaRetorno de executeUpdate()rollback() explícito; setAutoCommit(true) antes de devolver a conexão
C#Retorno de ExecuteNonQueryAsync()Automático, ao descartar a transação sem Commit

O ponto de atenção comum às cinco: conferir o número de linhas alteradas. Uma gravação condicional que não afeta nenhuma linha não é um erro para o banco. Se o código não olhar o contador, segue em frente e cria um pedido sem estoque.

O mesmo problema escrito do jeito errado

Esta é a versão que um assistente costuma gerar quando o pedido não menciona concorrência. Ela funciona em todos os testes com um usuário:

// ERRADO sob concorrência: lê, decide no código e grava depois
const { rows } = await conexao.query(
  "SELECT estoque FROM produtos WHERE id = $1",
  [produtoId],
);
if (rows[0].estoque < 1) throw new EstoqueEsgotado();

await conexao.query(
  "UPDATE produtos SET estoque = $1 WHERE id = $2",
  [rows[0].estoque - 1, produtoId],
);

Com dois clientes simultâneos, os dois leem estoque 1 e os dois gravam 0. Foi o que aconteceu no teste com o PostgreSQL: as duas vendas foram confirmadas. Há duas formas de corrigir: trocar pela gravação condicional, ou acrescentar FOR UPDATE ao SELECT.

Repetir transações que falham por conflito

Em repeatable read e serializable, e também em impasses, o banco cancela uma das transações em conflito. A forma correta de tratar é repetir a transação inteira, algumas vezes, com um intervalo crescente.

const CODIGOS_PARA_REPETIR = new Set([
  "40001", // falha de serialização
  "40P01", // impasse (deadlock)
]);

export async function comNovasTentativas<T>(operacao: () => Promise<T>, tentativas = 3) {
  for (let i = 1; ; i++) {
    try {
      return await operacao();
    } catch (erro: any) {
      if (i >= tentativas || !CODIGOS_PARA_REPETIR.has(erro.code)) throw erro;
      await new Promise((r) => setTimeout(r, 20 * 2 ** i + Math.random() * 20));
    }
  }
}

// A transação inteira é repetida, e não só a instrução que falhou
await comNovasTentativas(() => comprar(clienteId, produtoId));

Cada driver expõe o código do erro de um jeito:

Onde fica o código 40001
Node (pg)erro.code
Python (psycopg)Exceção psycopg.errors.SerializationFailure, ou erro.sqlstate
Go (pgx)errors.As(err, &pgErr) com *pgconn.PgError, campo Code
Java (JDBC)SQLException.getSQLState()
C# (Npgsql)PostgresException.SqlState, comparado a PostgresErrorCodes.SerializationFailure

Lista de revisão

Atomicidade

  • Toda operação com várias gravações relacionadas está numa transação?
  • Todas as instruções da transação usam a mesma conexão?
  • Os erros desfazem a transação, inclusive os lançados pela própria aplicação?

Concorrência

  • Existe algum "ler, decidir no código, gravar" sem FOR UPDATE ou sem gravação condicional?
  • Gravações condicionais conferem o número de linhas alteradas?
  • Transações em repeatable read ou serializable são repetidas ao falhar com 40001?
  • As travas são feitas sempre na mesma ordem?

Escopo

  • A transação é curta e não contém chamadas a serviços externos?
  • As regras importantes estão declaradas no banco com CHECK, UNIQUE e chaves estrangeiras?
  • Efeitos externos acontecem só depois do COMMIT?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Reproduzir e corrigir uma venda duplicada

    Quero reproduzir de verdade o problema de dois clientes comprarem o último item ao mesmo tempo, e depois corrigi-lo.
    
    Pergunte em qual linguagem eu trabalho e qual banco eu uso.
    
    Vamos em etapas:
    1. Me ajude a subir o banco localmente com Docker e criar a tabela de produtos.
    2. Escreva comigo a versão ingênua da compra: ler, conferir e gravar.
    3. Escreva um teste que dispara vinte compras simultâneas para um produto com estoque cinco, e conte quantas vendas foram confirmadas. Peça que eu rode e me diga o resultado.
    4. Corrija com gravação condicional e rode o teste de novo.
    5. Faça uma segunda versão com SELECT FOR UPDATE e compare o tempo total das duas sob a mesma carga.
    6. Acrescente a restrição CHECK no banco e mostre o que acontece se a versão ingênua rodar de novo.
    
    Em cada etapa, explique o resultado antes de seguir.
  • Novas tentativas para erros de serialização

    Quero implementar corretamente a repetição de transações que falham por conflito.
    
    Pergunte qual linguagem, driver e banco eu uso.
    
    Explique, com código para o meu caso:
    1. Quais códigos de erro indicam que a transação pode ser repetida (falha de serialização e impasse), e como o meu driver expõe esses códigos.
    2. Por que a transação inteira precisa ser repetida, e não só a instrução que falhou.
    3. Um auxiliar de novas tentativas com limite de tentativas e intervalo exponencial com variação aleatória.
    4. Por que efeitos externos, como e-mails e chamadas HTTP, não podem estar dentro da função repetida.
    5. Como testar esse auxiliar, forçando o erro de serialização de propósito.
    
    No final, mostre como registrar em métricas quantas transações precisaram ser repetidas, para perceber quando a disputa aumenta.
  • Revisar o uso de transações

    Vou colar abaixo código que grava no banco de dados. Revise com foco em transações e concorrência.
    
    Verifique, nesta ordem, e para cada item diga se está correto, incorreto ou ausente, citando a linha:
    1. Operações com várias gravações relacionadas estão numa transação, na mesma conexão?
    2. Erros, inclusive os lançados pela própria aplicação, desfazem a transação?
    3. Há algum padrão "ler, decidir no código, gravar" sem trava ou sem gravação condicional?
    4. Gravações condicionais conferem o número de linhas alteradas?
    5. Há chamadas a serviços externos, e-mails ou publicação de mensagens dentro da transação?
    6. Transações que podem falhar por conflito são repetidas corretamente?
    7. As travas são obtidas sempre na mesma ordem?
    8. As regras importantes também estão declaradas no banco?
    
    Para cada problema, descreva o cenário concreto em que ele causa dados errados e mostre a correção. Não reescreva o que já está correto.
    
    Código:
    [cole aqui]