Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~22 мин +50 XP

Транзакции — db.Transaction, вложенность, SavePoint и уровни изоляции в GORM

Четырнадцатый урок научил читать канон целиком; теперь — писать его надёжно. Пока каждая запись GORM выполнялась одиночной командой, вопрос целостности решался молча: Create, Update и Delete по умолчанию оборачиваются в транзакцию, и об этом в пятом уроке сказано ровно одно предложение при разборе конвейера gorm:create. Настоящая работа живёт из нескольких шагов, и gorm транзакции — это способ собрать многошаговую запись в атомарный маршрут: либо выполняются все шаги, либо ни один. Сломать сценарий проще простого: перенос задачи из списка «Дом» в «Работа» — это Update задачи, создание комментария «Перенесено» и правка пары полей; если третий шаг упадёт, а первые два уже уехали, база останется в полуфабрикате, из которого её вытащит только ручной скрипт. В этом уроке — db.Transaction как основной инструмент (вернули ошибку — откат, nil — коммит), правило tx вместо db, вложенные вызовы, которые превращаются в SavePoint автоматически, ручное управление через Begin и Commit и уровни изоляции через sql.TxOptions. Кто набирает в поиске transaction go, ищет ровно это: begin вокруг нескольких операций и гарантированный rollback при любой ошибке. Одна честная оговорка о формате: СУБД в браузерной песочнице не поставить, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а SQL-следы рассчитаны на локальный Go с PostgreSQL или SQLite и логгер из второго урока.

Содержание урока · 13 разделов

gorm транзакции: полуфабрикат вместо атомарного маршрута#

Канон к этому моменту умеет почти всё: модели и связи, Create с каскадом, чтение жадной загрузкой — и всё это работает при условии, что каждый запрос самодостаточен. Но настоящий сценарий — не запрос, а маршрут из нескольких шагов. Возьмём перенос задачи из списка «Дом» в «Работа»: Update задачи со сменой todo_list_id, затем комментарий «Перенесено в «Работа» как журнал, затем, возможно, правка служебных полей списков. Каждая команда GORM выполняет честно и последовательно — и вот в чём проблема: между шагами база переживает состояния, которых не должно существовать. Задача уже переехала, а журнал о переносе ещё не создан; упади второй шаг — состояние зависнет навсегда, потому что ни один механизм сам его не починит: база считает, что всё хорошо, ведь каждый оператор по отдельности прошёл успешно. gorm транзакции существуют ровно для этого: собрать шаги в атомарный маршрут, для читателя которого есть только «до» и «после» и нет промежуточных полуфабрикатов. Атомарность — это свойство единицы работы, а не отдельных операторов; СУБД умеет её обеспечивать, если ей об этом сказать словом BEGIN и закончить словом COMMIT или ROLLBACK. Кто набирает в поиске transaction go, ищет ту же вещь: begin вокруг нескольких операций и гарантированный откат, если что-то пошло не так. Дальше разберём всю лестницу управления транзакциями в GORM: от авто-обёртки одиночных команд — до вложенных блоков, SavePoint и уровней изоляции.

След без транзакции: шаги уезжают по отдельности

GO
// Канон: переносим задачу №4 из списка «Дом» (id=1) в «Работа» (id=2)
db.Model(&task).Update("todo_list_id", 2)
// UPDATE todo_items SET todo_list_id=2, updated_at='...' WHERE id = 4
// Задача переехала. Авто-транзакция этого шага уже ЗАКОММИЧЕНА.

// Шаг 2: журнал переноса - но забыли внешний ключ
db.Create(&Comment{Body: "Перенесено в «Работа»"})
// INSERT INTO comments (todo_item_id, body, ...) VALUES (0, ...)
// ОШИБКА: нарушение внешнего ключа - todo_item_id=0 не существует.

// Итог: задача в «Работа», журнала нет. Никто не откатит шаг 1:
// для базы это два независимых оператора, каждый из них атомарен сам.

