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.LevelReadUncommitted
  • sql.LevelReadCommitted
  • sql.LevelWriteCommitted (alguns bancos)
  • sql.LevelRepeatableRead
  • sql.LevelSerializable
  • sql.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() ou Commit() explicitamente; use defer para garantir.
  • Use BeginTx com 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

Exercícios

  1. 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()
    }
  2. 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.
  3. 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
    }
  4. 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)
    }
  5. 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.