Содержание урока · 12 разделов
- Зачем транзакции, когда ent уже «сам всё делает»
- Контракт Commit и Rollback
- WithTx: хелпер, который делит код на «внутри» и «снаружи»
- tx.Client() и BeginTx: интеграция с чужим кодом
- Вложенные транзакции: tx.Tx и savepoints
- Ошибки внутри транзакции: ConstraintError становится штатной
- Чеклист транзакций
- Карта случаев: что и как оборачивать
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Маршрут урока — от первого client.Tx до вложенных savepoints. Сначала — зачем это нужно: сцена с постом и счётчиком из тринадцатого урока, где две отдельные мутации обязаны жить или умереть вместе; здесь же — что транзакция даёт и чего не обещает. Затем — механика: client.Tx(ctx), сгенерированный файл ent/tx.go и контракт Commit/Rollback, включая sql.ErrTxDone. Третья часть — дисциплина: идиома defer и хелпер WithTx, стандарт де-факто из документации ent. Четвёртая — интеграция: tx.Client() для функций, чьи подписи принимают *ent.Client, With-цепочки из одиннадцатого урока внутри транзакции и BeginTx с sql.TxOptions. Пятая — вложенные транзакции: tx.Tx(ctx) и savepoints, где подоперация может откатиться, не роняя внешнюю. Финиш — ошибки: ConstraintError из четырнадцатого урока становится штатной веткой, а «полуудалённое состояние» исчезает из вашего словаря. Если вы искали по запросу «ent tx» — всё это здесь.
Зачем транзакции, когда ent уже «сам всё делает»#
Восьмой урок заметил: мутация с рёбрами уже открывает транзакцию сама — внутренний mayTx оборачивает INSERT узла и вставки в join-таблицу одним блоком. Но эта автоматика покрывает ровно одну мутацию. Две отдельные мутации — пост и инкремент счётчика, удаление дружб и удаление пользователя, создание группы и вступление в неё — в транзакции не живут: между их Exec природа может вставить что угодно, от падения процесса до сетевого сбоя. Итог известен из четырнадцатого урока: пост есть, счётчик не сдвинулся; дружбы стёрты, пользователь жив — полуудалённое состояние, которое не чинится ретраем, потому что часть операций уже необратима. Транзакция на уровне SQL — это BEGIN, набор стейтментов и либо COMMIT, либо ROLLBACK: атомарность, консистентность и изоляция на стороне базы. ent транзакции не переизобретают это — они дают типобезопасный доступ: сгенерированный tx-клиент, те же билдеры, компилятор следит за тем, чтобы вы не позвали клиентские билдеры там, где открыта транзакция, — ну, почти следит, и о дыре в этом заборе — в конце урока.
client.Tx: транзакционный клиент
GO// пост + счётчик — атомарно:
tx, err := client.Tx(ctx)
if err != nil {
return err
}
if _, err = tx.Post.Create().
SetTitle("про рёбра").
SetBody("...").
SetAuthorID(1).
Save(ctx); err != nil {
return err // до Commit база не увидит ничего
}
if err = tx.User.UpdateOneID(1).
AddPostsCount(1).
Exec(ctx); err != nil {
return err
}
return tx.Commit()client.Tx(ctx) возвращает (*ent.Tx, error) — из сгенерированного ent/tx.go. У Tx те же поля: tx.Post, tx.User — и те же билдеры, что у client, но драйвер у них один: соединение, открытое для этой транзакции. AddPostsCount — тот же мутатор из тринадцатого урока; разница в окружении: обе мутации теперь либо зафиксируются вместе на Commit, либо исчезнут вместе при откате.
Проверка: что такое *ent.Tx
Что возвращает client.Tx(ctx) и чем tx.Post отличается от client.Post?
Контракт Commit и Rollback#
Два метода *ent.Tx решают судьбу транзакции, и у обоих контракты стоит знать наизусть. tx.Commit() возвращает error: успех — nil, и все мутации зафиксированы; ошибка может прийти и здесь — констрейнты с отложенной проверкой, обрыв соединения, конфликт изоляции. tx.Rollback() тоже возвращает error — и это главное источник путаницы: rollback после успешного Commit вернёт ошибку уже завершённой транзакции, из database/sql она приходит как sql.ErrTxDone. Это не баг и не паника: вы просите отменить то, чего уже нет. Обратный порядок безопаснее по данным: Rollback после неудачного Commit может вытащить соединение из комы, если часть стейтментов исполнилась, а фиксировать нечего. Ещё одно свойство, о котором забывают: транзакция держит соединение из пула. Забытый Tx без Commit и без Rollback — соединение, которое не вернётся в пул до таймаута; три таких утечки — и приложение стоит, хотя процесс здоров. Отсюда железное правило: у каждой транзакции либо Commit, либо Rollback обязаны быть исполнены — на любом пути, включая панику.
Идиома: defer tx.Rollback
GOtx, err := client.Tx(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// ... бизнес-код, любые return внутри ...
return tx.Commit()Классическая идиома: defer с Rollback страхует все пути. На успешном пути Commit уже зафиксировал транзакцию, и отложенный Rollback вернёт sql.ErrTxDone — её осознанно глотают подчёркиванием. На любом ошибочном или паническом пути rollback сработает сам и вернёт соединение в пул. Заметьте: Commit здесь последний return — defer исполняется после него, но не мешает: ErrTxDone безвредна.
Проверка: defer после Commit
Код: tx открыта, defer func() { _ = tx.Rollback() }(), бизнес-код прошёл, return tx.Commit() вернул nil. Что исполнилось и что вернулось?
WithTx: хелпер, который делит код на «внутри» и «снаружи»#
Идиома с defer требует дисциплины в каждом месте: ошибку бизнес-кода надо не потерять, rollback не забыть, панику не упустить. Документация ent предлагает шаблон, который закрывает это раз и навсегда — хелпер WithTx. Он принимает клиент и функцию fn func(tx *ent.Tx) error; всё, что fn делает, живёт внутри транзакции, всё, что после её возврата, — снаружи. Возврат nil — Commit, возврат ошибки — Rollback с сохранением оригинальной ошибки: заметьте, как она оборачивается, если откат сам споткнулся, — ошибка мутации для вас ценнее ошибки отката, и терять её нельзя. Тот же принцип с паникой: recover поднимает её после отката — соединение вернётся в пул, а стектрейс останется честным. С таким хелпером ent транзакции становятся декларативными: читатель функции сразу видит границы атомарного блока, а не ищет парные Commit/Rollback по коду.
WithTx: стандартный хелпер
GOfunc WithTx(ctx context.Context, client *ent.Client,
fn func(tx *ent.Tx) error) error {
tx, err := client.Tx(ctx)
if err != nil {
return err
}
defer func() {
if v := recover(); v != nil {
_ = tx.Rollback()
panic(v) // откатились — и подняли честно
}
}()
if err := fn(tx); err != nil {
if rerr := tx.Rollback(); rerr != nil {
// ошибка мутации важнее ошибки отката:
err = fmt.Errorf("откат: %v: %w", rerr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}Три ветки — успех (Commit), ошибка (Rollback + оригинальная ошибка наружу), паника (Rollback + repanic). Хелпер живёт в одном файле проекта и обслуживает все транзакции приложения. Это адаптация примера из документации ent: если в вашей версии исходники отличаются мелочами — порядок идей тот же.
Что в базе после падения внутри WithTx
Прочитайте код и выберите, что он выведет:
err := WithTx(ctx, client, func(tx *ent.Tx) error {
if _, err := tx.Post.Create().
SetTitle("черновик").
SetAuthorID(1).
Save(ctx); err != nil {
return err
}
return errors.New("feature flag выключен: выходим")
})
// анна (id 1) имела posts_count = 2. Что в базе после возврата?tx.Client() и BeginTx: интеграция с чужим кодом#
Первый вопрос интеграции: что делать с функциями, чьи подписи принимают ent.Client — сервисный слой из восьмого урока, репозитории, хелперы. Переписывать всё на ent.Tx — долго и заразно. Ответ — tx.Client(): сгенерированный метод возвращает *ent.Client, чьи все билдеры привязаны к этой транзакции; передайте его в функцию — и её client.User.Query() исполнится внутри tx, а не на пуле. Внутри транзакции работают и With-цепочки из одиннадцатого урока: WithPosts, WithOwner — всё на том же соединении, консистентный снимок данных. Второй вопрос — изоляция. client.Tx(ctx) — сахар для BeginTx с нулевыми опциями; когда нужны особые уровни, вызывайте client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) — уровень согласован с диалектом базы: Postgres и MySQL поддерживают Repeatable Read, SQLite в режимах гонок ограничен. Третий — ReadOnly: для чисто читающих задач транзакция только с чтением освобождает базу от лишних блокировок. ent транзакции здесь не добавляют своей магии — они честно пробрасывают database/sql.
Транзакция внутри чужих функций и изоляция
GO// функция ждёт *ent.Client — не переписываем:
if err := publishToFeed(ctx, tx.Client(), post); err != nil {
return err // всё внутри publishToFeed — в этой же tx
}
// особая изоляция для отчёта по балансам:
tx, err := client.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})tx.Client() — мост между tx-миром и клиентскими подписями: один вызов, и чужой код работает в вашей транзакции, не зная об этом. BeginTx с sql.TxOptions — единственный способ задать уровень изоляции и ReadOnly: у client.Tx таких параметров нет. Выбор уровня — тема базы, не ent: ent лишь гарантирует, что билдеры ничего не перепутают.
Вложенные транзакции: tx.Tx и savepoints#
Реальные операции состоят из обязательной и необязательной части: основное должно зафиксироваться любой ценой, а необязательное — пусть откатится отдельно, не роняя всё. SQL решает это savepoints: точка внутри транзакции, к которой можно откатиться частично. ent поддерживает вложенные транзакции через savepoints: у ent.Tx есть метод Tx(ctx) — он возвращает новую ent.Tx, чья Commit исполняет RELEASE SAVEPOINT, а Rollback — ROLLBACK TO SAVEPOINT. Внешняя транзакция продолжает жить: ошибка вложенного блока, если вы её обработали, не смертельна для основной. Это тот же контракт, что и у client.Tx, только точка отсчёта другая: не пул, а текущая транзакция. Поддержка диалектами хорошая: Postgres и MySQL/InnoDB умеют savepoints нативно, SQLite — тоже. Дисциплина остаётся прежней: вложенная транзакция — тот же WithTx-паттерн, только корнем служит tx, а не client. Глубину не зарывайте: savepoint в savepoint — уже запутанный след в логе базы, и не каждая операция это заслуживает.
WithNestedTx: savepoint по-человечески
GOfunc WithNestedTx(ctx context.Context, rootTx *ent.Tx,
fn func(tx *ent.Tx) error) error {
tx, err := rootTx.Tx(ctx) // savepoint внутри внешней
if err != nil {
return err
}
if err := fn(tx); err != nil {
if rerr := tx.Rollback(); rerr != nil {
err = fmt.Errorf("откат вложенной: %v: %w", rerr, err)
}
return err // внешняя решает, что с этим делать
}
return tx.Commit() // RELEASE SAVEPOINT
}
// использование: основное — обязательно, аватар — пусть откатится:
return WithTx(ctx, client, func(tx *ent.Tx) error {
if _, err := tx.Group.Create().SetName("go-ent").Save(ctx); err != nil {
return err
}
err := WithNestedTx(ctx, tx, func(ntx *ent.Tx) error {
return makeAvatar(ctx, ntx.Client()) // может упасть
})
if err != nil {
log.Printf("аватар не удался, группа жива: %v", err)
}
return nil
})rootTx.Tx(ctx) открывает savepoint; Commit вложенной — RELEASE, Rollback — ROLLBACK TO. Ошибка makeAvatar проглочена логом: группа go-ent создана и зафиксируется, аватар — нет. Это точный ответ на «необязательная часть не должна ронять обязательную» без второго соединения и без переписывания бизнес-кода.
Проверка: судьба внешней транзакции
Внешняя транзакция создала группу. Вложенный savepoint упал, ошибку перехватили логом. Что окажется в базе после внешнего Commit?
Ошибки внутри транзакции: ConstraintError становится штатной#
Четырнадцатый урок кончался обещанием: пара «дети, потом родитель» обязана быть атомарной. Теперь это один блок: Delete по дружбам и DeleteOneID пользователя — внутри WithTx; ConstraintError, если что-то ссылается, останавливает блок до второго DELETE, и Rollback стирает след первого. Полуудалённого состояния больше не существует — в логе либо оба DELETE и COMMIT, либо первый DELETE и ROLLBACK. То же с ent.IsConstraintError и ent.IsNotFound: они ловятся как обычно, но теперь означают «откат + решение», а не «частичный успех». Отдельная честность: ошибки базы в транзакции бывают неконтролируемыми — после грубого нарушения (deadlock, серийный конфликт) база может сама откатить транзакцию, и дальнейшие стейтменты бесполезны; в этом случае ретрай — это новый WithTx с самого начала, а не продолжение старого. И ещё одна дыра забора, обещанная в начале: билдеры client.Post всё ещё доступны внутри WithTx — компилятор не запретит вам по ошибке вызвать клиентский билдер и уехать мимо транзакции на пул. Дисциплина простая: внутри fn(tx) используйте только tx.*, а ловлю мимо-транзакционных вызовов отдайте код-ревью и статическому анализу.
«Дети, потом родитель» — атомарно
GOerr := WithTx(ctx, client, func(tx *ent.Tx) error {
// дети: дружбы через through-таблицу из шестого урока
if _, err := tx.Friendship.Delete().
Where(friendship.HasUserWith(user.ID(1))).
Exec(ctx); err != nil {
return err
}
// родитель: если FK откажет — откатится всё
return tx.User.DeleteOneID(1).Exec(ctx)
})
if ent.IsConstraintError(err) {
// кто-то ещё держит строку: карта рёбер из пятого урока
}Тот же порядок, что в четырнадцатом уроке, но теперь разрыв невозможен: ConstraintError на втором шаге откатывает первый. ent.IsConstraintError ловится снаружи — это сигнал карты зависимостей, а не 500. Внутри fn(tx) только tx-билдеры: Friendship и User из транзакционного клиента.
Чеклист транзакций#
- Граница транзакции — граница бизнес-операции, не HTTP-запроса: держать tx открытым на время походов в сеть и ответов пользователю — способ связать весь пул в узел.
- WithTx в проекте один, его шаблон не переизобретают на месте: успех — Commit, ошибка — Rollback с оригинальной ошибкой, паника — Rollback + repanic.
- defer tx.Rollback — страховка каждого ручного client.Tx без WithTx: sql.ErrTxDone после успешного Commit — норма, а не баг.
- Внутри fn(tx) — только tx.*: tx.Client() для чужих подписей, client.Post внутри транзакции — способ тихо уехать мимо неё.
- Счётчики и инкременты — Add-мутаторами из тринадцатого урока и в той же транзакции, что и сама мутация: posts_count обязан догонять посты атомарно.
- ConstraintError и NotFoundError — штатные ветки: откат, лог карты зависимостей, решение; ретрай — новый WithTx с нуля, а не продолжение мёртвой транзакции.
- Внешние сайд-эффекты — после Commit: письмо и очередь, отправленные внутри транзакции, уйдут зря при откате; паттерн outbox-таблицы, записанной в той же tx, разберём в семнадцатом.
Карта случаев: что и как оборачивать#
| Сценарий | Инструмент | Что атомарно |
|---|---|---|
| Пост + инкремент posts_count | WithTx: tx.Post.Create + AddPostsCount | обе мутации вместе |
| Дети, потом родитель (14-й урок) | WithTx: два Delete в fn | весь каскад или ничего |
| Мутация с рёбрами (AddGroupIDs) | обычный Save — ent сам открывает mayTx | вставка узла + join-строки |
| Чужая функция с *ent.Client | tx.Client() как аргумент | всё, что она делает |
| Необязательная подоперация | WithNestedTx: tx.Tx(ctx) → savepoint | вложенный блок отдельно |
| Особый уровень изоляции | client.BeginTx + sql.TxOptions | вся транзакция по правилам уровня |
| Ручной client.Tx без хелпера | defer tx.Rollback + явный Commit | любой путь выхода |
| Массовый upsert из восьмого | CreateBulk + OnConflict внутри WithTx | весь каталог целиком |
| Паника в бизнес-коде | recover в WithTx: Rollback + repanic | соединение вернётся в пул |
| Аудит-лог той же судьбой | хук с m.Tx() — семнадцатый урок | мутация + запись аудита |
Runner-ов в курсе ent нет, и песочница не появится и здесь: транзакциям нужны настоящий драйвер и база — правило всего курса. Возьмите базу из домашек восьмого урока, включите лог SQL и запустите WithTx из этого урока: в логе увидите BEGIN, стейтменты, COMMIT. Затем верните errors.New из середины fn и увидите ROLLBACK — и ноль изменений в таблицах. Вложенную транзакцию проверьте по логу на Postgres или MySQL: SAVEPOINT, падение, ROLLBACK TO SAVEPOINT, внешние INSERT и COMMIT. Финальный тест — параллельность: два WithTx одновременно создают пост и двигают posts_count; после сотни итераций счётчик обязан совпасть с числом постов.
Соберите каркас транзакции
Впишите открытие транзакции, страховку отката и фиксацию.
Жизнь одной транзакции
Соберите по порядку всё, что происходит с WithTx от вызова до возврата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Вы умеете строить атомарные операции: client.Tx и BeginTx с sql.TxOptions, контракт Commit и Rollback с sql.ErrTxDone, идиому defer и хелпер WithTx, tx.Client() для чужого кода, вложенные транзакции через savepoints — и ошибки, которые больше не оставляют полуудалённых состояний. Транзакция — это контейнер; семнадцатый урок покажет, кто исполняется внутри него рядом с вашими мутациями: хуки перехватывают каждую Create/Update/Delete до записи, могут валидировать, переписывать поля и писать аудит-лог в ту же транзакцию — счётчик posts_count наконец начнёт двигаться сам. Домашнее задание ниже заставит транзакцию провалиться красиво и доказать по логу, что база этого не заметила. Полный план из 25 уроков — на странице курса, счётчик — в тринадцатом уроке, жадная загрузка внутри tx — в одиннадцатом, источник пары «дети, потом родитель» — в четырнадцатом.
Карточки: транзакции
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ent транзакции начинаются с client.Tx(ctx) → (*ent.Tx, error): сгенерированный tx-клиент из ent/tx.go с теми же билдерами User/Post/Group, но на одном соединении; Commit и Rollback — единственные два исхода.
- Идиома defer tx.Rollback делает забытую транзакцию невозможной: после успешного Commit она вернёт sql.ErrTxDone — штатно и безвредно; на ошибке и панике — отменит изменения и вернёт соединение в пул.
- WithTx — стандартный хелпер: fn func(tx *ent.Tx) error, nil → Commit, ошибка → Rollback с сохранением оригинальной ошибки, паника → Rollback + repanic; границы атомарного блока видны в сигнатуре.
- tx.Client() возвращает *ent.Client, привязанный к транзакции, — чужой код с клиентскими подписями работает внутри tx; With-цепочки из одиннадцатого урока исполняются на том же соединении.
- Изоляция задаётся только через client.BeginTx(ctx, &sql.TxOptions{Isolation: ...}) — client.Tx параметров не принимает; ReadOnly и уровни изоляции — ответственность базы, ent честно пробрасывает database/sql.
- Вложенные транзакции — tx.Tx(ctx): savepoint, где Commit = RELEASE, Rollback = ROLLBACK TO; обработанная ошибка вложенного блока не роняет внешнюю транзакцию.
- Ошибка внутри tx отменяет всё: ConstraintError из четырнадцатого урока становится штатной веткой, полуудалённое состояние исчезает; после deadlock ретрай — новый WithTx с нуля.
- Дисциплина: внутри fn(tx) только tx.* (tx.Client() для чужих подписей), внешние сайд-эффекты после Commit, счётчики Add-мутаторами в той же транзакции.
Домашнее задание
Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja — с включённым логом SQL драйвера. Первое: соберите WithTx из урока и проведите через него пару «пост + AddPostsCount(1)»: найдите в логе BEGIN и COMMIT, затем верните ошибку из середины fn и найдите ROLLBACK; после каждого прогона проверяйте SELECT-ом, что posts_count и число постов согласованы. Второе: смоделируйте «полуудалённое состояние» без транзакции — отдельные Exec на удаление дружб и пользователя с падением посередине — и письменно опишите, что зашилось в базе; затем повторите то же внутри WithTx и убедитесь, что база вернулась к исходному. Третье: соберите WithNestedTx на tx.Tx(ctx): основная часть создаёт группу и добавляет участника, вложенная «рисует аватар» и падает — по логу найдите SAVEPOINT и ROLLBACK TO SAVEPOINT и докажите, что группа зафиксировалась. Четвёртое: откройте BeginTx с sql.TxOptions и попробуйте два уровня изоляции на гонке «две горутины двигают posts_count по сотне итераций» — сравните итоговые числа с ожидаемыми и опишите, что изменил уровень. Пятое: передайте tx.Client() в функцию с подписью *ent.Client и с помощью лога докажите, что её запросы исполнились внутри транзакции; затем по ошибке вызовите внутри fn клиентский билдер client.Post — зафиксируйте в комменте, какой след в логе выдаёт утечку мимо транзакции. Вопрос на подумать: почему сайд-эффект во внешнюю систему (письмо, HTTP-вызов) внутри WithTx — это баг, даже если мутации пройдут без ошибок, и как из него корректно выйти?
Личные заметкиПусто
Вопросы и ответы по уроку
client.Tx или client.BeginTx — что использовать?
По умолчанию client.Tx(ctx) — сахар над BeginTx с нулевыми опциями. BeginTx нужен, когда вы хотите задать sql.TxOptions: уровень изоляции (LevelRepeatableRead и другие) или ReadOnly. API билдеров у них одинаковый: оба возвращают *ent.Tx, и весь остальной урок применим без изменений.
Можно ли держать транзакцию открытым весь HTTP-запрос?
Технически — да, tx.Client() в начале запроса и defer Commit в конце. Практически — не надо: транзакция держит соединение из пула, и пара десятков параллельных запросов вычерпают пул, а любое ожидание внешней системы растянет транзакцию на секунды. Граница транзакции — граница бизнес-операции: короткий блок мутаций, снаружи — чтения и сайд-эффекты.
Почему Rollback после Commit не паникует и что делать с ошибкой?
Потому что запрос «отменить уже завершённую транзакцию» — штатная ситуация для идиомы defer: database/sql отвечает sql.ErrTxDone, и её осознанно глотают подчёркиванием. Паника была бы вредительством: страховка должна быть бесплатной. Если Rollback спотыкается на живой транзакции — это уже информация о сбое соединения, и WithTx оборачивает её вместе с оригинальной ошибкой мутации.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker