Transações PostgreSQL em Go parecem simples no primeiro CRUD: abre BEGIN, roda duas queries, chama COMMIT e pronto. Em produção, a história muda. A mesma transação passa a disputar locks, esperar índice, concorrer com workers, lidar com timeout, receber erro de serialização, causar deadlock ou segurar conexão do pool por tempo demais.
Esse é um dos pontos que separa projeto de tutorial de backend que aguenta tráfego real. Go dá ferramentas ótimas para escrever serviços claros e concorrentes, mas o banco continua sendo o lugar onde consistência, ordem e disputa por recurso aparecem de verdade. Se você trabalha com APIs financeiras, marketplace, assinatura, estoque, filas, pagamentos, auditoria ou qualquer sistema com estado compartilhado, entender transações é obrigatório.
Este guia mostra como pensar em transações PostgreSQL em Go usando pgx, pgxpool, context.Context, locks explícitos e retry seguro. Ele complementa Go com PostgreSQL, pgxpool em produção, sqlc em Go, migrations em Go e outbox pattern em Go.
A regra básica: transação curta e com dono claro
Uma transação deve proteger uma mudança de estado que precisa ser atômica. Ela não é um lugar para fazer tudo que acontece em uma requisição. Dentro dela, evite chamada HTTP externa, publicação em broker, envio de e-mail, sleep, processamento pesado ou leitura de arquivo grande. Quanto mais tempo a transação fica aberta, maior a chance de segurar lock, ocupar conexão do pool e bloquear outras partes do sistema.
O formato básico com pgxpool é:
func transferir(ctx context.Context, pool *pgxpool.Pool, de, para int64, valor int64) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
if err := debitar(ctx, tx, de, valor); err != nil {
return err
}
if err := creditar(ctx, tx, para, valor); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
O defer tx.Rollback(ctx) é limpeza. Depois de Commit, o rollback não desfaz nada; normalmente retorna erro de transação fechada e pode ser ignorado. O ponto importante é garantir que qualquer retorno antecipado não deixe transação aberta.
Read committed é o padrão, não uma garantia mágica
No PostgreSQL, o nível padrão de isolamento é READ COMMITTED. Ele é bom para muitos casos, mas não significa que sua regra de negócio está automaticamente protegida contra concorrência. Cada statement enxerga dados confirmados naquele momento. Entre uma leitura e uma escrita, outra transação pode mudar o estado.
Considere um fluxo ingênuo de reserva de estoque:
SELECT quantidade FROM estoque WHERE produto_id = $1;
-- aplicação verifica se quantidade >= 1
UPDATE estoque SET quantidade = quantidade - 1 WHERE produto_id = $1;
Com duas requisições concorrentes, ambas podem ler o mesmo saldo e tentar atualizar. Às vezes o resultado ainda fica correto se o UPDATE for atômico e tiver condição adequada. Mas se a lógica depende da leitura anterior, você criou uma janela de corrida.
Uma forma mais segura é transformar a condição em parte do UPDATE:
UPDATE estoque
SET quantidade = quantidade - $2
WHERE produto_id = $1
AND quantidade >= $2
RETURNING quantidade;
Se não retornar linha, não havia estoque suficiente. Esse padrão reduz a necessidade de lock explícito e deixa o banco validar a regra em uma operação só.
Quando usar SELECT FOR UPDATE
SELECT FOR UPDATE bloqueia as linhas lidas para atualização por outras transações. Use quando você precisa ler estado, tomar decisão e depois escrever com base no mesmo estado.
Exemplo com conta corrente:
SELECT id, saldo
FROM contas
WHERE id = $1
FOR UPDATE;
Enquanto a transação atual não terminar, outra transação que tente bloquear a mesma conta vai esperar. Isso é útil para evitar duas transferências gastando o mesmo saldo ao mesmo tempo. Também é perigoso se você bloquear linhas em ordem inconsistente.
Se uma operação bloqueia conta A e depois conta B, e outra bloqueia conta B e depois conta A, você pode criar deadlock. A correção prática é sempre bloquear na mesma ordem. Para transferências, ordene os IDs antes:
primeira, segunda := de, para
if segunda < primeira {
primeira, segunda = segunda, primeira
}
Depois busque as duas linhas com FOR UPDATE nessa ordem. Esse detalhe pequeno evita muitos incidentes difíceis de reproduzir.
SKIP LOCKED para workers concorrentes
Quando múltiplos workers precisam pegar tarefas de uma tabela, FOR UPDATE SKIP LOCKED é uma ferramenta excelente. Ela permite que cada worker pule linhas já bloqueadas por outro worker e pegue trabalho disponível sem ficar parado disputando a mesma tarefa.
SELECT id, payload
FROM jobs
WHERE status = 'pending'
AND run_at <= now()
ORDER BY run_at, id
LIMIT 50
FOR UPDATE SKIP LOCKED;
Esse select deve rodar dentro de uma transação curta. O worker seleciona as linhas, marca como processing ou registra tentativa, confirma a transação e só então executa o trabalho pesado. Não segure a transação aberta enquanto chama API externa ou processa payload por minutos.
Esse padrão conversa bem com worker pools em Go, idempotência, retry e DLQ e outbox pattern. O banco coordena a posse do trabalho; a aplicação executa com retry e observabilidade fora do lock.
Timeouts de contexto e lock timeout
Todo código Go que acessa banco deve receber context.Context. Em transações, isso é ainda mais importante. Se a requisição HTTP foi cancelada, não faz sentido continuar segurando lock no banco.
Além do timeout de contexto, considere configurar lock_timeout por transação quando a operação não deve esperar muito por lock:
SET LOCAL lock_timeout = '500ms';
SET LOCAL statement_timeout = '2s';
SET LOCAL vale apenas para a transação atual. Isso evita que uma operação de usuário fique pendurada indefinidamente atrás de uma migration, relatório ou transação lenta. Para endpoints críticos, falhar rápido pode ser melhor do que acumular goroutines esperando conexão e saturar o pool PostgreSQL.
Não use timeouts aleatórios. Meça query lenta, pg_stat_activity, espera por lock e latência do endpoint. Um timeout curto demais pode transformar uma operação legítima em erro intermitente.
Retry: só para erros corretos
Nem todo erro de transação deve receber retry. Erro de validação, saldo insuficiente, chave estrangeira ausente ou payload inválido não melhora tentando de novo. Retry faz sentido para conflitos transitórios: serialização, deadlock, falha temporária de conexão ou lock timeout, dependendo da regra de negócio.
No PostgreSQL, códigos úteis incluem:
40001 serialization_failure
40P01 deadlock_detected
55P03 lock_not_available
Com pgx, você pode inspecionar pgconn.PgError:
func retryablePostgres(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == "40001" || pgErr.Code == "40P01" || pgErr.Code == "55P03"
}
O retry deve reexecutar a transação inteira, não apenas o Commit. Se uma transação falhou por conflito, o estado lido anteriormente pode não ser mais válido. Reabra a transação, releia os dados e recalcule a decisão.
Use limite de tentativas e backoff curto:
for tentativa := 1; tentativa <= 3; tentativa++ {
err := executarTransacao(ctx)
if err == nil {
return nil
}
if !retryablePostgres(err) || tentativa == 3 {
return err
}
time.Sleep(time.Duration(tentativa) * 50 * time.Millisecond)
}
Em APIs públicas, combine isso com idempotência. Se o cliente repetir a mesma cobrança, reserva ou criação de pedido, a aplicação precisa reconhecer a chave idempotente e não duplicar o efeito.
Serializable não substitui modelagem
SERIALIZABLE é o nível de isolamento mais forte do PostgreSQL. Ele tenta fazer transações concorrentes se comportarem como se fossem executadas em alguma ordem serial. Isso ajuda em regras complexas, mas vem com custo: mais conflitos e necessidade real de retry.
Use quando a regra depende de invariantes que não cabem bem em um único UPDATE, constraint ou lock de linha. Mesmo assim, prefira primeiro modelar bem: constraints, índices únicos, CHECK, chaves estrangeiras, updates condicionais e locks explícitos resolvem muitos casos com menos surpresa.
Uma reserva de username, por exemplo, não precisa de SERIALIZABLE; precisa de índice único em lower(username) e tratamento claro de erro de conflito. Uma regra financeira que soma múltiplas linhas e valida limite global talvez precise de isolamento mais forte ou de uma linha agregadora bloqueada.
Teste concorrência de verdade
Mocks não encontram deadlock, lock wait ou erro de serialização. Para fluxos críticos, escreva pelo menos um teste de integração contra PostgreSQL real. Use Testcontainers em Go ou um banco efêmero no CI, aplique migrations reais e dispare operações concorrentes.
Um bom teste tenta provar comportamento, não implementação:
- Duas reservas concorrentes não deixam estoque negativo.
- Duas transferências opostas não entram em deadlock recorrente.
- Dois workers não processam o mesmo job quando usam
SKIP LOCKED. - Retry de
40001reexecuta a transação inteira. - Timeout cancela a operação e libera conexão.
Esses testes são especialmente úteis para quem busca vagas Go backend ou posições sênior remotas. Muitas entrevistas brasileiras perguntam sobre banco, concorrência, filas e produção porque esses bugs custam dinheiro de verdade.
Checklist prático
Antes de colocar uma transação PostgreSQL em produção com Go, confira:
- A transação é curta e não chama serviços externos enquanto segura lock.
- Todo caminho de erro executa rollback ou deixa o
defer Rollbacklimpar. - As linhas são bloqueadas sempre em ordem consistente.
- Updates condicionais substituem leitura seguida de escrita quando possível.
context.Context,statement_timeoutelock_timeouttêm valores coerentes.- Retry existe apenas para erros transitórios e reexecuta a transação inteira.
- Constraints do banco protegem regras que não podem depender só da aplicação.
- Testes de integração cobrem concorrência real nos fluxos críticos.
Transação boa não é a mais sofisticada. É a que deixa claro qual invariável protege, segura o menor número de locks pelo menor tempo possível e falha de um jeito que o sistema consegue recuperar. Esse tipo de maturidade aparece no código, nos testes, no deploy e até no currículo. Se você acompanha oportunidades em outras stacks também, o eu.dev.br reúne vagas de tecnologia no Brasil e ajuda a comparar como PostgreSQL, filas e backend aparecem fora do ecossistema Go.
Go e PostgreSQL formam uma combinação forte para backend brasileiro: linguagem simples, banco confiável e ferramentas diretas. Mas simplicidade não significa ignorar concorrência. Trate transações como parte do desenho do produto, não como detalhe de repositório. Quando locks, isolamento e retry estão explícitos, o serviço fica mais previsível para usuários, operadores e pessoas desenvolvedoras que vão manter o sistema depois de você.