Содержание урока · 13 разделов
- gorm транзакции: полуфабрикат вместо атомарного маршрута
- Авто-транзакция изнутри: gorm:begin_transaction и SkipDefaultTransaction
- db.Transaction: блок, где ошибка = rollback
- Вложенные Transaction: SavePoint ставится сам
- SavePoint и RollbackTo вручную
- Ручное управление: Begin, Commit, Rollback и recover
- Уровни изоляции: sql.TxOptions вместо clause.Isolation
- Шпаргалка: уровни изоляции и аномалии
- Чек-лист урока
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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
GOvar 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 не ставится, внутреннее тело едет на той же транзакции, и его ошибка, выйдя наружу, откатывает уже весь внешний блок.
Вложенный блок: откат подмножества операций
GOerr := 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 по доке
GOtx := 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
GOfunc 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.
Соберите жизненный цикл db.Transaction при успехе
Расставьте шаги от вызова db.Transaction до возврата наружу — счастливый путь без ошибок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: db вместо tx внутри блока — операция уезжает мимо транзакции в autocommit и переживает откат; доковское use 'tx' from this point — не рекомендация, а требование. Вторая: долгие внешние вызовы (HTTP, ожидания) внутри блока держат соединение пула занятым — под нагрузкой пул истощается, а DefaultTransactionTimeout может оборвать транзакцию по дедлайну; блок держите коротким. Третья: Begin без Commit (забытый возврат, ранний выход) оставляет висящую транзакцию, а Commit или Rollback на db без транзакции даёт ErrInvalidTransaction — он в Error, не в панике. Четвёртая: SkipDefaultTransaction глобально снимает атомарность и у каскадов — батч-вставка из пятого урока перестаёт быть всё-или-ничего; отключайте точечно через Session и измеряйте. Пятая: изоляция — не территория GORM: mysql: unsupported isolation level для LevelWriteCommitted и LevelLinearizable, в PostgreSQL READ UNCOMMITTED ведёт себя как READ COMMITTED, а SavePoint без поддержки диалектом — ErrUnsupportedDriver.
Чек-лист урока#
- авто-транзакция едет у одиночных 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
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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%+ и что потеряли взамен.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx