Транзакции с go-mssqldb

Транзакции объединяют несколько операций в атомарную единицу. Либо все операции успешны (фиксация), либо ни одна из них не вступает в силу (откат). В этой статье рассматривается, как использовать транзакции с драйвером go-mssqldb , включая уровни изоляции, обработку ошибок и шаблоны для производственных приложений.

Примеры в этой статье выполняются в образце базы данных AdventureWorks2025. Примеры для операций записи ориентированы на HumanResources.Department, Production.ProductCategory, Production.ProductSubcategory и Production.ProductInventory.

Начать транзакцию

Используйте db.BeginTx для начала сделки. *sql.Tx закрепляет одно соединение из пула на всё время выполнения транзакции:

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

Всегда звоните defer tx.Rollback() сразу после BeginTx. Если выполнение Commit() прошло успешно, то отложенный Rollback() не выполняет никаких действий. Если какая-либо ошибка возникает до Commit(), отложенный Rollback() гарантирует, что транзакция не останется открытой и что соединение не будет возвращено в пул в неподтверждённом состоянии.

Уровни изоляции

SQL Server поддерживает несколько уровней изоляции, контролирующих взаимодействие одновременных транзакций. Установить уровень изоляции в sql.TxOptions:

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

Сравнение уровней изоляции

Уровень изоляции Грязные считывания Неповторимые чтения Фантомное чтение Влияние на производительность Используйте, если
sql.LevelReadUncommitted Yes Yes Yes Минимальные накладные расходы Приблизительный подсчёт, мониторинговые панели. Точность данных не критична.
sql.LevelReadCommitted No Yes Yes По умолчанию. Подходит для большинства рабочих нагрузок. Общие нагрузки по OLTP. Стандартная и рекомендуемая отправная точка.
sql.LevelRepeatableRead No No Yes Умеренный. Держит замки дольше. Чтения, которые должны видеть согласованные значения для тех же строк внутри транзакции.
sql.LevelSerializable No No No Наивысший. Блокировки диапазона блокируют параллельные вставки. Финансовые операции, управление запасами, любые фантомные считывания — это недопустимо.
sql.LevelSnapshot No No No Использует версионирование строк в tempdb. Никаких блокировок. Нагрузка с большим количеством чтения, требующая стабильности в момент времени, не блокируя авторов.

Note

sql.LevelSnapshot требует включения выделения снимков в базе данных: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON.

Пример: Read committed vs. Serializable

Укажите уровень изоляции в 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,
})

Маршрутизация только для чтения

Драйвер не поддерживает sql.TxOptions.ReadOnly. Если вы передаёте ReadOnly: true, BeginTx возвращает ошибку.

Для маршрутизации подключений только для чтения AlwaysOn установите applicationintent=ReadOnly в строке подключения при открытии подключения:

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

Это настройка на уровне соединения. Он может направлять подходящие сеансы на вторичный сервер, доступный для чтения, но не переводит существующую транзакцию в режим только чтения. Используйте выделенное подключение только для чтения или учетные данные с наименьшими привилегиями для рабочих нагрузок, которые не должны записывать данные.

Обработка ошибок в транзакциях

Аккуратно обрабатывайте ошибки в транзакциях. Если какая-либо выписка не проходит, транзакция должна быть полностью откатена. Не пытайтесь продолжать другие утверждения после ошибки:

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
}

Если пакетная или сохранённая процедура выполняется с SET XACT_ABORT ON, считайте любую ошибку оператора терминальной для транзакции. Немедленно откатитесь назад и не пытайтесь делать новые заявления или Commit(). Текущие версии драйверов обнаруживают транзакции, прерванные сервером, и возвращают ошибку вместо того, чтобы допускать тихое частичное подтверждение транзакции.

Точки сохранения

Точки сохранения создают промежуточные точки отката внутри транзакции. SQL Server поддерживает точки сохранения нативно. Поскольку пакет Go database/sql не предоставляет прямого доступа к точкам сохранения, выполняйте их как необработанные SQL-команды через транзакцию:

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()
}

Note

SAVE TRANSACTION <name> Создаёт точку сохранения. ROLLBACK TRANSACTION <name> откатывается обратно к этой точке сохранения, не завершая внешнюю транзакцию. ROLLBACK без имени откатывает всю транзакцию.

Обработка тупика

SQL Server устраняет тупиковые блокировки, завершая одну из конкурирующих транзакций (жертву тупика) и возвращая ошибку 1205. Завершившаяся транзакция автоматически откатывается сервером.

Обнаружение и повторная проверка тупиков

Проверьте ошибку 1205 и повторите всю транзакцию с небольшой задержкой:

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)
}

Используйте обёртку повторного теста с deadlock

Передайте функцию транзакции в обёртку для повторных попыток:

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
})

Уменьшить тупиковые блокировки

Strategy Как это помогает
Обращаться к таблицам в одном и том же порядке Когда все транзакции блокируют таблицу A перед таблицей B, круговые ожидания не могут происходить.
Держите транзакции короткими Короткие транзакции удерживают блокировки в течение меньшего времени, что сокращает окно для конфликтов.
Используйте минимальный уровень изоляции ReadCommitted содержит меньше замков, чем Serializable.
Добавьте соответствующие индексы Обновления, ориентированные на индекс, блокируют меньше строк, чем сканы таблиц.
Избегайте взаимодействия пользователей во время транзакций Никогда не ждите ввода пользователя между BeginTx и Commit.

Повторная попытка — это правильный ответ в коде приложения, но повторяющиеся блокировки по одному и тому же запросу указывают на проблему проектирования. Используйте граф взаимоблокировки SQL Server, полученный с помощью Extended Events или сеанса system_health, чтобы определить конфликтующие инструкции и типы блокировок, а затем примените стратегии из предыдущей таблицы. Для полного обзора анализа и предотвращения тупиков смотрите руководство по Deadlocks.

Транзакции и закрепление соединений

Транзакция закрепляет за собой одно соединение из пула до вызова Commit() или Rollback(). В это время ни одна другая горутина не может использовать это соединение.

Последствия:

  • Долгосрочные сделки уменьшают эффективный размер пула. Если у вас есть MaxOpenConns=25 и 20 открытых транзакций, для других задач доступно только 5 подключений.
  • Из-за забытого Rollback() соединение навсегда остаётся незакрытым.
  • Отмена контекста в контексте транзакции откатывает транзакцию назад и возвращает соединение в пул.
// 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()

Параллельные транзакции

Каждая горутина должна создавать собственную транзакцию. Никогда не передавайте *sql.Tx между горутинами, потому что *sql.Tx небезопасен для одновременного использования:

// 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()

Распределенные транзакции

Драйвер go-mssqldb не поддерживает распределённые транзакции (транзакции XA или System.Transactions их аналоги). Если вам нужно координировать работу между несколькими базами данных:

  • Используйте паттерн саги с компенсирующими действиями.
  • Консолидировать операции в единую базу данных, когда это возможно.
  • Используйте связанные серверы с BEGIN DISTRIBUTED TRANSACTION из Transact-SQL (T-SQL), если обе базы данных размещены в SQL Server.

Чек-лист транзакций

Area Recommendation
Безопасность при откате Всегда defer tx.Rollback() сразу после BeginTx.
Уровень изоляции Начните с ReadCommitted (по умолчанию). Эскалируйте только при необходимости.
Взаимоблокировки Оберните транзакционный код в цикл повторных попыток. Обращайтесь к таблицам в одном и том же порядке.
Duration Держите транзакции как можно короче. Устанавливайте контекстные сроки.
Concurrency Никогда не используйте *sql.Tx совместно между горутинами.
Точки сохранения Используйте SAVE TRANSACTION и ROLLBACK TRANSACTION <name> для частичного отката.
Влияние на бассейн Отслеживайте db.Stats().InUse, чтобы выявлять утечки соединений, вызванные неподтверждёнными транзакциями.