Обратите внимание на след: у каждой команды свой UPDATE или INSERT, и GORM по умолчанию обёртывает каждую в короткую транзакцию — BEGIN, оператор, COMMIT. Атомарность есть, но радиус у неё один оператор: первый шаг успешно закоммичен ещё до того, как второй пошёл в базу. Когда второй падает с нарушением внешнего ключа, откатывается только он сам — база остаётся с задачей, которая переехала без журнала. Именно этот разрыв между «оператор атомарен» и «сценарий атомарен» лечится одним BEGIN на весь сценарий.

Проверка: чего не хватает авто-транзакции

Сценарий переноса задачи без блока: db.Model(&task).Update(...) прошёл, db.Create(&Comment{...}) упал с ошибкой. Что гарантировала авто-транзакция GORM по умолчанию?

Авто-транзакция изнутри: gorm:begin_transaction и SkipDefaultTransaction#

Разберём обёртку по исходникам — она проще, чем кажется. Реализация обоих колбэков живёт в callbacks/transaction.go, а в callbacks/callbacks.go у трёх конвейеров записи — create, update, delete — первым зарегистрирован колбэк gorm:begin_transaction, а последним — gorm:commit_or_rollback_transaction; оба вешаются с условием Match(enableTransaction), где enableTransaction — это просто !db.SkipDefaultTransaction. У конвейеров чтения — gorm:query и gorm:preload из четырнадцатого урока — таких колбэков нет: SELECT-ы в авто-транзакции не едут. Начало: BeginTransaction проверяет db.Error (запрос уже битый — открывать транзакцию поздно), зовёт db.Begin(), подменяет Statement.ConnPool на соединение транзакции и помечает факт InstanceSet("gorm:started_transaction", true). Конец: CommitOrRollbackTransaction смотрит на метку и решает — db.Error != nil значит Rollback, иначе Commit, после чего возвращает ConnPool обратно. Отсюда правило: одиночная запись атомарна по умолчанию, но три последовательных записи — это три отдельных транзакции, а не одна. Теперь SkipDefaultTransaction — углубим то, что пятый урок обронил мимоходом. Комментарий в gorm.go говорит прямо: single create, update, delete operations run in transactions by default to ensure database data integrity. Отключение — глобально в gorm.Open через &gorm.Config{SkipDefaultTransaction: true} или точечно через db.Session(&Session{SkipDefaultTransaction: true}) — снимает обёртку и даёт, по доке, about 30%+ performance improvement. Отключать честно тогда, когда каждая запись — одна команда к одной таблице и атомарна сама по себе, а каскадов и хуков, которым нужна общая судьба, нет; помните и про батч-вставку из пятого урока — там обёртка тоже исчезнет, потому и совет был «подумайте дважды». Последний нюанс: внутри уже открытой транзакции авто-обёртка не сработает второй раз — BeginTransaction вызовет db.Begin(), чей ConnPool уже *sql.Tx, получит ErrInvalidTransaction и молча его погасит: операция спокойно едет во внешней транзакции.

db.Transaction: блок, где ошибка = rollback#

Для многошаговой записи у GORM есть блок: db.Transaction(func(tx gorm.DB) error { ... }). Дока описывает его формулой: возвращаете ошибку — откат, nil — коммит; комментарии в доковском примере говорят то же самое — return any error will rollback и return nil will commit the whole transaction. В исходниках finisher_api.go метод документирован как start a transaction as a block — «транзакция как блок», и это точнее всего: тело замыкания и есть ваш блок. Механика на успех: db.Begin(opts...) открывает транзакцию, fc(tx) исполняется, при err == nil сбрасывается флаг panicked и возвращается tx.Commit().Error — то есть ошибка самого COMMIT тоже приедет наружу, проверяйте возвращаемое значение. Механика на провал важнее: deferred-функция откатывает транзакцию, если err != nil ИЛИ panicked — да, паника внутри fc тоже откатывает блок (rollback выполняется на этапе размотки стека), но сама паника не гасится и улетает вызывающему, как и без GORM. Главное правило блока сформулировано в доке прямо: do some database operations in the transaction (use 'tx' from this point, not 'db'). tx — это тот же gorm.DB, у которого ConnPool подменён на *sql.Tx открытой транзакции; db — прежний пул с autocommit. Операция через db внутри блока выполнится немедленно и останется в базе, даже когда блок откатится, — самая тихая ошибка из возможных. Возвращаемое значение db.Transaction — либо ваша ошибка из fc, либо ошибка Commit; Count и RowsAffected здесь не нужны: блок либо сработал целиком, либо не сработал вообще.

