Transações com go-mssqldb

As transações agrupam múltiplas operações numa unidade atómica. Ou todas as operações são concluídas com êxito (commit) ou nenhuma delas produz efeitos (rollback). Este artigo aborda como usar transações com o go-mssqldb driver, incluindo níveis de isolamento, tratamento de erros e padrões para aplicações de produção.

Os exemplos deste artigo são executados na base de dados de exemplo AdventureWorks2025. Exemplos orientados à escrita têm como alvo HumanResources.Department, Production.ProductCategory, Production.ProductSubcategory, e Production.ProductInventory.

Iniciar uma transação

Usa db.BeginTx para iniciar uma transação. Os pins devolvidos *sql.Tx garantem uma única ligação do pool durante a vida útil da transação:

tx, err := db.BeginTx(ctx, nil) // nil uses the default isolation level
if err != nil {
    return err
}
defer tx.Rollback() // No-op if tx.Commit() succeeds first.

_, err = tx.ExecContext(ctx, "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
    sql.Named("p1", name),
    sql.Named("p2", groupName))
if err != nil {
    return err
}

return tx.Commit()

Important

Chame defer tx.Rollback() sempre imediatamente após BeginTx. Se Commit() for executado com êxito, a chamada diferida a Rollback() não faz nada. Se ocorrer algum erro antes de Commit(), a instrução adiada Rollback() garante que a transação não permaneça aberta nem devolva a ligação ao pool sem efetuar o commit.

Níveis de isolamento

O SQL Server suporta vários níveis de isolamento que controlam como as transações concorrentes interagem. Defina o nível de isolamento em sql.TxOptions:

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

Comparação de níveis de isolamento

Nível de isolamento Leituras sujas Leituras não repetíveis Phantom lê Impacto no desempenho Utilizar quando
sql.LevelReadUncommitted Sim Sim Sim Despesas gerais mais baixas Contagens aproximadas, painéis de monitorização. A precisão dos dados não é crítica.
sql.LevelReadCommitted No Sim Sim Predefinição. Bom para a maioria das cargas de trabalho. Cargas de trabalho OLTP gerais. O ponto de partida padrão e recomendado.
sql.LevelRepeatableRead No No Sim Moderado. Segura as fechaduras por mais tempo. Leituras que devem apresentar valores consistentes nas mesmas linhas durante a transação.
sql.LevelSerializable No No No Mais alto. Os bloqueios de intervalo impedem inserções concorrentes. Transações financeiras, gestão de inventário, qualquer situação em que as leituras fantasma sejam inaceitáveis.
sql.LevelSnapshot No No No Usa versionamento por linhas em tempdb. Sem bloqueios. Cargas de trabalho com utilização intensiva de leitura que exigem consistência num determinado momento, sem bloquear os processos de escrita.

Observação

sql.LevelSnapshot requer que o isolamento de snapshots esteja ativado na base de dados: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON.

Exemplo: Ler Commit vs. serializável

Especifique o nível de isolamento em sql.TxOptions:

// Read Committed (default) - suitable for most operations.
tx1, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

// Serializable - prevents phantom reads in financial calculations.
tx2, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})

Roteio de somente leitura

O driver não suporta sql.TxOptions.ReadOnly. Se passares ReadOnly: true, BeginTx devolve um erro.

Para o encaminhamento de apenas leitura do AlwaysOn, defina applicationintent=ReadOnly na cadeia de ligação ao abrir a ligação:

sqlserver://listener.example.com?database=AdventureWorks2025&applicationintent=ReadOnly

Isto é uma configuração ao nível da ligação. Pode encaminhar sessões elegíveis para um secundário legível, mas não torna uma transação existente apenas de leitura. Use uma ligação dedicada de apenas leitura ou credenciais de privilégio mínimo para cargas de trabalho que não devem gravar dados.

Tratamento de erros em transações

Lide com os erros em transações com cuidado. Quando qualquer instrução falha, a transação deve ser revertida na íntegra. Não tente continuar com outras afirmações após um erro:

