Transações são fundamentais para garantir a integridade e consistência de dados em aplicações que utilizam bancos de dados. Em Go, o pacote database/sql oferece suporte nativo a transações, permitindo que você agrupe múltiplas operações em uma unidade atômica, que pode ser confirmada (commit) ou desfeita (rollback) como um todo. Nesta aula, vamos explorar os conceitos e práticas essenciais para trabalhar com transações em Go, desde os comandos básicos até padrões avançados de tratamento de erros e isolamento.

Dominar transações é crucial para aplicações que lidam com dados críticos, como sistemas financeiros, e-commerce e qualquer cenário que exija consistência em operações de escrita. Vamos analisar como o pacote database/sql abstrai a complexidade do gerenciamento de transações, permitindo que você se concentre na lógica de negócio sem se preocupar com os detalhes de baixo nível do banco de dados.

Begin, Commit, Rollback

Em Go, uma transação é iniciada chamando o método Begin() do objeto *sql.DB. Esse método retorna um objeto *sql.Tx, que representa a transação. A partir desse ponto, todas as operações de banco de dados (consultas, inserções, atualizações) devem ser executadas por meio dos métodos do Tx, como Exec(), Query() e QueryRow(). Ao final, você deve chamar Commit() para confirmar as alterações ou Rollback() para desfazê-las.

O método Begin() também pode receber um contexto (context.Context) e opções de transação (*sql.TxOptions) para controlar o nível de isolamento e o modo de somente leitura, como veremos mais adiante. Quando você usa Begin(), o banco de dados inicia uma transação subjacente, e todas as operações executadas dentro dela são temporárias até que um Commit() ou Rollback() seja chamado.

Aqui está um exemplo simples de uso de uma transação com Begin, Commit e Rollback:

package main

import (
    "database/sql"
    "log"
)

func transfer(db *sql.DB, fromID, toID int, amount float64) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    // Executa as operações dentro da transação
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        tx.Rollback()
        return err
    }

    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    if err != nil {
        tx.Rollback()
        return err
    }

    // Se tudo ocorreu bem, confirma a transação
    err = tx.Commit()
    if err != nil {
        return err
    }

    return nil
}

Observe que, se qualquer uma das operações falhar, chamamos Rollback() para reverter as alterações parciais. Isso garante que o banco de dados não fique em um estado inconsistente. O Commit() é chamado apenas se todas as operações tiverem sucesso.

Padrões

Um padrão comum ao trabalhar com transações em Go é usar defer para garantir que a transação seja finalizada (commit ou rollback) mesmo em caso de pânico ou retorno inesperado. No entanto, é importante tomar cuidado, pois defer executará a função mesmo se você já tiver chamado Commit() ou Rollback() explicitamente. Por isso, uma abordagem comum é usar uma variável para controlar o estado da transação e chamar Rollback() apenas se a transação não tiver sido confirmada.

Outro padrão é encapsular a lógica da transação em uma função que recebe um *sql.Tx como parâmetro, facilitando o teste e a reutilização. Além disso, é recomendável usar context.Context para cancelar a transação se a operação for interrompida, por exemplo, quando o cliente desconecta.

Aqui está um exemplo que usa defer para lidar com rollback automático:

func transferWithDefer(db *sql.DB, fromID, toID int, amount float64) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    // Garante rollback se não houver commit
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // Executa as operações
    if _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID); err != nil {
        return err
    }

    if _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID); err != nil {
        return err
    }

    // Confirma a transação
    err = tx.Commit()
    return err
}

Nesse exemplo, a função defer verifica se houve erro; se sim, faz rollback. Se o Commit() for bem-sucedido, err será nil e o rollback não será executado. No entanto, se o Commit() falhar, o rollback também será tentado, o que é inofensivo (geralmente o banco já reverterá).

Outro padrão é usar uma função auxiliar para executar uma transação com um callback, abstraindo a lógica de commit/rollback:

func withTransaction(db *sql.DB, fn func(tx *sql.Tx) error) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p)
        } else if err != nil {
            tx.Rollback()
        } else {
            err = tx.Commit()
        }
    }()

    err = fn(tx)
    return err
}

Esse padrão é útil para reduzir código repetido e garantir a finalização correta.

Isolamento

O nível de isolamento de uma transação define o grau em que as operações dentro dela são visíveis para outras transações concorrentes. O pacote database/sql permite especificar o nível de isolamento ao iniciar uma transação, usando a estrutura sql.TxOptions com o campo Isolation.

Os níveis de isolamento padrão, definidos pelo SQL, são: Read Uncommitted, Read Committed, Repeatable Read e Serializable. Cada um oferece um equilíbrio entre consistência e concorrência. Por exemplo, Read Uncommitted permite leituras sujas (dados não confirmados por outras transações), enquanto Serializable garante o maior isolamento, impedindo leituras fantasmas, mas ao custo de performance.

Em Go, você pode definir o nível de isolamento da seguinte forma:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})

Se você não especificar, o banco de dados usará seu nível padrão (geralmente Read Committed ou Repeatable Read, dependendo do SGBD). É importante entender as implicações de cada nível para escolher o adequado para sua aplicação.

Além do nível de isolamento, você também pode definir a transação como somente leitura, usando o campo ReadOnly:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    ReadOnly: true,
})

Isso pode ser útil para otimizar consultas que não precisam de escrita, pois alguns bancos podem usar otimizações específicas.

Exemplo de uso de isolamento:

ctx := context.Background()
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelRepeatableRead,
})
if err != nil {
    log.Fatal(err)
}
// ... operações

Tratamento de erros

O tratamento de erros em transações é crítico para garantir a consistência. Você deve verificar o erro retornado por cada operação e, em caso de falha, reverter a transação. Além disso, o próprio Commit() pode falhar, por exemplo, se a conexão foi perdida ou se houve um conflito de concorrência.

Uma prática recomendada é usar defer para rollback automático, mas é essencial não mascarar erros. Ao usar defer, você pode capturar o erro retornado e decidir se deve fazer rollback. Também é importante lidar com pânicos: se ocorrer um pânico dentro da transação, o rollback deve ser executado para não deixar a transação aberta.

Aqui está um exemplo mais completo que lida com erros e usa contexto:

func performTransaction(ctx context.Context, db *sql.DB) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }

    // Garante rollback se qualquer erro ocorrer
    committed := false
    defer func() {
        if !committed {
            tx.Rollback()
        }
    }()

    // Executa operações
    if _, err := tx.ExecContext(ctx, "INSERT INTO logs (message) VALUES (?)", "início"); err != nil {
        return err
    }

    if _, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1); err != nil {
        return err
    }

    // Confirma
    if err := tx.Commit(); err != nil {
        return err
    }
    committed = true
    return nil
}

Nesse código, usamos committed para evitar um rollback duplo, e usamos ExecContext para respeitar o cancelamento do contexto.

Boas Práticas

  • Sempre feche a transação (commit ou rollback) antes de sair do escopo, preferencialmente com defer.
  • Use context.Context para permitir cancelamento e timeouts.
  • Evite transações longas, pois elas podem causar bloqueios e reduzir a concorrência.
  • Escolha o nível de isolamento adequado ao caso de uso; o padrão geralmente é suficiente.
  • Teste o comportamento da transação em cenários de erro e concorrência.

Exercícios

  1. Escreva uma função em Go que inicia uma transação, insere um registro em uma tabela users e depois atualiza um contador em outra tabela. Se qualquer operação falhar, faça rollback. Use Begin e Commit.
  2. Refatore a função do exercício 1 para usar defer para rollback automático, garantindo que o rollback seja chamado apenas se o commit não for bem-sucedido.
  3. Crie uma função que use BeginTx com um nível de isolamento RepeatableRead e execute uma consulta de leitura e uma de escrita dentro da transação. Explique por que o isolamento é importante nesse cenário.
  4. Implemente um padrão de transação com callback (como o withTransaction mostrado) e use-o para executar duas operações de atualização. Mostre como o erro é propagado.
  5. Escreva um teste que verifique se uma transação é revertida corretamente quando ocorre um erro em uma das operações. Use um banco de dados SQLite em memória ou simule um erro com um driver mock.

✓ Resposta:
func insertAndUpdate(db *sql.DB) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()

    _, err = tx.Exec("INSERT INTO users (name) VALUES (?)", "Alice")
    if err != nil {
        return err
    }

    _, err = tx.Exec("UPDATE counters SET count = count + 1 WHERE id = 1")
    if err != nil {
        return err
    }

    return tx.Commit()
}

✓ Resposta:
func insertAndUpdate(db *sql.DB) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    if _, err = tx.Exec("INSERT INTO users (name) VALUES (?)", "Alice"); err != nil {
        return err
    }

    if _, err = tx.Exec("UPDATE counters SET count = count + 1 WHERE id = 1"); err != nil {
        return err
    }

    err = tx.Commit()
    return err
}

✓ Resposta:
func readAndWrite(db *sql.DB) error {
    ctx := context.Background()
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelRepeatableRead,
    })
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var count int
    err = tx.QueryRow("SELECT count FROM counters WHERE id = 1").Scan(&count)
    if err != nil {
        return err
    }

    _, err = tx.Exec("UPDATE counters SET count = ? WHERE id = 1", count+1)
    if err != nil {
        return err
    }

    return tx.Commit()
}

O isolamento RepeatableRead garante que a leitura do contador seja consistente durante toda a transação, evitando que outra transação modifique o valor entre a leitura e a escrita, o que poderia causar uma condição de corrida.

✓ Resposta:
func withTransaction(db *sql.DB, fn func(tx *sql.Tx) error) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p)
        } else if err != nil {
            tx.Rollback()
        } else {
            err = tx.Commit()
        }
    }()

    err = fn(tx)
    return err
}

func updateBoth(db *sql.DB) error {
    return withTransaction(db, func(tx *sql.Tx) error {
        if _, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1); err != nil {
            return err
        }
        if _, err := tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", 100, 2); err != nil {
            return err
        }
        return nil
    })
}

✓ Resposta:
// Exemplo usando SQLite em memória
func TestRollback(t *testing.T) {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // Cria tabelas
    db.Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)")
    db.Exec("INSERT INTO accounts (id, balance) VALUES (1, 1000), (2, 1000)")

    // Tenta uma transferência com erro
    err = transferWithError(db, 1, 2, 100)
    if err == nil {
        t.Fatal("esperava erro")
    }

    // Verifica se os saldos não mudaram
    var balance1, balance2 float64
    db.QueryRow("SELECT balance FROM accounts WHERE id = 1").Scan(&balance1)
    db.QueryRow("SELECT balance FROM accounts WHERE id = 2").Scan(&balance2)
    if balance1 != 1000 || balance2 != 1000 {
        t.Errorf("saldos inconsistentes: %f, %f", balance1, balance2)
    }
}

Referências