As transações PostgreSQL no Node.js agrupam várias operações em uma unidade atômica: todas são confirmadas ou nenhuma produz efeito. Elas são essenciais em transferências, pedidos, reservas, atualização de estoque e qualquer fluxo em que uma alteração parcial deixaria o sistema inconsistente.
Usar BEGIN, COMMIT e ROLLBACK parece simples, mas erros comuns tornam a proteção ineficaz. Uma transação precisa usar a mesma conexão do pool, deve permanecer curta, tratar falhas em todos os caminhos e escolher um nível de isolamento compatível com as regras de negócio.
Neste guia, você aprenderá a abrir transações com o driver pg, implementar helpers, usar savepoints, compreender isolamento, evitar deadlocks, aplicar retries seguros, integrar idempotência, testar concorrência e observar locks em produção.
O que é uma transação?
Uma transação delimita um conjunto de comandos SQL. A documentação oficial de transações do PostgreSQL apresenta os comandos básicos. A documentação oficial de isolamento explica os níveis e anomalias.
Para configurar conexões, consulte Pool PostgreSQL no Node.js. Para alterar schema com segurança, veja Migrações de Banco no Node.js.
Propriedades ACID
- Atomicidade: o conjunto inteiro confirma ou reverte.
- Consistência: constraints e regras mantêm o banco válido.
- Isolamento: operações concorrentes não observam estados intermediários indevidos.
- Durabilidade: após o commit, o resultado sobrevive a falhas conforme as garantias configuradas.
ACID não corrige regra de negócio mal implementada. A aplicação ainda precisa validar valores, autorização e invariantes.
Exemplo básico com pg
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, sourceAccountId]
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, destinationAccountId]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}Todas as queries usam o mesmo client. Usar pool.query() dentro da transação pode selecionar outra conexão e quebrar a atomicidade.
Por que a mesma conexão é obrigatória?
O estado da transação pertence à sessão PostgreSQL. BEGIN em uma conexão não afeta comandos enviados por outra. Por isso, obtenha um client exclusivo até commit ou rollback.
Helper reutilizável
async function withTransaction(pool, callback) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
error.rollbackError = rollbackError;
}
throw error;
} finally {
client.release();
}
}O helper reduz repetição, mas não deve esconder timeouts, isolamento ou logging necessários.
Usando o helper
const order = await withTransaction(pool, async client => {
const inventory = await reserveInventory(client, items);
const createdOrder = await insertOrder(client, input);
await insertOrderItems(client, createdOrder.id, items);
return {
...createdOrder,
inventory
};
});Repositories chamados dentro do callback precisam aceitar o client transacional.
Não abra transação em cada repository
A camada de serviço conhece a unidade de negócio. Se cada repository abre sua própria transação, o pedido pode confirmar enquanto o estoque falha.
Transações curtas
Não mantenha uma transação aberta durante:
- chamada HTTP externa;
- upload de arquivo;
- espera por input;
- processamento pesado;
- sleep ou backoff;
- publicação síncrona em broker;
- leitura de arquivo remoto.
Transações longas mantêm locks, versões antigas de linhas e conexões ocupadas.
Chamada externa antes ou depois?
Uma API de pagamento não participa da transação PostgreSQL. Evite manter locks enquanto aguarda o provedor. Use estados, idempotência e outbox para coordenar o fluxo.
Consulte Idempotência em APIs Node.js.
Outbox transacional
BEGIN;
INSERT INTO orders (...) RETURNING id;
INSERT INTO outbox_events (
aggregate_type,
aggregate_id,
event_type,
payload
) VALUES (
'order',
$1,
'order.created',
$2
);
COMMIT;Um worker publica eventos depois. Pedido e intenção de publicação são confirmados juntos.
Rollback automático após erro
Depois que uma query falha dentro de uma transação, o PostgreSQL marca a transação como abortada. Comandos posteriores falham até executar ROLLBACK ou rollback para savepoint.
Não ignore erro intermediário
try {
await client.query(sql);
} catch (error) {
logger.warn({ err: error });
}
await client.query('COMMIT');Esse padrão é incorreto. A operação falhou e a transação precisa ser revertida ou tratada com savepoint explícito.
Savepoints
await client.query('SAVEPOINT optional_step');
try {
await executeOptionalStep(client);
await client.query('RELEASE SAVEPOINT optional_step');
} catch (error) {
await client.query('ROLLBACK TO SAVEPOINT optional_step');
}Savepoint reverte parte da transação. Use apenas quando falha parcial é realmente permitida pela regra de negócio.
Transações aninhadas
PostgreSQL não oferece transações aninhadas independentes. Bibliotecas simulam aninhamento com savepoints. Defina ownership para evitar commit em uma camada interna.
Nível Read Committed
É o padrão do PostgreSQL. Cada comando vê um snapshot iniciado naquele comando. Duas consultas dentro da mesma transação podem observar resultados diferentes confirmados por concorrentes.
Repeatable Read
BEGIN ISOLATION LEVEL REPEATABLE READ;Todas as consultas usam um snapshot consistente. Em conflito com concorrência, uma transação pode falhar com erro de serialização e precisar ser repetida.
Serializable
BEGIN ISOLATION LEVEL SERIALIZABLE;Oferece comportamento equivalente a uma execução serial válida, mas pode abortar transações para preservar a garantia.
Escolhendo isolamento
- Read Committed: padrão eficiente para muitos CRUDs.
- Repeatable Read: snapshot estável e detecção de alguns conflitos.
- Serializable: invariantes complexas com retries planejados.
Isolamento maior não substitui constraints e pode reduzir throughput.
Definindo isolamento no helper
async function withSerializableTransaction(pool, callback) {
const client = await pool.connect();
try {
await client.query(
'BEGIN ISOLATION LEVEL SERIALIZABLE'
);
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}Retry de serialização
Erros com SQLSTATE 40001 podem ser repetidos:
async function runSerializable(operation) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await withSerializableTransaction(
pool,
operation
);
} catch (error) {
const retryable = error.code === '40001';
if (!retryable || attempt === 2) {
throw error;
}
await delay(backoffWithJitter(attempt));
}
}
}A operação precisa ser idempotente fora do banco. Não envie e-mail ou cobre cartão dentro do callback que pode repetir.
Deadlocks
Um deadlock ocorre quando transações aguardam locks umas das outras. O PostgreSQL detecta e aborta uma delas com SQLSTATE 40P01.
Ordem consistente de locks
Em transferência, bloqueie contas em ordem de ID:
const [firstId, secondId] = [sourceId, destinationId]
.sort((a, b) => a - b);
await client.query(
'SELECT id FROM accounts WHERE id IN ($1, $2) ORDER BY id FOR UPDATE',
[firstId, secondId]
);Todas as transações adquirem locks na mesma ordem.
SELECT FOR UPDATE
SELECT id, stock
FROM products
WHERE id = $1
FOR UPDATE;A linha fica bloqueada para atualizações concorrentes até o fim da transação.
FOR NO KEY UPDATE e outros modos
PostgreSQL oferece modos menos restritivos. Escolha o menor lock que protege a operação e consulte a documentação da versão.
SKIP LOCKED
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10;Workers concorrentes ignoram linhas já bloqueadas. É útil para filas simples, mas exige recuperação de jobs abandonados.
NOWAIT
SELECT id
FROM resources
WHERE id = $1
FOR UPDATE NOWAIT;Em vez de esperar, a query falha imediatamente se a linha está bloqueada.
Lock timeout
SET LOCAL lock_timeout = '2s';SET LOCAL vale apenas na transação atual.
Statement timeout
SET LOCAL statement_timeout = '5s';Limita cada comando. Um timeout aborta a transação e exige rollback.
Idle in transaction timeout
Configure no banco ou na sessão para encerrar conexões esquecidas em estado idle in transaction. Elas retêm snapshots e podem causar bloat.
Constraints como última defesa
Use UNIQUE, CHECK, FOREIGN KEY e NOT NULL. Verificar apenas no JavaScript possui race condition:
SELECT 1 FROM users WHERE email = $1;
// outra transação pode inserir aqui
INSERT INTO users (...);Uma constraint UNIQUE garante a regra.
Upsert
INSERT INTO counters (key, value)
VALUES ($1, 1)
ON CONFLICT (key)
DO UPDATE SET value = counters.value + 1
RETURNING value;Uma única instrução pode ser mais segura que SELECT seguido de UPDATE.
Update condicional
UPDATE products
SET stock = stock - $1
WHERE id = $2
AND stock >= $1
RETURNING stock;Se nenhuma linha retorna, não havia estoque suficiente. Isso reduz locks e round trips.
Lock otimista
Uma coluna de versão permite atualização condicional sem manter lock durante leitura. O próximo artigo sobre Lock Otimista aprofunda esse padrão.
Query builders
Kysely e Drizzle oferecem helpers de transação, mas os princípios continuam:
- mesma conexão;
- callback curto;
- rollback em erro;
- isolamento explícito;
- sem efeitos externos repetíveis.
Consulte Kysely com TypeScript e SQL e Drizzle ORM com PostgreSQL.
Prisma
Prisma oferece transações em lote e interativas. Transações interativas devem permanecer curtas e não realizar I/O externo.
Cancelamento do cliente
Se o cliente HTTP desconecta, a transação não deve necessariamente continuar. Propague AbortSignal quando o driver e a arquitetura permitirem, mas sempre execute rollback antes de liberar a conexão.
Graceful shutdown
Pare de aceitar novas operações, aguarde transações em andamento por prazo limitado e encerre o pool. Consulte Graceful Shutdown no Node.js.
Observabilidade
Registre:
- operação lógica;
- duração;
- commit ou rollback;
- tentativa de retry;
- SQLSTATE;
- tempo aguardando conexão;
- deadlock;
- timeout.
Não registre parâmetros sensíveis ou SQL completo com dados.
Métricas
Crie counters e histograms para commits, rollbacks, retries, deadlocks e duração. Consulte Métricas Prometheus no Node.js.
Transações longas no PostgreSQL
Consulte pg_stat_activity:
SELECT pid,
now() - xact_start AS transaction_age,
state,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;Restrinja acesso e sanitize queries ao expor diagnóstico.
Locks
pg_locks e pg_stat_activity ajudam a identificar quem bloqueia quem. Prepare uma consulta de runbook antes do incidente.
Testes unitários
Mocks não validam isolamento e locking reais. Use testes unitários para fluxos, mas execute integração em PostgreSQL.
Teste de rollback
test('reverte pedido se estoque falhar', async () => {
await assert.rejects(() =>
createOrderWithInsufficientStock(input)
);
const order = await findOrderByExternalId(input.externalId);
assert.equal(order, null);
});Teste concorrente
test('não vende estoque negativo', async () => {
const attempts = Array.from({ length: 20 }, () =>
reserveProduct(productId, 1)
);
await Promise.allSettled(attempts);
const product = await findProduct(productId);
assert.ok(product.stock >= 0);
});Teste de deadlock
Crie duas conexões, adquira locks em ordem oposta e confirme que a aplicação classifica e repete apenas quando seguro.
Teste de serializable
Execute transações concorrentes que violariam uma invariante em isolamento menor. Confirme que uma é abortada e o retry produz resultado correto.
Falhas simuladas
Injete erro depois da primeira query, antes do commit e durante rollback. Confirme que a conexão volta limpa ao pool.
Conexão quebrada
Se COMMIT falha por perda de conexão, o resultado pode ser ambíguo. Use idempotência e consulte o estado por chave de negócio antes de repetir.
Erros comuns
- pool.query dentro da transação: queries usam conexões diferentes.
- I/O externo dentro do BEGIN: locks ficam abertos.
- Erro ignorado: a transação permanece abortada.
- Retry de qualquer falha: efeitos externos duplicam.
- Sem ordem de locks: deadlocks aumentam.
- Transação em repository isolado: unidade de negócio fica parcial.
- Sem timeout: conexão fica presa.
Boas práticas
- Use a mesma conexão.
- Centralize BEGIN, COMMIT e ROLLBACK.
- Mantenha transações curtas.
- Use constraints.
- Escolha isolamento conscientemente.
- Repita apenas SQLSTATEs transitórios.
- Torne o callback livre de efeitos externos.
- Defina lock e statement timeout.
- Monitore duração e rollbacks.
- Teste concorrência no PostgreSQL real.
Conclusão
As transações PostgreSQL no Node.js protegem operações que precisam confirmar como uma unidade. O requisito fundamental é manter todas as queries na mesma conexão e executar rollback em qualquer falha.
Transações curtas, constraints, isolamento adequado e retries limitados reduzem anomalias e deadlocks. Com outbox, idempotência, timeouts e testes concorrentes, a aplicação mantém consistência mesmo quando múltiplas requisições disputam os mesmos dados ou uma conexão falha no pior momento.