func createCategory(ctx context.Context, db *sql.DB, category Category) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback()

    var categoryId int64
    err = tx.QueryRowContext(ctx, `
        INSERT INTO Production.ProductCategory (Name)
        OUTPUT INSERTED.ProductCategoryID
        VALUES (@name)`,
        sql.Named("name", category.Name)).Scan(&categoryId)
    if err != nil {
        return fmt.Errorf("insert category: %w", err)
    }

    // Insert subcategories under the new category.
    for _, sub := range category.Subcategories {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name)
            VALUES (@catId, @name)`,
            sql.Named("catId", categoryId),
            sql.Named("name", sub.Name))
        if err != nil {
            return fmt.Errorf("insert subcategory %s: %w", sub.Name, err)
        }
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit category: %w", err)
    }
    return nil
}

Se o procedimento em lote ou armazenado for executado com SET XACT_ABORT ON, trate qualquer erro de instrução como terminal para a transação. Recua imediatamente e não tente mais afirmações ou Commit(). As versões atuais dos drivers detetam transações abortadas pelo servidor e devolvem um erro em vez de permitir um commit parcial silencioso.

Guardar pontos

Os pontos de restauro criam pontos intermédios de reversão dentro de uma transação. O SQL Server suporta pontos de gravação nativos. Como o pacote database/sql do Go não expõe diretamente os pontos de restauro, execute-os como SQL em bruto através da transação:

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

    err = tx.QueryRowContext(ctx,
        "INSERT INTO Production.ProductCategory (Name) OUTPUT INSERTED.ProductCategoryID VALUES (@p1)",
        sql.Named("p1", category.Name)).Scan(&category.Id)
    if err != nil {
        return err
    }

    // Try to add a subcategory. If it fails, roll back only the subcategory part.
    _, err = tx.ExecContext(ctx, "SAVE TRANSACTION AddSubcategory")
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx,
        "INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name) VALUES (@p1, @p2)",
        sql.Named("p1", category.Id),
        sql.Named("p2", category.DefaultSubcategory))
    if err != nil {
        // Roll back only the subcategory; the category insert is preserved.
        _, rbErr := tx.ExecContext(ctx, "ROLLBACK TRANSACTION AddSubcategory")
        if rbErr != nil {
            return fmt.Errorf("rollback savepoint: %w (original: %w)", rbErr, err)
        }
        log.Printf("Subcategory %q failed, proceeding without it: %v",
            category.DefaultSubcategory, err)
    }

    return tx.Commit()
}

Observação

SAVE TRANSACTION <name> cria o ponto de restauro. ROLLBACK TRANSACTION <name> Reverte até esse ponto de restauro sem terminar a transação externa. ROLLBACK sem nome reverte a transação inteira.

Tratamento de interbloqueios

O SQL Server resolve os deadlocks terminando uma das transações concorrentes (a vítima do deadlock) e devolve o erro 1205. A transação terminada é automaticamente revertida pelo servidor.

Detetar e repetir impasses

Verifique o erro 1205 e tente novamente toda a transação com um pequeno atraso:

import (
    "errors"
    "fmt"
    "time"

    "github.com/microsoft/go-mssqldb"
)

func isDeadlock(err error) bool {
    var mssqlErr mssql.Error
    return errors.As(err, &mssqlErr) && mssqlErr.Number == 1205
}

func withDeadlockRetry(ctx context.Context, db *sql.DB, maxRetries int,
    fn func(ctx context.Context, tx *sql.Tx) error) error {

    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }

        err = fn(ctx, tx)
        if err != nil {
            tx.Rollback()
            if isDeadlock(err) && attempt < maxRetries-1 {
                // Wait briefly before retrying.
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }

        if err = tx.Commit(); err != nil {
            if isDeadlock(err) && attempt < maxRetries-1 {
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }
        return nil
    }
    return fmt.Errorf("transaction failed after %d deadlock retries", maxRetries)
}

Use o invólucro de repetição em caso de impasse

Passe uma função de transação para o invólucro de repetição:

err := withDeadlockRetry(ctx, db, 3, func(ctx context.Context, tx *sql.Tx) error {
    _, err := tx.ExecContext(ctx,
        "UPDATE Production.ProductInventory SET Quantity = Quantity - @qty WHERE ProductID = @pid AND LocationID = @lid",
        sql.Named("qty", orderQty),
        sql.Named("pid", productId),
        sql.Named("lid", locationId))
    return err
})

Reduzir impasses

Strategy Como ajuda
Aceder a tabelas numa ordem consistente Quando todas as transações bloqueiam a Tabela A antes da Tabela B, não podem ocorrer esperas circulares.
Mantenha as transações curtas Transações mais curtas mantêm bloqueios por menos tempo, reduzindo a janela para conflitos.
Use o nível de isolamento suficiente mais baixo ReadCommitted tem menos bloqueios do que Serializable.
Adicionar índices apropriados As atualizações direcionadas ao índice bloqueiam menos linhas do que as análises de tabelas.
Evite a interação do utilizador durante as transações Nunca espere pela entrada do utilizador entre BeginTx e Commit.

Voltar a tentar é a abordagem correta no código da aplicação, mas interbloqueios repetidos na mesma consulta indicam um problema de conceção. Use o grafo de deadlock do SQL Server (capturado através de Eventos Estendidos ou da sessão de saúde do sistema) para identificar as instruções concorrentes e os tipos de bloqueio, aplicando depois as estratégias na tabela anterior. Para uma explicação detalhada da análise e prevenção de interbloqueios, consulte o guia sobre interbloqueios.

Transações e fixação de ligação

Uma transação fixa uma única ligação do pool até Commit() que ou Rollback() seja chamada. Durante este tempo, nenhuma outra goroutine pode usar essa conexão.

Implicações:

  • Transações de longa duração reduzem o tamanho efetivo do pool. Se tiveres MaxOpenConns=25 e 20 transações abertas, só 5 ligações estão disponíveis para outras tarefas.
  • Um Rollback() esquecido deixa a ligação aberta permanentemente.
  • O cancelamento de contexto no contexto da transação reverte a transação e devolve a ligação ao pool.
// Set a deadline to prevent transactions from running indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

Transações simultâneas

Cada goroutine deve criar a sua própria transação. Nunca partilhe um(a) *sql.Tx entre goroutines, porque *sql.Tx não é seguro para utilização concorrente:

// CORRECT: Each goroutine gets its own transaction.
var g errgroup.Group
for _, item := range items {
    item := item
    g.Go(func() error {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
        defer tx.Rollback()

        _, err = tx.ExecContext(ctx,
            "UPDATE Production.ProductInventory SET Quantity = Quantity - 1 WHERE ProductID = @p1 AND LocationID = @p2",
            sql.Named("p1", item.ProductId),
            sql.Named("p2", item.LocationId))
        if err != nil {
            return err
        }
        return tx.Commit()
    })
}
return g.Wait()

Transações distribuídas

O go-mssqldb driver não suporta transações distribuídas (transações XA ou System.Transactions equivalentes). Se precisar de coordenar trabalho em várias bases de dados:

  • Utilize o padrão saga com ações compensatórias.
  • Consolide as operações numa única base de dados sempre que possível.
  • Utilize servidores ligados com BEGIN DISTRIBUTED TRANSACTION no Transact-SQL (T-SQL) se ambas as bases de dados forem do SQL Server.

Lista de verificação de transações

Area Recommendation
Segurança da reversão Sempre defer tx.Rollback() imediatamente após BeginTx.
Nível de isolamento Comece por ReadCommitted (o padrão). Escalar só quando for necessário.
Deadlocks Envolva o código transacional num ciclo de repetição. Aceder às tabelas numa ordem consistente.
Duração Mantenha as transações o mais curtas possível. Defina prazos de contexto.
Concurrency Nunca partilhem um *sql.Tx entre as rotinas.
Guardar pontos Uso SAVE TRANSACTION e ROLLBACK TRANSACTION <name> para recuo parcial.
Impacto da piscina Monitorize db.Stats().InUse para detetar fugas de ligação causadas por transações não confirmadas.