Transações
Quando trabalhamos com bancos de dados, algumas operações precisam ser executadas em conjunto para garantir que as informações permaneçam corretas. Imagine um sistema financeiro que precisa transferir dinheiro entre duas contas. Para concluir essa operação, precisamos retirar determinado valor de uma conta e adicionar o mesmo valor em outra. Se apenas uma dessas alterações for concluída, os saldos ficarão inconsistentes.
As transações resolvem esse problema permitindo agrupar várias operações SQL em uma única unidade de trabalho. Dentro de uma transação, podemos executar diferentes comandos e decidir se todas as alterações devem ser confirmadas ou desfeitas. No Go, o pacote database/sql oferece o tipo sql.Tx, que representa uma transação e fornece métodos para executar consultas, modificar registros e controlar a confirmação das alterações.
O que é uma transação
Uma transação é um conjunto de operações executadas como uma única unidade de trabalho dentro do banco de dados. Isso significa que as alterações realizadas precisam ser tratadas em conjunto, garantindo que uma falha não deixe apenas parte da operação confirmada.
Imagine que temos duas contas bancárias, cada uma com um saldo armazenado no banco.
| id | titular | saldo_centavos |
|---|---|---|
| 1 | Ana | 10000 |
| 2 | Bruno | 2500 |
Nesse exemplo, Ana possui R$ 100,00 e Bruno possui R$ 25,00. Os valores são armazenados em centavos para evitar problemas de precisão que podem acontecer quando trabalhamos com valores monetários usando números de ponto flutuante.
Agora, imagine que precisamos transferir R$ 30,00 da conta de Ana para a conta de Bruno. Essa operação exige duas alterações no banco, uma para retirar o dinheiro da primeira conta e outra para adicionar o valor na segunda.
-- Retira R$ 30,00 da conta de Ana.
UPDATE contas
SET saldo_centavos = saldo_centavos - 3000
WHERE id = 1;
-- Adiciona R$ 30,00 à conta de Bruno.
UPDATE contas
SET saldo_centavos = saldo_centavos + 3000
WHERE id = 2;
O primeiro comando reduz o saldo da conta de Ana em 3000 centavos, enquanto o segundo aumenta o saldo da conta de Bruno pelo mesmo valor. Se os dois comandos forem executados corretamente, Ana ficará com R$ 70,00 e Bruno com R$ 55,00, mantendo o valor total das duas contas.
O problema aparece quando o primeiro comando é confirmado, mas o segundo falha. Se executarmos essas alterações separadamente, sem uma transação explícita que agrupe as duas operações, o SQLite poderá confirmar a primeira alteração antes mesmo de começar a segunda. Nesse caso, Ana perderia R$ 30,00, mas Bruno não receberia esse dinheiro.
Para evitar isso, podemos executar os comandos dentro de uma transação.
-- Inicia uma transação.
BEGIN TRANSACTION;
-- Retira o dinheiro da primeira conta.
UPDATE contas
SET saldo_centavos = saldo_centavos - 3000
WHERE id = 1;
-- Adiciona o dinheiro à segunda conta.
UPDATE contas
SET saldo_centavos = saldo_centavos + 3000
WHERE id = 2;
-- Confirma as alterações.
COMMIT;
O comando BEGIN TRANSACTION inicia uma transação explícita, fazendo com que as operações seguintes pertençam à mesma unidade de trabalho. Já COMMIT confirma as alterações realizadas durante a transação, permitindo que os novos valores sejam mantidos no banco.
Se acontecer algum problema antes da confirmação, podemos executar ROLLBACK para desfazer as alterações realizadas desde o início da transação. Isso permite que as duas contas mantenham seus saldos anteriores, em vez de deixar apenas uma parte da transferência concluída.
No Go, não precisamos executar manualmente os comandos BEGIN TRANSACTION, COMMIT e ROLLBACK como strings SQL. O pacote database/sql fornece métodos próprios para controlar essas operações, que devem ser preferidos porque mantêm o gerenciamento da transação integrado ao controle de conexões.
Quando usar transações
As transações são necessárias principalmente quando temos várias operações que dependem umas das outras. Se uma delas falhar, as outras não devem permanecer confirmadas, porque isso poderia deixar os dados em um estado incorreto.
Imagine um sistema de pedidos que precisa cadastrar uma compra e diminuir a quantidade disponível de um produto. Essas duas operações fazem parte do mesmo processo, porque não queremos registrar uma compra sem atualizar o estoque, nem diminuir o estoque se a compra não for concluída.
Podemos encontrar situações semelhantes em transferências financeiras, movimentações de estoque, cadastros que envolvem várias tabelas e operações que precisam manter relacionamentos consistentes. Em todos esses casos, a transação permite tratar diferentes comandos SQL como partes de uma única operação.
Isso não significa que precisamos criar uma transação explícita para cada comando executado. O SQLite já executa comandos de escrita individuais dentro de transações automáticas, permitindo que uma operação isolada seja confirmada ou desfeita de acordo com seu resultado.
Por exemplo, um UPDATE simples que modifica o nome de um produto normalmente não precisa de uma transação explícita adicional. A necessidade aparece principalmente quando precisamos controlar a confirmação de várias operações relacionadas.
Também precisamos considerar que uma transação mantém recursos do banco ocupados enquanto está aberta. Por isso, devemos executar apenas as operações necessárias e encerrá-la assim que possível, evitando manter uma transação aberta enquanto aguardamos respostas de APIs externas, entradas do usuário ou outras tarefas demoradas.
Commit e rollback
O controle de uma transação envolve principalmente três operações, iniciar a transação, executar os comandos SQL e decidir se as alterações devem ser confirmadas ou desfeitas. No Go, essas responsabilidades são representadas pelos métodos Begin() ou BeginTx(), Commit() e Rollback().
O método BeginTx() pertence ao sql.DB e cria uma transação, retornando um valor do tipo *sql.Tx. Essa estrutura representa a transação ativa e possui métodos semelhantes aos do sql.DB, como Exec(), Query() e QueryRow().
Podemos iniciar uma transação da seguinte maneira.
// Inicia uma transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Garante o rollback caso a transação não seja confirmada.
defer tx.Rollback()
O método BeginTx() recebe um context.Context, que permite controlar o cancelamento da transação, e um *sql.TxOptions, que permite configurar determinadas opções. Quando informamos nil no segundo argumento, usamos as opções padrão oferecidas pelo banco e pelo driver.
A variável tx recebe um ponteiro para sql.Tx, que será usado para executar todas as operações que fazem parte da transação. O contexto permanece associado à transação até que ela seja confirmada ou desfeita. Se esse contexto for cancelado, o database/sql solicitará o rollback da transação.
Também existe o método Begin(), que inicia uma transação sem receber explicitamente um contexto. Nos exemplos, vamos preferir BeginTx(), porque ele permite controlar melhor o prazo e o cancelamento das operações.
Commit
O Commit() é responsável por confirmar as alterações realizadas durante uma transação. Quando esse método é executado com sucesso, as operações que fazem parte da transação são confirmadas em conjunto.
Podemos executar comandos SQL por meio do próprio tx e depois confirmar as alterações.
// Inicia a transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Desfaz a transação se ela não for confirmada.
defer tx.Rollback()
// Atualiza o estoque de um produto.
_, err = tx.ExecContext(ctx, `
UPDATE produtos
SET estoque = estoque - ?
WHERE id = ? AND estoque >= ?
`, 2, 1, 2)
if err != nil {
return err
}
// Confirma as alterações.
if err := tx.Commit(); err != nil {
return err
}
return nil
Nesse exemplo, iniciamos a transação e executamos um UPDATE usando tx.ExecContext(). Observe que não usamos db.ExecContext(), porque queremos que o comando faça parte da transação criada. A condição estoque >= ? impede que a quantidade seja reduzida caso não exista estoque suficiente, embora ainda precisemos verificar quantas linhas foram afetadas para saber se o produto realmente foi atualizado.
O método tx.Commit() tenta confirmar a transação. Se a confirmação acontecer corretamente, as alterações passam a fazer parte do estado confirmado do banco. Se Commit() retornar um erro, não devemos considerar a operação concluída com sucesso, porque a confirmação não foi garantida.
Depois que uma transação é confirmada, ela não pode ser usada novamente. Se tentarmos executar outro comando pelo mesmo tx, o database/sql retornará um erro, normalmente sql.ErrTxDone, indicando que a transação já foi encerrada.
Rollback
O Rollback() é responsável por desfazer as alterações que ainda não foram confirmadas dentro de uma transação. Esse mecanismo permite recuperar o estado anterior dos dados quando alguma etapa da operação falha ou quando decidimos não continuar com a confirmação.
Imagine que precisamos atualizar o estoque de um produto e, em seguida, registrar uma movimentação em outra tabela. Se a primeira operação funcionar, mas a segunda falhar, podemos executar Rollback() para desfazer a alteração realizada no estoque.
// Inicia a transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Desfaz as alterações se necessário.
defer tx.Rollback()
// Atualiza o estoque.
_, err = tx.ExecContext(ctx, `
UPDATE produtos
SET estoque = estoque - ?
WHERE id = ?
`, 2, 1)
if err != nil {
return err
}
// Registra a movimentação.
_, err = tx.ExecContext(ctx, `
INSERT INTO movimentacoes (produto_id, quantidade)
VALUES (?, ?)
`, 1, 2)
if err != nil {
// O defer executará Rollback ao retornar.
return err
}
// Confirma as duas operações.
return tx.Commit()
Esse exemplo considera que as tabelas produtos e movimentacoes já existem e que suas estruturas permitem executar os comandos apresentados. O objetivo é mostrar que as duas operações pertencem à mesma transação, porque ambas são executadas por meio de tx.ExecContext().
Se a inserção na tabela movimentacoes falhar, a função retornará o erro antes de executar Commit(). Como registramos defer tx.Rollback(), o Go tentará desfazer a transação quando a função terminar, incluindo a alteração anterior realizada no estoque.
O Rollback() não precisa ser chamado apenas quando acontece um erro SQL. Também podemos desfazer uma transação quando uma regra da aplicação não é atendida, como a ausência de estoque suficiente ou a tentativa de realizar uma transferência com valor inválido.
Por que usamos defer tx.Rollback()?
Uma prática recomendada pelo próprio Go é chamar defer tx.Rollback() logo depois que a transação for iniciada corretamente. Isso permite garantir que a função tente desfazer a transação caso termine antes de executar Commit().
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Garante uma tentativa de rollback ao sair da função.
defer tx.Rollback()
O defer agenda a chamada de Rollback() para o momento em que a função terminar. Se ocorrer um erro em alguma operação e retornarmos antes do Commit(), o rollback será executado, liberando os recursos associados à transação e desfazendo as alterações ainda não confirmadas.
Se a transação já tiver sido confirmada com sucesso por Commit(), a chamada posterior de Rollback() não desfará as alterações. Nesse caso, ela retornará sql.ErrTxDone, porque a transação já foi encerrada. Como estamos chamando o método por meio de defer e não precisamos tratar esse erro esperado, podemos simplesmente deixar seu retorno sem tratamento nesse padrão.
Essa abordagem reduz a necessidade de escrever chamadas explícitas de Rollback() em cada ponto em que uma função pode retornar um erro, mantendo o código mais simples e evitando que uma transação permaneça aberta por descuido.
Não misturando sql.DB com sql.Tx
Quando iniciamos uma transação por meio de BeginTx(), o database/sql reserva uma conexão do pool para executar suas operações. Enquanto a transação estiver ativa, os comandos executados por meio de tx pertencem à mesma conexão e à mesma transação.
Por isso, não devemos misturar chamadas de db.Exec() ou db.Query() com operações que precisam fazer parte da transação. Esses métodos podem escolher outras conexões do pool, fazendo com que os comandos sejam executados fora da transação que estamos controlando.
// Inicia uma transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Correto: executa dentro da transação.
_, err = tx.ExecContext(ctx, `
UPDATE produtos
SET estoque = estoque - 1
WHERE id = 1
`)
if err != nil {
return err
}
// Confirma a transação.
return tx.Commit()
Se substituíssemos tx.ExecContext() por db.ExecContext(), o comando deixaria de fazer parte da transação criada. Além disso, se o pool estiver limitado a uma conexão, a operação executada por db.ExecContext() poderá ficar esperando uma conexão disponível, porque a única conexão já estará reservada pelo tx.
Durante uma transação, os comandos que precisam fazer parte dela devem ser executados pelo próprio sql.Tx. O mesmo cuidado vale para consultas, que devem usar tx.QueryContext() ou tx.QueryRowContext() quando seus resultados precisam estar associados à transação.
Garantindo consistência dos dados
Uma das principais responsabilidades de uma transação é ajudar a manter os dados consistentes, garantindo que operações relacionadas não deixem o banco em um estado incorreto. Para entender como isso funciona, precisamos conhecer quatro propriedades normalmente associadas às transações, conhecidas pela sigla ACID.
| Propriedade | Significado |
|---|---|
| Atomicidade | As operações são confirmadas ou desfeitas como uma unidade. |
| Consistência | As alterações devem preservar as regras de integridade dos dados. |
| Isolamento | Transações concorrentes seguem regras que controlam a visibilidade e a interferência entre suas operações. |
| Durabilidade | Alterações confirmadas devem permanecer armazenadas conforme as garantias do banco. |
A atomicidade significa que uma transação não deve confirmar apenas parte das alterações que precisam acontecer em conjunto. No exemplo da transferência, retirar dinheiro de uma conta e adicionar esse valor em outra fazem parte da mesma operação, então não queremos confirmar apenas uma das alterações.
A consistência está relacionada às regras que definem quais estados são válidos para os dados. No SQLite, podemos aplicar restrições como NOT NULL, UNIQUE, CHECK e chaves estrangeiras, mas também precisamos implementar regras de negócio na aplicação. Uma transação não identifica automaticamente que uma transferência precisa manter o valor total das contas, por exemplo. É o código que precisa executar e verificar as operações corretas.
O isolamento controla como diferentes transações interagem quando são executadas concorrentemente. O SQLite permite várias operações de leitura em determinadas condições, mas apenas uma transação de escrita por vez em um mesmo arquivo de banco. Além disso, as regras de isolamento e visibilidade dependem do modo de funcionamento do SQLite e das configurações de conexão.
Já a durabilidade significa que, depois de uma confirmação bem-sucedida, as alterações devem permanecer armazenadas. O SQLite possui mecanismos de journal e recuperação para proteger a integridade do banco, mas as garantias diante de falhas de energia ou armazenamento também dependem das configurações, como o modo de journal e o nível de sincronização usado.
A transação não substitui as regras de negócio
Embora uma transação permita confirmar ou desfazer várias operações em conjunto, ela não garante sozinha que os valores informados fazem sentido para a aplicação. Precisamos validar os dados e verificar se cada comando realmente produziu o resultado esperado.
Imagine que precisamos retirar R$ 50,00 de uma conta que possui apenas R$ 20,00. Se executarmos uma atualização sem verificar o saldo, poderemos produzir um valor negativo ou receber um erro de restrição, dependendo da estrutura da tabela.
-- Retira um valor sem verificar o saldo.
UPDATE contas
SET saldo_centavos = saldo_centavos - 5000
WHERE id = 1;
Uma maneira mais segura é incluir a verificação diretamente na condição do comando.
-- Retira o valor apenas se houver saldo suficiente.
UPDATE contas
SET saldo_centavos = saldo_centavos - 5000
WHERE id = 1 AND saldo_centavos >= 5000;
Nesse segundo exemplo, o SQLite só modifica o registro quando a conta possui saldo suficiente para realizar a operação. Se a condição não for atendida, nenhum registro será atualizado.
No Go, podemos verificar esse resultado usando RowsAffected().
// Retira dinheiro apenas se houver saldo suficiente.
resultado, err := tx.ExecContext(ctx, `
UPDATE contas
SET saldo_centavos = saldo_centavos - ?
WHERE id = ? AND saldo_centavos >= ?
`, 5000, 1, 5000)
if err != nil {
return err
}
// Verifica se alguma conta foi atualizada.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return fmt.Errorf("conta não encontrada ou saldo insuficiente")
}
Nesse exemplo, a condição do UPDATE faz com que a verificação de saldo e a alteração aconteçam dentro do mesmo comando SQL. Isso evita depender de uma consulta separada para descobrir o saldo e, depois, executar uma atualização baseada em uma informação que poderia ter mudado.
Se nenhuma linha for afetada, retornamos um erro e deixamos que o Rollback() desfaça as alterações da transação. Como o mesmo resultado pode acontecer quando a conta não existe, a mensagem considera as duas possibilidades.
Também podemos adicionar uma restrição diretamente na tabela.
CREATE TABLE IF NOT EXISTS contas (
id INTEGER PRIMARY KEY,
titular TEXT NOT NULL,
-- Impede o armazenamento de saldos negativos.
saldo_centavos INTEGER NOT NULL CHECK (saldo_centavos >= 0)
);
A condição CHECK (saldo_centavos >= 0) impede que valores negativos sejam armazenados na coluna. Isso cria uma proteção adicional, porque a regra continua sendo verificada pelo banco mesmo quando uma atualização é executada por outra parte da aplicação.
Entretanto, o CHECK não substitui a condição do UPDATE. Enquanto a restrição protege o estado armazenado, a condição permite que a operação seja executada apenas quando existe saldo suficiente, fornecendo um comportamento mais adequado à regra de transferência.
Concorrência e bloqueios no SQLite
Quando diferentes partes da aplicação tentam executar operações ao mesmo tempo, precisamos considerar como o banco controla o acesso concorrente. O SQLite permite diferentes conexões de leitura, mas apenas uma transação de escrita por vez em um mesmo arquivo de banco.
Isso significa que, se uma transação estiver modificando registros, outra conexão que também queira escrever poderá precisar aguardar ou receber um erro de bloqueio, dependendo das configurações do banco e do tempo de espera permitido.
O SQLite oferece diferentes modos de início de transação, como DEFERRED, IMMEDIATE e EXCLUSIVE. No modo DEFERRED, que é o comportamento padrão do SQLite, a transação não precisa adquirir imediatamente os recursos necessários para escrever, pois isso pode acontecer apenas quando o primeiro comando de escrita é executado.
Já BEGIN IMMEDIATE tenta iniciar uma transação de escrita imediatamente. Esse comportamento pode ser interessante em determinadas operações concorrentes, mas seu uso depende do driver e da maneira como a aplicação controla as transações. Nos exemplos com database/sql, vamos continuar usando BeginTx(), sem executar manualmente comandos BEGIN na conexão gerenciada pelo pacote.
Também precisamos lembrar que uma transação deve permanecer aberta apenas durante o tempo necessário. Quanto mais tempo ela fica ativa, maior pode ser a disputa por conexões e recursos do banco, principalmente quando existem várias operações de escrita.
Exemplo prático com múltiplas operações
Agora vamos criar uma aplicação Go que realiza transferências entre contas bancárias usando SQLite. O objetivo é garantir que o dinheiro seja retirado de uma conta e adicionado em outra dentro da mesma transação, impedindo que apenas uma dessas alterações seja confirmada.
Para isso, vamos trabalhar com uma tabela chamada contas, que armazena o identificador, o nome do titular e o saldo disponível em centavos.
CREATE TABLE IF NOT EXISTS contas (
id INTEGER PRIMARY KEY,
titular TEXT NOT NULL,
saldo_centavos INTEGER NOT NULL CHECK (saldo_centavos >= 0)
);
A coluna id identifica cada conta, enquanto titular armazena o nome de seu responsável. O campo saldo_centavos mantém o saldo como um número inteiro e possui uma restrição que impede valores negativos. Essa estrutura é suficiente para demonstrar as operações envolvidas em uma transferência, embora um sistema financeiro real também precise de mecanismos de histórico, auditoria e outras regras de controle.
Vamos considerar que duas contas foram cadastradas com os seguintes valores.
-- Cadastra duas contas.
INSERT INTO contas (id, titular, saldo_centavos)
VALUES
(1, 'Ana', 10000),
(2, 'Bruno', 2500);
Depois dessa inserção, Ana possui R$ 100,00 e Bruno possui R$ 25,00. Nossa aplicação deverá transferir R$ 30,00 da primeira conta para a segunda, verificando se a origem possui saldo suficiente e se a conta de destino realmente existe.
Criando a função de transferência
Podemos criar uma função chamada transferir(), que recebe o contexto da operação, o gerenciador de conexões, os identificadores das contas e o valor que será transferido. Essa função será responsável por validar os argumentos, iniciar a transação, executar as duas alterações e confirmar o resultado apenas quando todas as etapas forem concluídas corretamente.
func transferir(
ctx context.Context,
db *sql.DB,
origemID int64,
destinoID int64,
valor int64,
) error {
// Impede transferências com valores inválidos.
if valor <= 0 {
return fmt.Errorf("o valor deve ser maior que zero")
}
// Impede transferências para a mesma conta.
if origemID == destinoID {
return fmt.Errorf("as contas devem ser diferentes")
}
// Inicia a transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("erro ao iniciar transação: %w", err)
}
// Desfaz as alterações se a transação não for confirmada.
defer tx.Rollback()
// Retira o valor da conta de origem.
resultado, err := tx.ExecContext(ctx, `
UPDATE contas
SET saldo_centavos = saldo_centavos - ?
WHERE id = ? AND saldo_centavos >= ?
`, valor, origemID, valor)
if err != nil {
return fmt.Errorf("erro ao debitar conta: %w", err)
}
// Verifica se a conta foi atualizada.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return fmt.Errorf("conta de origem não encontrada ou saldo insuficiente")
}
// Adiciona o valor à conta de destino.
resultado, err = tx.ExecContext(ctx, `
UPDATE contas
SET saldo_centavos = saldo_centavos + ?
WHERE id = ?
`, valor, destinoID)
if err != nil {
return fmt.Errorf("erro ao creditar conta: %w", err)
}
// Verifica se a conta de destino existe.
linhas, err = resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return fmt.Errorf("conta de destino não encontrada")
}
// Confirma as duas alterações.
if err := tx.Commit(); err != nil {
return fmt.Errorf("erro ao confirmar transferência: %w", err)
}
return nil
}
A função começa verificando se o valor informado é maior que zero e se os identificadores representam contas diferentes. Essas validações acontecem antes de iniciar a transação, porque não precisamos reservar uma conexão do banco para rejeitar argumentos que já sabemos serem inválidos.
Depois disso, chamamos db.BeginTx(ctx, nil), que cria a transação e retorna um *sql.Tx. O defer tx.Rollback() garante que a transação seja desfeita se a função terminar antes de executar Commit() com sucesso.
A primeira operação executa um UPDATE na conta de origem, diminuindo seu saldo apenas quando existe dinheiro suficiente. Em seguida, usamos RowsAffected() para verificar se algum registro foi atualizado. Se o resultado for zero, significa que a conta não existe ou não possui saldo suficiente, então retornamos um erro sem continuar a transferência.
Se a retirada funcionar, executamos o segundo UPDATE, que adiciona o mesmo valor à conta de destino. Também verificamos RowsAffected(), porque a ausência de uma conta correspondente ao identificador não provoca necessariamente um erro SQL. Se nenhuma linha for afetada, retornamos um erro e o rollback desfaz a retirada que já havia sido executada.
Somente depois que as duas operações forem concluídas corretamente chamamos tx.Commit(). Dessa forma, a função confirma a transferência apenas quando o dinheiro foi retirado da origem e adicionado ao destino.
Implementando a aplicação completa
Podemos reunir essa função em uma aplicação que cria o banco SQLite, cadastra as contas iniciais e executa uma transferência. Vamos usar um arquivo chamado transacoes.db, mantendo esse exemplo separado dos bancos criados nos tópicos anteriores.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}
func executar() error {
// Cria o gerenciador de conexões com SQLite.
db, err := sql.Open("sqlite", "transacoes.db")
if err != nil {
return err
}
// Fecha o banco quando a função terminar.
defer db.Close()
// Limita o pool a uma conexão.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return err
}
// Cria a tabela de contas.
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS contas (
id INTEGER PRIMARY KEY,
titular TEXT NOT NULL,
saldo_centavos INTEGER NOT NULL CHECK (saldo_centavos >= 0)
);
`)
if err != nil {
return err
}
// Cadastra as contas apenas se ainda não existirem.
_, err = db.Exec(`
INSERT INTO contas (id, titular, saldo_centavos)
VALUES
(1, 'Ana', 10000),
(2, 'Bruno', 2500)
ON CONFLICT(id) DO NOTHING;
`)
if err != nil {
return err
}
// Cria um contexto com limite de cinco segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
// Transfere R$ 30,00 da conta 1 para a conta 2.
if err := transferir(ctx, db, 1, 2, 3000); err != nil {
return err
}
fmt.Println("Transferência realizada com sucesso!")
// Consulta os saldos atualizados.
rows, err := db.Query(`
SELECT id, titular, saldo_centavos
FROM contas
ORDER BY id ASC
`)
if err != nil {
return err
}
defer rows.Close()
// Percorre as contas cadastradas.
for rows.Next() {
var id int64
var titular string
var saldo int64
if err := rows.Scan(&id, &titular, &saldo); err != nil {
return err
}
fmt.Printf(
"Conta: %d | Titular: %s | Saldo: %d centavos\n",
id,
titular,
saldo,
)
}
return rows.Err()
}
// Realiza uma transferência entre duas contas.
func transferir(
ctx context.Context,
db *sql.DB,
origemID int64,
destinoID int64,
valor int64,
) error {
// Valida o valor da transferência.
if valor <= 0 {
return fmt.Errorf("o valor deve ser maior que zero")
}
// Impede transferências para a mesma conta.
if origemID == destinoID {
return fmt.Errorf("as contas devem ser diferentes")
}
// Inicia a transação.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("erro ao iniciar transação: %w", err)
}
// Desfaz a transação caso não seja confirmada.
defer tx.Rollback()
// Debita o valor da conta de origem.
resultado, err := tx.ExecContext(ctx, `
UPDATE contas
SET saldo_centavos = saldo_centavos - ?
WHERE id = ? AND saldo_centavos >= ?
`, valor, origemID, valor)
if err != nil {
return fmt.Errorf("erro ao debitar conta: %w", err)
}
// Verifica se o débito foi realizado.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return fmt.Errorf("conta de origem inexistente ou saldo insuficiente")
}
// Credita o valor na conta de destino.
resultado, err = tx.ExecContext(ctx, `
UPDATE contas
SET saldo_centavos = saldo_centavos + ?
WHERE id = ?
`, valor, destinoID)
if err != nil {
return fmt.Errorf("erro ao creditar conta: %w", err)
}
// Verifica se a conta de destino existe.
linhas, err = resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return fmt.Errorf("conta de destino não encontrada")
}
// Confirma a transferência.
if err := tx.Commit(); err != nil {
return fmt.Errorf("erro ao confirmar transferência: %w", err)
}
return nil
}
A função executar() começa criando o gerenciador de conexões e garantindo que a tabela contas exista. Depois, executa uma inserção com ON CONFLICT(id) DO NOTHING, permitindo cadastrar as contas iniciais sem gerar um erro quando seus identificadores já estiverem presentes.
Isso significa que o programa pode ser executado novamente sem tentar cadastrar as mesmas contas, mas os saldos não serão reiniciados automaticamente. Como o banco é persistente, cada transferência confirmada continuará modificando os valores armazenados no arquivo transacoes.db.
Em seguida, criamos um contexto com limite de cinco segundos e chamamos transferir(), informando os identificadores das contas e o valor da transferência. O contexto permanece ativo durante a transação, permitindo que o database/sql solicite seu cancelamento caso o prazo termine antes da confirmação.
Depois que a transferência é concluída, executamos uma consulta para mostrar os saldos atualizados. O Query() retorna um *sql.Rows, que percorremos com Next() e Scan(), copiando os valores de cada conta para as variáveis correspondentes.
Na primeira execução, considerando um arquivo de banco novo e uma transferência concluída com sucesso, o resultado será semelhante a este.
Transferência realizada com sucesso!
Conta: 1 | Titular: Ana | Saldo: 7000 centavos
Conta: 2 | Titular: Bruno | Saldo: 5500 centavos
Observe que Ana começou com 10000 centavos e terminou com 7000, enquanto Bruno começou com 2500 e terminou com 5500. A soma dos saldos continua sendo 12500 centavos, porque o valor retirado da primeira conta foi adicionado à segunda.
O que acontece quando uma operação falha?
Agora imagine que precisamos transferir R$ 200,00 da conta de Ana para Bruno, mas Ana possui apenas R$ 100,00. Nesse caso, a transferência deve ser rejeitada sem modificar nenhuma das contas.
Podemos executar a mesma função informando um valor maior que o saldo disponível.
// Tenta transferir R$ 200,00.
err := transferir(ctx, db, 1, 2, 20000)
if err != nil {
fmt.Println("Transferência não realizada:", err)
}
Quando a função executar o primeiro UPDATE, a condição saldo_centavos >= ? não será atendida, porque a conta de origem não possui dinheiro suficiente. O comando não atualizará nenhum registro e RowsAffected() retornará 0, fazendo com que a função devolva um erro antes de executar o crédito na conta de destino.
Como a transação ainda não foi confirmada, a chamada de Rollback() registrada com defer será executada. Nesse caso específico, nenhuma alteração chegou a acontecer, mas o rollback continua encerrando a transação sem confirmar modificações.
Também podemos imaginar uma situação em que a conta de origem existe e possui saldo suficiente, mas a conta de destino não foi encontrada.
// Tenta transferir para uma conta inexistente.
err := transferir(ctx, db, 1, 999, 3000)
if err != nil {
fmt.Println("Transferência não realizada:", err)
}
Nesse segundo exemplo, o primeiro UPDATE poderá retirar o valor da conta de origem dentro da transação. Entretanto, o segundo comando não encontrará uma conta com identificador 999, fazendo com que RowsAffected() retorne 0.
A função devolverá um erro e o Rollback() desfará a retirada realizada anteriormente. Isso significa que a conta de origem manterá seu saldo anterior, mesmo que o primeiro comando tenha sido executado corretamente dentro da transação.
Esse é o comportamento que precisamos garantir quando várias operações dependem umas das outras. O banco só deve manter as alterações quando todas as etapas necessárias forem concluídas e a transação for confirmada com sucesso.
Cuidados ao trabalhar com transações
Uma transação deve ser encerrada por meio de Commit() ou Rollback(). Deixar uma transação aberta pode manter uma conexão reservada e dificultar a execução de outras operações. Por isso, o padrão com defer tx.Rollback() é recomendado mesmo quando esperamos confirmar normalmente as alterações.
Também precisamos verificar todos os erros retornados pelas operações, incluindo os resultados de ExecContext(), RowsAffected() e Commit(). Uma consulta pode ser executada sem encontrar registros, e isso não significa necessariamente que ocorreu um erro SQL. Quando a existência de um registro é obrigatória para continuar a operação, precisamos verificar explicitamente quantas linhas foram afetadas.
Outro cuidado é não executar comandos relacionados à transação diretamente pelo sql.DB, porque essas chamadas podem acontecer fora da conexão reservada pelo sql.Tx. Isso pode produzir resultados inconsistentes ou provocar esperas desnecessárias por conexões disponíveis, principalmente quando o pool está limitado a uma conexão.
As transações também não desfazem automaticamente operações externas ao banco, como o envio de e-mails, chamadas a APIs ou gravações em arquivos independentes. Se uma aplicação enviar uma mensagem e depois executar Rollback(), a mensagem enviada não será desfeita. Para trabalhar com operações que envolvem diferentes sistemas, precisamos de mecanismos adicionais de coordenação.
Por fim, o exemplo de transferência apresenta as verificações necessárias para entender o funcionamento de uma transação, mas um sistema financeiro real precisa de controles adicionais. Entre eles estão o registro de movimentações, prevenção de operações duplicadas, regras de autorização e tratamento de limites numéricos. Uma transação garante que as operações incluídas nela sejam tratadas em conjunto, mas a definição de quais operações são corretas continua sendo responsabilidade da aplicação.