Перенос задачи внутри db.Transaction

GO
var dst TodoList // список «Работа» (id=2), прочитан заранее
err := db.Transaction(func(tx *gorm.DB) error {
    // do some database operations in the transaction
    // (use 'tx' from this point, not 'db')
    if err := tx.Model(&task).Update("todo_list_id", dst.ID).Error; err != nil {
        return err // return any error will rollback
    }
    if err := tx.Create(&Comment{
        TodoItemID: task.ID,
        Body:       "Перенесено в «Работа»",
    }).Error; err != nil {
        return err // откатит и UPDATE первого шага тоже
    }
    return nil // return nil will commit the whole transaction
})
if err != nil {
    log.Println("перенос не удался:", err) // в следе будет ROLLBACK
}

След счастливого пути: BEGIN; UPDATE todo_items ...; INSERT INTO comments ...; COMMIT. След неудачи: BEGIN; UPDATE; INSERT (ошибка); ROLLBACK — и задача остаётся в «Дом». Никакого промежуточного состояния снаружи не существует ни в один момент времени: параллельный читатель со своим Preload увидит либо старое расположение задачи, либо новое — об этом и был тизер четырнадцатого урока. Ошибка вернулась из db.Transaction как обычный error — дальше с ней работает ваш код: повторить, залогировать, вернуть пользователю.

Проверка: tx против db внутри блока

Внутри db.Transaction(func(tx *gorm.DB) error { ... }) по ошибке написано db.Model(&task).Update("todo_list_id", 2), затем tx.Create(...). fc вернул ошибку. Что окажется в базе?

Что останется после ошибки внутри блока

Прочитайте код и выберите, что он выведет:

var n int64
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&TodoItem{Title: "Полить цветы", TodoListID: 1}).Error; err != nil {
        return err
    }
    return errors.New("передумали") // ошибка ПОСЛЕ успешного Create
})
db.Model(&TodoItem{}).Where("title = ?", "Полить цветы").Count(&n)
fmt.Println(n, err != nil)

Вложенные Transaction: SavePoint ставится сам#

А что, если вызвать db.Transaction внутри db.Transaction? Ничего не сломается — GORM поддерживает вложенные блоки, и та же самая функция Transaction разрулит это сама. Детектор примитивен и надёжен: в начале метода Statement.ConnPool проверяется на TxCommitter — интерфейс с Commit и Rollback; внутри открытой транзакции ConnPool — это *sql.Tx, то есть TxCommitter, и метод уходит в ветку nested transaction. Дальше, если конфиг DisableNestedTransaction не задан, GORM ставит savepoint за вас: spID := new(maphash.Hash).Sum64() — случайное 64-битное число (не счётчик, на конкретное имя рассчитывать нельзя), затем db.SavePoint(fmt.Sprintf("sp%d", spID)), а deferred-функция при panicked || err != nil делает db.RollbackTo с тем же именем. Тело внутреннего блока исполняется на db.Session(&Session{NewDB: db.clone == 1}) — свежем инстансе с чистым Statement, чтобы цепочки Where не протекали между уровнями. Семантика ровно из доки: вы можете откатить подмножество операций внутри большого блока — rollback a subset of operations performed within the scope of a larger transaction. В доковском примере user1 создаётся внешним блоком, первый внутренний блок с user2 возвращает errors.New("rollback user2"), второй внутренний блок с user3 возвращает nil — финальный комментарий доки: Commit user1, user3. Заметьте: ошибка внутреннего блока возвращается из tx.Transaction как обычный error — что с ней делать, решаете вы: залогировать и продолжить (тогда внешний COMMIT пройдёт, минус откачанная часть) или пробросить дальше (тогда deferred внешнего блока откатит всё, включая прошедшие шаги). А DisableNestedTransaction: true меняет поведение: savepoint не ставится, внутреннее тело едет на той же транзакции, и его ошибка, выйдя наружу, откатывает уже весь внешний блок.

