Transações
Esta aula ensina como trabalhar com transações em Go, cobrindo desde o uso básico de Begin, Commit e Rollback, até padrões avançados, níveis de isolamento e tratamento de erros. O conteúdo inclui exemplos práticos com o pacote database/sql e boas práticas para garantir consistência e robustez em aplicações que interagem com bancos de dados.
Transações são um conceito fundamental em sistemas que interagem com bancos de dados, garantindo que um conjunto de operações seja executado de forma atômica, consistente, isolada e durável (ACID). Em Go, o pacote padrão database/sql fornece suporte nativo para transações, permitindo que os desenvolvedores controlem explicitamente o início, a confirmação e o rollback de operações. Nesta aula, vamos explorar como usar transações em Go, desde os conceitos básicos até padrões mais avançados, incluindo isolamento e tratamento de erros.
Dominar transações é essencial para garantir a integridade dos dados em aplicações reais, como sistemas de pagamento, reservas e qualquer operação que envolva múltiplas escritas. Veremos como evitar problemas comuns, como concorrência e inconsistências, e como escrever código limpo e seguro.
Begin, Commit, Rollback
O pacote database/sql representa uma transação através do tipo *sql.Tx. Para iniciar uma transação, usamos o método Begin() do objeto *sql.DB. Esse método retorna um *sql.Tx que pode ser usado para executar consultas e comandos dentro da transação. Depois de executar as operações desejadas, podemos chamar Commit() para persistir as alterações ou Rollback() para desfazê-las.
É importante notar que, ao usar Begin(), a conexão com o banco de dados é reservada para a transação até que ela seja finalizada. Portanto, é crucial garantir que Commit() ou Rollback() sejam chamados, mesmo em caso de erro, para evitar vazamento de conexões. O padrão idiomático em Go é usar defer para garantir o rollback caso o commit não seja executado.
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq" // driver PostgreSQL
)
func main() {
db, err := sql.Open("postgres", "user=postgres dbname=test sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Inicia uma transação
tx, err := db.Begin()
if err != nil {
log.Fatal(err)
}
// Garante rollback se a transação não for commitada
defer tx.Rollback()
// Executa operações na transação
_, err = tx.Exec("INSERT INTO users (name) VALUES ($1)", "Alice")
if err != nil {
log.Fatal(err) // rollback será chamado via defer
}
_, err = tx.Exec("UPDATE accounts SET balance = balance - 100 WHERE id = $1", 1)
if err != nil {
log.Fatal(err)
}
// Confirma a transação
if err = tx.Commit(); err != nil {
log.Fatal(err)
}
}No exemplo acima, usamos defer tx.Rollback() logo após iniciar a transação. Isso garante que, se qualquer erro ocorrer antes do Commit(), o rollback será executado automaticamente. Quando o Commit() é bem-sucedido, o Rollback() posterior se torna um no-op, pois a transação já foi finalizada.
Uma prática comum é encapsular a lógica de transação em uma função que recebe um *sql.Tx como parâmetro, permitindo que o controle de commit/rollback fique centralizado. Isso ajuda a evitar erros e melhora a legibilidade.
Padrões
Existem vários padrões para lidar com transações em Go. Um dos mais comuns é o uso de funções auxiliares que gerenciam o ciclo de vida da transação, executando uma função de callback dentro de uma transação e cuidando do commit/rollback automaticamente.
Por exemplo, podemos criar uma função WithTransaction que recebe um *sql.DB e uma função que recebe um *sql.Tx. A função auxiliar inicia a transação, executa o callback, e faz o commit se o callback retornar nil, ou rollback se retornar erro. Isso elimina a repetição de código e garante que o rollback seja sempre chamado.
func WithTransaction(db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}
// Uso
func transfer(db *sql.DB, fromID, toID int, amount float64) error {
return WithTransaction(db, func(tx *sql.Tx) error {
// Debitar
_, err := tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
return err
}
// Creditar
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
return err
}
return nil
})
}Outro padrão é usar transações com context.Context para controle de cancelamento e timeouts. O pacote database/sql oferece métodos que aceitam contexto, como BeginTx, ExecContext, etc. Isso é importante para aplicações web que precisam lidar com requisições que podem ser canceladas.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Use tx.ExecContext(ctx, ...) para operações
Além disso, é recomendável usar defer tx.Rollback() mesmo quando se espera commit, pois o rollback em uma transação já commitada é inofensivo e protege contra esquecimentos.
Isolamento
O nível de isolamento de uma transação define como ela interage com outras transações concorrentes. O pacote database/sql permite especificar o nível de isolamento ao iniciar uma transação com BeginTx, usando a opção *sql.TxOptions. Os níveis suportados dependem do driver, mas os padrões incluem:
sql.LevelReadUncommittedsql.LevelReadCommittedsql.LevelWriteCommitted(alguns bancos)sql.LevelRepeatableReadsql.LevelSerializablesql.LevelSnapshot(alguns bancos)sql.LevelDefault(usado quando nil)
O nível LevelDefault usa o padrão do banco de dados (geralmente ReadCommitted no PostgreSQL). Para escolher um nível específico, passamos TxOptions com o campo Isolation preenchido. Por exemplo:
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
log.Fatal(err)
}
Entender os níveis de isolamento é crucial para evitar problemas como leituras sujas, leituras não repetíveis e fantasmas. Por exemplo, LevelReadCommitted evita leituras sujas, mas permite leituras não repetíveis. Já LevelSerializable garante o isolamento mais forte, mas pode reduzir a concorrência.
Na prática, para a maioria das aplicações, o nível padrão é suficiente, mas em cenários que exigem consistência estrita, como operações financeiras, pode ser necessário aumentar o isolamento.
Tratamento de erros
O tratamento de erros em transações é fundamental. Devemos sempre verificar os erros retornados por cada operação e decidir se devemos fazer rollback ou tentar recuperar. No padrão com defer tx.Rollback(), qualquer erro que retorne da função de callback fará o rollback automaticamente.
Além disso, é importante lidar com erros específicos, como conflitos de serialização (código 40001 no PostgreSQL). Nesses casos, pode ser útil tentar novamente a transação. Vamos ver um exemplo de retry com backoff exponencial:
func executeWithRetry(db *sql.DB, fn func(tx *sql.Tx) error) error {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
err := WithTransaction(db, fn)
if err == nil {
return nil
}
// Verifica se é erro de serialização
if isSerializationError(err) {
// Espera antes de tentar novamente
time.Sleep(time.Duration(i+1) * 100 * time.Millisecond)
continue
}
return err
}
return fmt.Errorf("max retries reached")
}
func isSerializationError(err error) bool {
var pgErr *pq.Error
if errors.As(err, &pgErr) {
return pgErr.Code == "40001"
}
return false
}
Outro aspecto é o uso de context.Context para propagar cancelamentos e timeouts. Se o contexto for cancelado durante uma transação, as operações devem retornar erro e a transação deve ser revertida.
Também é importante lembrar que, ao usar transações, devemos evitar segurar transações abertas por muito tempo, pois isso pode bloquear recursos do banco. Sempre finalize a transação o mais rápido possível.
Boas práticas
- Sempre chame
Rollback()ouCommit()explicitamente; usedeferpara garantir. - Use
BeginTxcom contexto para cancelamento e timeouts. - Prefira funções auxiliares para encapsular a lógica de transação.
- Evite transações longas; mantenha-as curtas e eficientes.
- Escolha o nível de isolamento adequado ao caso de uso.
- Trate erros de forma robusta, incluindo retries para conflitos de concorrência.
Referências
- Pacote database/sql - Documentação oficial
- Go Database/SQL Tutorial
- PostgreSQL: Transaction Isolation
- lib/pq - Driver PostgreSQL
- Go: Changing data with transactions
- CockroachDB: Transactions
Exercícios
Escreva uma função em Go que transfira dinheiro entre duas contas usando uma transação. A função deve receber os IDs das contas e o valor, e usar `Begin`, `Commit` e `Rollback` corretamente. Inclua tratamento de erros.
✓ Resposta: Veja um exemplo de implementação:func transfer(db *sql.DB, fromID, toID int, amount float64) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID) if err != nil { return err } _, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID) if err != nil { return err } return tx.Commit() }Explique a diferença entre `ReadCommitted` e `Serializable` no contexto de isolamento de transações. Dê um exemplo prático onde `Serializable` seria necessário.
✓ Resposta: `ReadCommitted` permite que uma transação veja apenas dados commitados, mas não impede que outra transação modifique dados entre leituras, causando leituras não repetíveis. `Serializable` garante que as transações sejam executadas como se fossem sequenciais, prevenindo leituras não repetíveis e fantasmas. Por exemplo, em um sistema de reserva de assentos, se duas transações verificarem assentos disponíveis simultaneamente e depois reservarem, `Serializable` evita que ambos reservem o mesmo assento.Crie um padrão de transação com retry para erros de serialização, usando backoff exponencial. Inclua a lógica para identificar o erro de serialização (por exemplo, código 40001 no PostgreSQL).
✓ Resposta: Um exemplo de função com retry:func withRetry(db *sql.DB, fn func(tx *sql.Tx) error) error { maxRetries := 3 for i := 0; i < maxRetries; i++ { err := WithTransaction(db, fn) if err == nil { return nil } if isSerializationError(err) { time.Sleep(time.Duration(i+1) * 100 * time.Millisecond) continue } return err } return fmt.Errorf("max retries reached") } func isSerializationError(err error) bool { var pgErr *pq.Error if errors.As(err, &pgErr) { return pgErr.Code == "40001" } return false }Escreva um exemplo de uso de `BeginTx` com `context.Context` para definir um timeout de 2 segundos. Mostre como o contexto afeta as operações.
✓ Resposta: Exemplo:ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() tx, err := db.BeginTx(ctx, nil) if err != nil { log.Fatal(err) } defer tx.Rollback() // Se a operação demorar mais que 2s, o contexto cancela e a operação falha. _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = $1", 1) if err != nil { log.Fatal(err) } if err := tx.Commit(); err != nil { log.Fatal(err) }Descreva um cenário onde o uso de `defer tx.Rollback()` pode evitar problemas de vazamento de conexão, mesmo quando o `Commit` é chamado. Explique o comportamento.
✓ Resposta: O `defer tx.Rollback()` é chamado quando a função retorna, mesmo que o `Commit` já tenha sido executado. Após o `Commit`, o `Rollback` é um no-op, mas se houver um erro no `Commit` ou se o `Commit` não for chamado, o `Rollback` libera a conexão. Isso garante que a conexão não fique presa, evitando vazamento de conexões no pool.