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();
}
}import psycopg
class EstoqueEsgotado(Exception):
pass
def comprar(conexao: psycopg.Connection, cliente_id: int, produto_id: int) -> int:
# Confirma ao sair do bloco; desfaz se houver exceção
with conexao.transaction():
with conexao.cursor() as cursor:
cursor.execute(
"""UPDATE produtos SET estoque = estoque - 1
WHERE id = %s AND estoque >= 1""",
(produto_id,),
)
if cursor.rowcount == 0:
raise EstoqueEsgotado()
cursor.execute(
"""INSERT INTO pedidos (cliente_id, produto_id, status)
VALUES (%s, %s, 'pago') RETURNING id""",
(cliente_id, produto_id),
)
return cursor.fetchone()[0]package loja
import (
"context"
"database/sql"
"errors"
)
var ErrEstoqueEsgotado = errors.New("estoque esgotado")
func Comprar(ctx context.Context, db *sql.DB, clienteID, produtoID int64) (int64, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() // não faz nada se o Commit já aconteceu
baixa, err := tx.ExecContext(ctx,
`UPDATE produtos SET estoque = estoque - 1
WHERE id = $1 AND estoque >= 1`,
produtoID,
)
if err != nil {
return 0, err
}
if linhas, _ := baixa.RowsAffected(); linhas == 0 {
return 0, ErrEstoqueEsgotado
}
var pedidoID int64
err = tx.QueryRowContext(ctx,
`INSERT INTO pedidos (cliente_id, produto_id, status)
VALUES ($1, $2, 'pago') RETURNING id`,
clienteID, produtoID,
).Scan(&pedidoID)
if err != nil {
return 0, err
}
return pedidoID, tx.Commit()
}import java.sql.Connection;
import java.sql.SQLException;
public class Loja {
public static class EstoqueEsgotado extends Exception {}
public long comprar(Connection conexao, long clienteId, long produtoId)
throws SQLException, EstoqueEsgotado {
conexao.setAutoCommit(false);
try (var baixa = conexao.prepareStatement(
"UPDATE produtos SET estoque = estoque - 1 WHERE id = ? AND estoque >= 1");
var pedido = conexao.prepareStatement(
"INSERT INTO pedidos (cliente_id, produto_id, status) VALUES (?, ?, 'pago') RETURNING id")) {
baixa.setLong(1, produtoId);
if (baixa.executeUpdate() == 0) {
throw new EstoqueEsgotado();
}
pedido.setLong(1, clienteId);
pedido.setLong(2, produtoId);
long pedidoId;
try (var resultado = pedido.executeQuery()) {
resultado.next();
pedidoId = resultado.getLong(1);
}
conexao.commit();
return pedidoId;
} catch (SQLException | EstoqueEsgotado erro) {
conexao.rollback();
throw erro;
} finally {
conexao.setAutoCommit(true);
}
}
}using Npgsql;
public class EstoqueEsgotadoException : Exception;
public class Loja(NpgsqlDataSource dados)
{
public async Task<long> Comprar(long clienteId, long produtoId)
{
await using var conexao = await dados.OpenConnectionAsync();
// Sem CommitAsync, o descarte da transação desfaz tudo
await using var transacao = await conexao.BeginTransactionAsync();
await using var baixa = new NpgsqlCommand(
"UPDATE produtos SET estoque = estoque - 1 WHERE id = $1 AND estoque >= 1",
conexao, transacao)
{
Parameters = { new() { Value = produtoId } },
};
if (await baixa.ExecuteNonQueryAsync() == 0)
{
throw new EstoqueEsgotadoException();
}
await using var pedido = new NpgsqlCommand(
"INSERT INTO pedidos (cliente_id, produto_id, status) VALUES ($1, $2, 'pago') RETURNING id",
conexao, transacao)
{
Parameters =
{
new() { Value = clienteId },
new() { Value = produtoId },
},
};
var pedidoId = (long)(await pedido.ExecuteScalarAsync())!;
await transacao.CommitAsync();
return pedidoId;
}
}O que muda de uma linguagem para outra
| Linhas alteradas | Como a transação é desfeita | |
|---|---|---|
| Node | rowCount no resultado | ROLLBACK explícito no catch |
| Python | cursor.rowcount | Automático, se o bloco with lançar exceção |
| Go | RowsAffected() no resultado | defer tx.Rollback(), sem efeito depois do Commit |
| Java | Retorno 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 UPDATEou 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,UNIQUEe 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]