Вложенный блок: откат подмножества операций

GO
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Model(&task).Update("todo_list_id", dst.ID).Error; err != nil {
        return err
    }
    // Внутренний блок: GORM сам ставит SAVEPOINT sp<число>
    if err := tx.Transaction(func(tx2 *gorm.DB) error {
        return tx2.Create(&Comment{TodoItemID: task.ID, Body: "Перенесено"}).Error
    }); err != nil {
        // ROLLBACK TO SAVEPOINT sp<...>: откачен только журнал
        // ошибка НЕ роняет внешний блок - обрабатываем сами
        log.Println("журнал не записался, перенос продолжается:", err)
    }
    return nil
})
// След: BEGIN; UPDATE; SAVEPOINT sp...; INSERT; [ROLLBACK TO SAVEPOINT]; COMMIT

Схема честного журнала: перенос важнее журнала, поэтому сбой журнала не отменяет перенос. GORM откатывает к savepoint ровно внутренний блок, ваш обработчик глотает ошибку, внешний блок завершается nil — COMMIT фиксирует UPDATE. Если бы внешний fc пробросил ошибку дальше, deferred-функция внешнего блока откатила бы всё: и savepoint-ветку, и UPDATE. Паника во внутреннем теле тоже откатывается к точке сохранения — но, как всегда, не гасится.

Проверка: семантика вложенного блока

Внешний db.Transaction создал user1. Внутри tx.Transaction внутренний блок создал user2 и вернул ошибку; внешний fc эту ошибку обработал и вернул nil. Что закоммитится?

SavePoint и RollbackTo вручную#

SavePoint можно и нужно уметь ставить руками — это оператор СУБД, аккуратно прокинутый через API. Дока: GORM provides SavePoint, RollbackTo to save points and roll back to a savepoint. Методы живут на *gorm.DB рядом с Commit и Rollback: tx.SavePoint("sp1") уезжает как SAVEPOINT sp1, tx.RollbackTo("sp1") — как ROLLBACK TO SAVEPOINT sp1, а SQL для диалекта строит dialector. За кулисами — интерфейс SavePointerDialectorInterface с двумя методами SavePoint(tx, name) и RollbackTo(tx, name): драйверы MySQL, Postgres и SQLite его реализуют, а что-то без поддержки вернёт ErrUnsupportedDriver. Три факта, которые сберегут нервы. Первый: savepoint существует только внутри открытой транзакции — голый db.SavePoint на autocommit-соединении не оставит после себя живой точки сохранения; ставьте их на tx. Второй: ROLLBACK TO SAVEPOINT откатывает работу после точки и уничтожает более поздние savepoints, но не закрывает саму транзакцию — COMMIT после него легален и зафиксирует всё, что выжило. Третий, для любителей PrepareStmt: в исходниках методы SavePoint и RollbackTo временно подменяют PreparedStmtTX на сырой Tx и восстанавливают обратно — с комментарием, что savepoint-команды несовместимы с prepared statements, MySQL 8.0 на это явно ругается. GORM делает грязную работу за вас, но знайте: с включённым PrepareStmt каждое SavePoint и RollbackTo теряет кэш подготовленных выражений этой транзакции. Доковский пример минимален: tx := db.Begin(); Create user1; tx.SavePoint("sp1"); Create user2; tx.RollbackTo("sp1") — Rollback user2; tx.Commit() — Commit user1. Именно его повторим на каноне ниже.

Ручной SavePoint по доке

GO
tx := db.Begin()
// SAVEPOINT живут только внутри открытой транзакции
tx.Create(&TodoItem{Title: "Задача A", TodoListID: 1})
tx.SavePoint("sp1") // SAVEPOINT sp1
tx.Create(&TodoItem{Title: "Задача B", TodoListID: 1})
tx.RollbackTo("sp1") // ROLLBACK TO SAVEPOINT sp1 - «Задача B» откачена
tx.Create(&TodoItem{Title: "Задача C", TodoListID: 1})
tx.Commit() // COMMIT: в базе останутся A и C

Разница с вложенным db.Transaction только в управлении: здесь вы сами решаете, где точка и когда к ней возвращаться, а GORM лишь переводит вызовы в SAVEPOINT и ROLLBACK TO SAVEPOINT. Обратите внимание: RollbackTo не закрыл транзакцию — после него Create для задачи C поехал в той же транзакции и дожил до COMMIT. Если драйвер не умеет savepoints, обе команды вернут ErrUnsupportedDriver в Error.

Что останется в базе после savepoint-развилки

Прочитайте код и выберите, что он выведет:

// Последовательность из доки:
tx := db.Begin()
tx.Create(&TodoItem{Title: "A", TodoListID: 1})
tx.SavePoint("sp1")
tx.Create(&TodoItem{Title: "B", TodoListID: 1})
tx.RollbackTo("sp1")
tx.Create(&TodoItem{Title: "C", TodoListID: 1})
tx.Commit()
// После COMMIT в таблице:

Ручное управление: Begin, Commit, Rollback и recover#

Замыкание — не единственный стиль. GORM поддерживает вызов команд управления транзакцией напрямую: tx := db.Begin(), операции на tx, затем tx.Rollback() или tx.Commit(). Дока показывает аккуратный пример CreateAnimals, и его стоит разобрать построчно, потому что он собирает все правила сразу. Первое: db.Begin() возвращает DB — конвейер, у которого ConnPool подменён на транзакцию; ошибка старта живёт в tx.Error, и её проверяют сразу после Begin: if err := tx.Error; err != nil { return err }. Внутри Begin используется BeginTx контекста соединения — интерфейсы TxBeginner и ConnPoolBeginner; что-то третье даст ErrInvalidTransaction, а настройка DefaultTransactionTimeout, если она задана и у контекста нет дедлайна, оборачивает контекст в context.WithTimeout. Второе: каждая ошибка операции — tx.Rollback() и возврат; забытый Commit после Begin оставляет соединение в висящей транзакции, и пул медленно истощается. Третье: defer с recover — если тело паникует, tx.Rollback() выполняется на размотке: ровно то, что db.Transaction делает под капотом в своём флаге panicked. Четвёртое: финал — return tx.Commit().Error, ошибка коммита возвращается вызывающему. Ориентиры выбора: замыкание Transaction — стиль по умолчанию, компактный и паникобезопасный; ручной Begin и Commit берут тогда, когда транзакцию не натянуть на одну функцию — ранние возвраты из глубины, передача tx между функциями, логирование промежуточных шагов. И предупреждение из исходников: Commit и Rollback на gorm.DB без транзакции дают ErrInvalidTransaction — оно попадёт в Error результата, а не в панику, так что проверяйте результат явно.

Перенос задачи вручную: Begin, defer, Commit

GO
func MoveTask(db *gorm.DB, task *TodoItem, dstListID uint) error {
    tx := db.Begin() // BEGIN: у tx.ConnPool теперь *sql.Tx
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback() // паника тоже откатывает, как в доке
        }
    }()
    if err := tx.Error; err != nil { // ошибка самого Begin живёт в tx.Error
        return err
    }
    if err := tx.Model(task).Update("todo_list_id", dstListID).Error; err != nil {
        tx.Rollback()
        return err
    }
    if err := tx.Create(&Comment{TodoItemID: task.ID, Body: "Перенесено"}).Error; err != nil {
        tx.Rollback()
        return err
    }
    return tx.Commit().Error // COMMIT; его ошибка - наша
}

Тот же маршрут переноса, что и в db.Transaction, только все швы открыты: Begin, проверка tx.Error, откат на каждой ветке, recover в defer, финальный Commit. Вместить это в замыкание было бы тоже можно — но представьте функцию на двести строк с десятью ранними возвратами: ручной стиль держит транзакцию на всей функции, а не на одном блоке. Цена — дисциплина: забытый Rollback на одной ветке = висящая транзакция в пуле.

Уровни изоляции: sql.TxOptions вместо clause.Isolation#

