Все (я надеюсь) способы работать с транзакциями в go

Так уж вышло, что я до сих пор не могу подобрать лучший способ для работы с транзакциями. Поэтому я решил написать эту обзорную статью. Я разберу все известные мне паттерны для работы с транзакциями и постараюсь выбрать лучший.
Вводные
Рассмотрим самый дефолтный проект с любой реляционной БД. У проекта есть две сущности - пользователи и заказы. Каждая сущность хранится в своей таблице. Мы пытаемся (насколько это возможно) не мешать слой БД со слоем бизнес-логики и стараемся писать поддерживаемый и расширяемый код.
В примерах кода специально упрощены сигнатуры функций, чтобы не переусложнять.
Столкнувшись с необходимостью работать с транзакциями, нам придётся выбрать один из этих вариантов:
1. Используем объект транзакции в слое бизнес-логики
Рассмотрим ситуацию, когда нам надо одновременно сохранить в БД и юзера, и заказ. Тогда можно написать примерно такой код:
package handler
import (
"database/sql"
"model"
"context"
)
type UsersRepository {
Save(ctx context.Context, user model.User, tx *sql.Tx)
}
type OrdersRepository {
Save(ctx context.Context, order model.Order, tx *sql.Tx)
}
type CreateHandler struct {
usersDb UsersRepository
ordersDb OrdersRepository
db *sql.Db
}
func (h *CreateHandler) Create(user model.User, order model.Order) error {
tx := h.db.Begin()
defer tx.Rollback()
h.usersDb.Save(user, tx)
h.ordersDb.Save(order, tx)
return tx.Commit()
}Плюс у такого подхода ровно один: это простой код, который поймёт разработчик любого уровня.
Но минусов намного больше:
Что делать, если мы в другом хендлере захотим переиспользовать метод
Saveиз репозитория без транзакции?В слой бизнес-логики протекла логика работы с БД
Если мы захотим переехать с
database/sqlна другую библиотеку для работы с БД, скорее всего придётся править много кода в слое бизнес-логикиВелика вероятность ошибки, так как разработчик может забыть написать
tx.Commitилиtx.RollbackПри появлении вложенных транзакций или необходимости указывать явно уровень изоляции код становится сильно менее читаемым
На самом деле примерно такой код я чаще всего и вижу в реальных проектах. И подозреваю, что в комментариях будет много защитников такого подхода, так как в сообществе go очень любят простоту.
2. Менеджер транзакций
Предыдущий способ, видимо, не понравился не только мне, поэтому разрабы из авито сделали свой менеджер транзакций. Он хранит транзакции в контексте и позволяет работать с ними примерно так:
package handler
import (
"model"
"context"
"github.com/avito-tech/go-transaction-manager/trm/v2/manager"
)
type UsersRepository {
Save(ctx context.Context, user model.User)
}
type OrdersRepository {
Save(ctx context.Context, order model.Order)
}
type CreateHandler struct {
usersDb UsersRepository
ordersDb OrdersRepository
txManager manager.Manager
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
// метод Do создаёт транзакцию и сохраняет её в контекст
// если колбэк возвращает ошибку, транзакция откатывается
return h.txManager.Do(ctx, func(ctx context.Context) error {
h.usersDb.Save(ctx, user)
h.ordersDb.Save(ctx, order)
return nil
})
}Вообще этот подход мне критиковать сложнее всего, хоть он и не очень мне нравится. Код всё ещё достаточно простой и читабельный, про откат транзакции думать не надо, можно переиспользовать методы репозитория внутри и вне транзакций. Но этот подход с хранением транзакции в контексте лично меня слишком отталкивает. В документации go по этому поводу сказано следующее:
Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
Формально транзакцию можно рассматривать как request-scoped data, но имхо это притягивание за уши.
Фактически разработчики просто заметили, что контекст, который всегда передаётся в любой метод репозитория, случайно имеет удобные методы для хранения в нём транзакции. Хотя изначально они использовали его для других целей.
3. Агрегаты
Мы все привыкли, что для каждой сущности создаётся свой репозиторий. Для клиентов создаётся UsersRepository, для заказов - OrdersRepository и т. д. Но вообще это не какое-то золотое правило, которое нельзя нарушать. Некоторые справедливо заметят, что репозитории надо делить не по таблицам в БД, а по бизнес-сущностям.
Если какой-то процесс подразумевает транзакционное создание и заказа, и юзера, то почему мы должны делить эту операцию на две? Мы можем выделить агрегат, состоящий из заказа и пользователя, и наш код будет выглядеть вот так:
package handler
import (
"model"
"context"
)
type UsersRepository {
Save(ctx context.Context, user model.UserWithOrder)
}
type CreateHandler struct {
usersDb UsersRepository
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
userWithOrder := model.NewUserWithOrder(user, order)
h.usersDb.Save(userWithOrder)
return nil
}Мне очень нравится такой подход. Я всегда стараюсь следовать именно ему, но тут тоже есть свои нюансы. Иногда всё же возможна ситуация, что в другом хендлере нам потребуется создать заказ отдельно от пользователя. И тогда нам придётся либо как-то шарить логику создания заказа между двумя репозиториями, либо делать новый слой, который знает про репозитории, но не знает про бизнес-логику. Выглядеть он будет примерно так:
package storage
import (
"database/sql"
"model"
"context"
)
type UsersRepository {
Save(ctx context.Context, user model.User, tx *sql.Tx)
}
type OrdersRepository {
Save(ctx context.Context, order model.Order, tx *sql.Tx)
}
type Storage struct {}
// из хендлеров будет вызываться этот метод
// репозитории в нём больше не используются
func (s *Storage) Create(ctx context.Context, user model.UserWithOrder) error {
tx := h.db.Begin()
defer tx.Rollback()
h.usersDb.Save(user.User, tx)
h.ordersDb.Save(user.Order, tx)
return tx.Commit()
}4. Один репозиторий на всё
Сразу оговорюсь, что это экстремальный метод, который у многих вызовет отторжение. Но у него хватает своих плюсов, поэтому не могу не сказать о нём. Идея довольно простая: мы сделаем один репозиторий на все сущности проекта и добавим удобный метод для транзакций:
package handler
import (
"model"
"context"
)
type Repository interface{
SaveUser(ctx context.Context, user model.User)
SaveOrder(ctx context.Context, order model.Order)
InTx(ctx context.Context, f func(ctx context.Context, tx Repository) error)
}
type CreateHandler struct {
db Repository
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
return h.db.InTx(ctx, func(ctx context.Context, tx Repository) error {
tx.SaveUser(ctx, user)
tx.SaveOrder(ctx, order)
return nil
})
}Да, многим такое не зайдёт. Наверняка у многих в голове сразу возникнут мысли про god object, big ball of mud (большой комок грязи), нарушение SOLID и пр. Но мне кажется, что такой подход не так уж и плох. Я легко пойму разработчиков, которые выберут именно этот вариант в своих проектах.
Кстати, подобный подход реализован в библиотеке ent, которая косит под entity framework.
5. CQRS (ver. 1)
Можно чуть усложнить предыдущий вариант и отказаться от паттерна репозиторий. Вместо этого мы разделим все наши запросы к БД на команды (command) и запросы (query), которые сможет обрабатывать один executor:
package cqrs
import (
"model"
"context"
"command"
"database/sql"
)
type Command interface {
Handle(ctx context.Context, conn *sql.Conn) error
}
type Query[T any] interface {
Handle(ctx context.Context, conn *sql.Conn) (T, error)
}
type Executor interface {
RunCommand(ctx context.Context, command Command) error
RunQuery(ctx context.Context, query Query[any]) (any, error)
InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error
}И тогда итоговый код будет выглядеть примерно так:
package handler
import (
"model"
"context"
"command"
)
type Executor interface{
RunCommand(ctx context.Context, command Command) error
InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error
}
type CreateHandler struct {
db Executor
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error {
tx.RunCommand(ctx, command.SaveUser{User: user})
tx.RunCommand(ctx, command.SaveOrder{Order: order}))
return nil
})
}Теперь у нас нет одного гигантского репозитория, где хранятся все методы для работы со всеми сущностями. Но наружу торчат методы Handle, где в сигнатуре наружу торчит тип sql.Tx. Да, формально никто не будет его вызывать напрямую, но чувство прекрасного всё равно задето.
В C# для таких вещей используют MediatR. В go не нашёл популярных аналогов. Что-то похожее делает sqlc. Имхо это одна из самых толковых либ для работы с БД, но многих пугает кодогенерация как явление.
6. CQRS (ver. 2)
Если в предыдущем варианте совсем не нравится метод Handle, то можно рассмотреть такой вариант:
package handler
import (
"model"
"context"
"command"
)
type Executor interface {
RunCommand(ctx context.Context, command any) error
InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error
}
type CreateHandler struct {
db Executor
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error {
tx.RunCommand(ctx, command.SaveUser{User: user})
tx.RunCommand(ctx, command.SaveOrder{Order: order}))
return nil
})
}Теперь у команды нет метода Handle с торчащим наружу типом sql.Tx. Вместо этого для каждой команды создаётся отдельный хендлер:
package cqrs
import (
"context"
"command"
)
type SaveUserHandler struct {}
func (h *SaveUserHandler) Handle(ctx context.Context, query command.SaveUser) error {
// тут логика сохранения юзера в БД
}Проблема начинается на этапе связывания команд и их хендлеров. Приходится либо подключать рефлексию, либо использовать кодогенерацию, либо вручную при сборе зависимостей указывать какой хендлер должен вызываться для каждой комманды. Ну и использование any в коде хочется избегать примерно всегда.
Из всех подходов этот мне, наверное, нравится меньше всех остальных.
7. UOW (unit of work)
Честно скажу, что я этим паттерном никогда не пользовался ни в go, ни в C#. В книгах про паттерны я тоже его не встречал (либо просто забыл об этом). Поэтому здесь я полагаюсь на чатгпт и мнение сообщества, которое почему-то не сходится в одном мнении.
На хабре в комментариях пользователь hello_my_name_is_dany пишет, что UoW придумали для шаринга одного коннекта между несколькими репозиториями, а удобство работы с транзакциями - это случайный приятный бонус.
Фаулер на своём сайте пишет следующее:
A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, it figures out everything that needs to be done to alter the database as a result of your work.
Имхо звучит немного расплывчато. Да и про транзакции вообще ничего не сказано. В любом случае не хочу превращать эту статью в ресёрч паттерна UoW, поэтому предлагаю реализацию, которая чаще всего приводится в других статьях на эту тему:
package handler
import (
"context"
"model"
)
type UsersRepository {
Save(ctx context.Context, user model.User)
}
type OrdersRepository {
Save(ctx context.Context, order model.Order)
}
type UnitOfWork interface {
func (u *UnitOfWork) GetUsersRepository() UsersRepository
func (u *UnitOfWork) GetOrdersRepository() OrdersRepository
func (u *UnitOfWork) Transaction(fn func() error)
}
type CreateHandler struct {
usersDb UsersRepository
ordersDb OrdersRepository
uow UnitOfWork
}
func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error {
return h.uow.Transaction(func() {
h.uow.GetUsersRepository().Save(ctx, user)
h.uow.GetOrdersRepository().Save(ctx, order)
return nil
})
}Я не буду прикладывать конкретную реализацию интерфейса UnitOfWork, но чаще всего там приходится хранить все репозитории в одном месте примерно вот так:
package uow
type UoW struct {
users UsersRepository // конкретная реализация репозитория, а не просто интерфейс
orders OrdersRepository // конкретная реализация репозитория, а не просто интерфейс
db *slx.Db
}Конкретный пример реализации можно посмотреть всё в том же комментарии от hello_my_name_is_dany.
По сути мы просто взяли подход номер 4, но вместо создания одного гигантского репозитория мы сделали адаптер UoW, который хранит в себе все репозитории. Минусы по сути такие же: god object, big ball of mud (большой комок грязи), нарушение SOLID и пр.
8. Callback
Предлагаю немного другую задачу. Теперь в хендлере нам надо запросить какого-то пользователя, залочить его, а потом деактивировать его, если он не совершил ни одной покупки. Тогда можно написать примерно такой код:
package handler
import (
"context"
"model"
)
type UsersRepository interface {
SelectForUpdate(ctx context.Context, userID int, func(u *model.User)) error
}
type UpdateHandler struct {
users UsersRepository
}
func (h *CreateHandler) Create(ctx context.Context, userID int) error {
return h.users.SelectForUpdate(ctx, userID, func(u *model.User) {
if u.OrdersCount == 0 { // да, поле с количеством заказов лежит в таблице users, так уж вышло
u.Active = false
}
})
}Метод SelectForUpdate сделает следующее:
выполнит
SELECT FOR UPDATEдля поиска юзеравызовет колбэк из нашего хендлера
сравнит, изменился ли наш юзер, и вызовет апдейт
Здесь хорошо почти всё, но на практике такой подход наиболее хрупкий из всех.
По мере добавления новых полей в model.User надо держать в голове, что где-то есть репозиторий с методом для обновления юзера, где надо эти изменения поддержать. Да, можно использовать кодогенерацию и даже навесить её на хук перед коммитом, но это ощущается как слишком большой оверхед.
К тому же этот подход плохо работает с большими агрегатами. Допустим, мы хотим запросить вот такой объект:
type UserWithOrder struct {
User User
Orders []Order
}Надо ли при этом лочить заказы? Если да, то до или после селекта юзера? А если нам в каких-то случаях надо изменить порядок локов? Ну и такой подход банально не работает для случая, когда нам надо создать какую-то другую сущность в этой же транзакции.
У меня был опыт работы в команде, где все микросервисы были настолько маленькие, что у каждого была ровно одна таблица. Там такой подход идеально бы зашёл. Но увы, это слишком узкий кейс.
Заключение
Наверняка какие-то подходы я пропустил, но все наиболее распространённые я вроде бы рассмотрел. Честно говоря, всё ещё не знаю, какой из них лучше других. Мне одновременно хочется читаемый и удобный вариант без протекания слоёв, но не похоже, что это возможно.
В статьях часто просят обратную связь ради трафика и подписчиков. Мне на это всё равно, но вот мнение сообщества мне действительно интересно. Буду рад прочитать ваши примеры, подходы и библиотеки для работы с транзакциями в go.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.