Уровень изоляции — это ответ СУБД на вопрос, какие чужие недоделанные изменения вы можете увидеть. Классические аномалии: грязное чтение (dirty read) — данные чужой незакоммиченной транзакции; неповторяющееся чтение (non-repeatable read) — повторный SELECT видит другой закоммиченный UPDATE той же строки; фантомы (phantom) — повторный запрос видит новые подходящие строки. GORM не изобретает свою лестницу: clause.Isolation в ядре нет — слово Isolation в исходниках GORM не встречается вовсе — а изоляция задаётся стандартным *sql.TxOptions из database/sql, который Transaction и Begin принимают вариадиком: db.Transaction(fc, &sql.TxOptions{Isolation: sql.LevelSerializable}) или db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead}). Набор констант database/sql: LevelDefault — не трогать, работает серверный уровень; далее LevelReadUncommitted, LevelReadCommitted, LevelRepeatableRead, LevelWriteCommitted, LevelSerializable, LevelLinearizable. Что с ними сделает драйвер — вопрос уже драйвера. go-sql-driver/mysql перед BEGIN выполняет SET TRANSACTION ISOLATION LEVEL: карта уровней знает четыре классических — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE; остальное даёт «mysql: unsupported isolation level». lib/pq строит BEGIN с хвостом ISOLATION LEVEL READ COMMITTED или другим из тех же четырёх. Дефолты помните всегда: PostgreSQL — READ COMMITTED, и его READ UNCOMMITTED ведёт себя как READ COMMITTED; MySQL InnoDB — REPEATABLE READ. Практика: для обычного CRUD дефолт честен; REPEATABLE READ берут для отчётов, где повторные SELECT обязаны видеть один и тот же снимок; SERIALIZABLE — для денег и инвариантов, помня, что конфликтующие транзакции получают ошибку сериализации и требуют повтора. Уровень — свойство транзакции, а не сессии навсегда: в MySQL SET TRANSACTION ISOLATION LEVEL без SESSION и GLOBAL действует на следующую транзакцию соединения — потому драйвер и ставит его прямо перед вашим BEGIN.

Шпаргалка: уровни изоляции и аномалии#

УровеньГрязное чтениеНеповторяющеесяФантомыКто и когда
LevelReadUncommittedвозможновозможновозможнов PostgreSQL ведёт себя как READ COMMITTED; драйвером MySQL поддержан
LevelReadCommittedнетвозможновозможнодефолт PostgreSQL; разумный дефолт для CRUD
LevelRepeatableReadнетнетпо стандарту возможныдефолт MySQL InnoDB; в PG — снимок на транзакцию, фантомы тоже отфильтрованы
LevelSerializableнетнетнетдля денег и инвариантов; конфликт — ошибка сериализации, нужен ретрай
LevelDefault———драйвер уровень не выставляет: работает серверный дефолт
LevelWriteCommitted, LevelLinearizable———mysql: unsupported isolation level — вне карты уровней go-sql-driver/mysql

Заполните пропуски: блок с savepoint и уровнем изоляции

Соберите четыре пропуска по порядку: метод-обёртка, точка сохранения, откат к ней и константа уровня изоляции из database/sql.

err := db.(func(tx *gorm.DB) error { if err := tx.Create(&task).Error; err != nil { return err // ошибка = rollback } tx.("sp1") if err := tx.Create(&Comment{TodoItemID: task.ID}).Error; err != nil { tx.("sp1") // журнал не нужен - вернуть состояние точки return err } return nil }, &sql.TxOptions{Isolation: sql.})

Соберите жизненный цикл db.Transaction при успехе

Расставьте шаги от вызова db.Transaction до возврата наружу — счастливый путь без ошибок.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Чек-лист урока#

  • авто-транзакция едет у одиночных Create, Update и Delete: gorm:begin_transaction — первый колбэк, gorm:commit_or_rollback_transaction — последний; у SELECT-ов её нет
  • SkipDefaultTransaction отключает обёртку глобально или через Session — около 30%+ выигрыша на одиночных записях, но исчезает атомарность каскадов
  • db.Transaction — блок: fc(tx), ошибка = rollback, nil = commit; panic тоже откатывает, но не гасится; наружу возвращается err из fc или из Commit
  • внутри блока только tx: операции через db идут мимо транзакции и не откатятся
  • вложенный Transaction внутри транзакции превращается в SAVEPOINT sp<число> (имя из maphash) с RollbackTo при ошибке; DisableNestedTransaction отключает
  • tx.SavePoint и tx.RollbackTo — ручные точки отката внутри открытой транзакции; без поддержки диалектом — ErrUnsupportedDriver
  • ручной стиль: db.Begin, проверка tx.Error, defer с recover и Rollback, финальный tx.Commit().Error
  • уровень изоляции задаётся *sql.TxOptions в Transaction или Begin; clause.Isolation в GORM нет; MySQL-драйвер знает четыре классических уровня

Что дальше по курсу#

Лестница транзакций пройдена целиком: авто-обёртка одиночных команд, блок db.Transaction с ошибкой вместо COMMIT, вложенные вызовы, которые сами превращаются в SavePoint, ручной Begin и Commit и уровни изоляции через sql.TxOptions. Полезно вернуться и перечитать свой код: многошаговая запись без блока — самое частое место тихих полуфабрикатов, а m2m-канон из тринадцатого урока с его тегами — готовый кандидат на блок: назначение нескольких тегов задаче хочется выполнять целиком. Preload внутри блока видит ровно те данные, что и его владелец, — gorm:query и gorm:preload едут по ConnPool той же транзакции, о чём четырнадцатый урок обещал в финале. Карта курса из 25 уроков — на странице курса. Дальше — периферия записи: Шестнадцатый урок «Хуки жизненного цикла» покажет BeforeCreate и AfterUpdate, валидацию в хуках и одну важную связку с сегодняшней темой: ошибка, возвращённая из хука, попадает в db.Error, а значит — gorm:commit_or_rollback_transaction откатит авто-транзакцию: хук — ещё один рычаг атомарности, теперь вы знаете, куда он врезается. Домашка ниже собирает весь урок в один скрипт.

Карточки: транзакции в GORM

Термин

db.Transaction

нажмите, чтобы перевернуть
Определение

Блок транзакции: db.Transaction(func(tx *gorm.DB) error { ... }). Вернули ошибку — ROLLBACK, nil — COMMIT; паника откатывает, но не гасится. Под капотом: Begin(opts...), deferred-rollback при panicked || err != nil, наружу — err из fc или из Commit. Тело на свежем Session, чтобы Statement не протекал между уровнями.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Одиночные Create, Update и Delete GORM по умолчанию едет в авто-транзакции: gorm:begin_transaction — первый колбэк конвейера записи, gorm:commit_or_rollback_transaction — последний; у чтения транзакционных колбэков нет. SkipDefaultTransaction снимает обёртку (около 30%+ выигрыша по доке) — но вместе с ней исчезает атомарность каскадов.
  • db.Transaction — блок: fc(tx), вернули ошибку — ROLLBACK, nil — COMMIT; паника тоже откатывает, но не гасится; наружу возвращается err из fc или из Commit — проверяйте её всегда.
  • Внутри блока используйте tx: у него ConnPool подменён на *sql.Tx, а операции через db уходят в autocommit мимо транзакции и переживают откат.
  • Вложенный db.Transaction внутри транзакции автоматически превращается в SAVEPOINT с именем sp<число> от maphash и RollbackTo при ошибке; ошибку внутреннего блока можно обработать и продолжить; DisableNestedTransaction отключает savepoint-ветку.
  • tx.SavePoint и tx.RollbackTo — ручные точки отката внутри открытой транзакции; через SavePointerDialectorInterface драйвера; без поддержки — ErrUnsupportedDriver; с PrepareStmt GORM временно снимает prepared-кэш.
  • Ручной стиль: db.Begin(), проверка tx.Error, defer с recover и Rollback, финальный tx.Commit().Error; Commit или Rollback без транзакции — ErrInvalidTransaction; DefaultTransactionTimeout ставит дедлайн контексту.
  • Уровень изоляции — *sql.TxOptions в Transaction или Begin; clause.Isolation в GORM нет. MySQL-драйвер поддерживает четыре классических уровня через SET TRANSACTION ISOLATION LEVEL, дефолты: PostgreSQL READ COMMITTED, MySQL REPEATABLE READ.

Домашнее задание

Разверните канон локально и пройдите транзакции по кругу. Шаг первый: без блока — воспроизведите полуфабрикат: db.Model(&task).Update("todo_list_id", 2) на задачу из «Дом», затем db.Create(&Comment{Body: "Перенесено"}) с нулевым TodoItemID; по логгеру найдите BEGIN и COMMIT у каждого шага и покажите, что задача переехала, а комментарий упал отдельно. Шаг второй: соберите тот же маршрут через db.Transaction(func(tx *gorm.DB) error { ... }) — Update задачи, затем комментарий; сломайте второй шаг тем же нулевым ключом и сверйте след: BEGIN, UPDATE, INSERT, ROLLBACK — и задача осталась в «Дом». Шаг третий: почините комментарий и проверьте счастливый путь: BEGIN, UPDATE, INSERT, COMMIT; убедитесь, что err из db.Transaction равен nil. Шаг четвёртый: добавьте внутренний блок tx.Transaction вокруг комментария, сломайте только его и обработайте ошибку снаружи: в следе должен появиться SAVEPOINT sp... и ROLLBACK TO SAVEPOINT, а внешний COMMIT — пройти; затем включите DisableNestedTransaction: true в конфиге и покажите, что теперь та же внутренняя ошибка, проброшенная наружу, откатывает весь маршрут. Шаг пятый: savepoints руками — tx := db.Begin(); Create A; tx.SavePoint("sp1"); Create B; tx.RollbackTo("sp1"); Create C; tx.Commit(); посчитайте записи в таблице: A и C. Шаг шестой: ручной стиль — перепишите MoveTask как функцию с db.Begin, проверкой tx.Error, defer с recover и tx.Commit().Error; сравните с версией на замыкании и объясните в комментарии, какой стиль вам читабельнее и почему. Шаг седьмой: уровни изоляции — выполните блок с &sql.TxOptions{Isolation: sql.LevelSerializable} на PostgreSQL и найдите уровень в следе драйвера; на MySQL попробуйте LevelWriteCommitted и получите mysql: unsupported isolation level; затем через SkipDefaultTransaction: true замерьте время пачки одиночных Create до и после — оцените, где те самые 30%+ и что потеряли взамен.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Транзакции — db.Transaction, вложенность, SavePoint и уровни изоляции в GORM)

Вопросы и ответы по уроку

Чем tx отличается от db внутри db.Transaction и что будет, если перепутать?

tx — это *gorm.DB, у которого ConnPool подменён на соединение открытой транзакции (*sql.Tx); db — прежний пул с autocommit. Операция через db внутри блока выполнится сразу и закоммичена независимо от исхода блока: откат её не тронет. Поэтому доковское правило гласит: use 'tx' from this point, not 'db' — все операции тела блока должны ехать через tx.

Что происходит при вложенном db.Transaction и можно ли отключить savepoint?

GORM замечает вложенность по ConnPool: если он уже реализует TxCommitter, внутренний блок исполняется как savepoint-транзакция — SAVEPOINT с именем sp и случайным числом (maphash), ROLLBACK TO SAVEPOINT при ошибке или панике внутреннего блока. Ошибка возвращается из tx.Transaction как обычный error — можно обработать и продолжить либо пробросить, откатив всё. Конфиг DisableNestedTransaction: true убирает savepoint: внутреннее тело едет на той же транзакции, и его ошибка откатывает весь внешний блок.

Как задать уровень изоляции и почему в GORM нет clause.Isolation?

Изоляция задаётся стандартными опциями database/sql: db.Transaction(fc, &sql.TxOptions{Isolation: sql.LevelSerializable}) или db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead}); clause.Isolation в GORM не существует — слово Isolation в ядре не встречается. Драйвер сам переводит константу в SQL: go-sql-driver/mysql делает SET TRANSACTION ISOLATION LEVEL перед BEGIN и знает четыре классических уровня (остальные — unsupported), драйверы PostgreSQL встраивают уровень в BEGIN. Дефолты без опций: PG — READ COMMITTED, MySQL InnoDB — REPEATABLE READ.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx