Comments 34
Теперь осталось красиво завернуть блокировки на уровне строк SELECT FOR UPDATE с обработкой сбоя сериализации.
С этим особых проблем нет, просто делаю в репо метод вида GetCarWithLock(ctx context.Context, id string) (*model.Car, error)
, внутри которого SELECT FOR UPDATE.
Сбоев сериализации пока не было, но при необходимости можно добавить в WithinTransaction функциональных опций с передачей уровня изоляции, например.
Тут как раз имеет смысл через замыкания иначе как будешь делать ретрай?
Никак не буду делать ретрай, при локе первая транзакция проставит лок, а вторая будет ждать синхронно, пока лок будет отпущен.
Синхронных операций по 20 минут в одной транзакции принципиально нет, все отрабатывает за 20мс в худшем случае, поэтому необходимости в каких-то ретраях нету.
вторая будет ждать синхронно, пока лок будет отпущен.
Да же если возмем уровень Serializable:
Однако, как и на уровне Repeatable Read, на этом уровне приложения должны быть готовы повторять транзакции из-за сбоев сериализации.
Насколько помню, локи отрабатывают для всех уровней изоляции одинаково хорошо (если речь не про фантомы, конечно, но зачем нам локи на фантомы?).
Касательно того, что называют здесь "сбоями сериализации" - видимо имеется в виду Lost updates и Non-repeatable reads (ну и фантомы), но этого можно избежать, просто грамотно продумывая запросы, а не бездумно полагаясь на повышение уровня сериализации.
Пока у меня не было проблем с этим, т.к. пишу неконфликтные запросы, а где конфликты или конкуренция может быть, решаю локами или другими механизмами неконкурентной обработки. Поэтому дефолтного в Postgres Read Committed мне вполне хватает без мыслей о ретраях.
Впрочем, конкретный сервис построен так, что ретраи операции возможны при ошибках, но не на уровне транзакции, а на уровне обработки запроса - запрос или прийдет еще раз от балансировщика (реверс прокси), либо перечитается еще раз из очереди. В случае retryable ошибки, конечно.
Касательно того, что называют здесь "сбоями сериализации" — видимо имеется в виду Lost updates и Non-repeatable reads (ну и фантомы)
Нет. Сбой сериализации тут — это ситуация, когда две транзакции заблокировали друг друга, из-за чего одна из них оказалась отменена.
Там будет 40001 serialization_failure. Если используется pgx, то будет проверка
var pgxErr *pgconn.PgError
if errors.As(err, &pgxErr) {
if pgxErr.Code == pgerrcode.SerializationFailure {
}
}
Из-за MVCC с Read Committed ты будешь читать значение из снепшота, на момент начала транзакции. Соответсвенно ты октрываешь новую транзакцию, что бы прочитать уже новые изменения.
Спасибо. В целом слежу за тем, чтобы таких запросов не было, а где нельзя - пользуюсь блокировками БД. Таких ошибок не было, да и отлавливаются они быстро.
Но если рассматривать подход как фреймворк, который отдается в руки программистам, плохо владеющим SQL, ретраи могут пригодиться.
Зависит от контекста задачи - можно и прямо всю функцию-замыкание ретраить, а можно ретраить на уровне отправки запроса/чтения сообщения (мы пошли по второму пути).
Не удобнее ли вместо контекста (где зачеркнуто)
func (db *Database) WithinTransaction(ctx context.Context, tFunc func(ctx context.Context) error) error
передавать копию Database с транзакцией в поле conn
?
func (db *Database) WithinTransaction(ctx context.Context, tFunc func(db CarRepository) error) error
Метод model, функции injectTx, extractTx тогда, возможно, совсем не понадобятся.
Нет, неудобно по двум причинам:
контекст все равно нужен, он не только для транзакций используется, но еще много для чего - от прерывания задач по таймауту до телеметрии;
если передавать копию Database, то придется протащить этот тип в интерфейсы ядра гексагона, а это нарушает принцип "зависимости от центра к периферии" - получается, что у нас бизнес логика знает о реализации адаптера для работы с БД, а этого быть не должно.
Работа через контекст позволяет сохранить изоляцию слоев и не нарушать направление зависимостей в гексагоне.
Контекст и так доступен в замыкании. Передавать его параметром нужно, если контекст изменяется. В варианте "замыкание получает экземпляр CarRepository, работающий внутри транзакции" это не нужно.
Копия (новый экземпляр, не тип) Database реализует уже объявленный интерфейс CarRepository, именно его я предлагаю передавать в замыкание.
Возможно, для conn придется использовать интерфейс, подобный приведенному ниже в комментариях QueryExecutor.
В этом случае, как писал выше, нужно будет протащить интерфейс со всеми методами (Database, QueryExecutor или еще что) в ядро гексагона, где он никак не нужен, да и реализует специфичную для адаптера логику.
Поэтому это делается через контекст, который в этом случае меняется, поэтому передается в функцию.
В ветке ниже (где QueryExecutor)@andrdru, фактически, предложил тот же вариант - передавать в замыкание работающий внутри транзакции экземпляр CarReporisotory.
У вас там хороший, развернутый комментарий. По нему у меня ощущение, что мы упираемся в какие-то идеологические, а не практические соображения.
Ведь подходы с контекстом и txRepo
, в принципе, схожи. Внутри замыкания можно использовать ctx
и txCtx
, а можно - repo
и txRepo
. Транзакция в обоих случаях создаётся на уровне репо. И если управление транзакцией (интерфейс Transactor, реализуемый репо) пробрасывается выше - его ведь можно и, может, даже желательно прикрыть интерфейсом домена?
Так что, в принципе, всё сводится к тому как именно Transactor доступен выше уровня домена, и различию между
c.carRepo.GetCar(txCtx, id)
и
txRepo.GetCar(ctx, id)
Мне второй вариант кажется чуть более устойчивым (он даёт компилятору больше информации), но различие это не принципиально.
Есть разница.
Первое - действительно есть некие идеологические соображения. А именно то, что для слоя бизнес-логики транзакция выглядит как-то так:
type Transaction interface {
Commit()
Rollback()
}
А вот всякие QueryContext
не имеют для бизнес логики никакого значения, потому что вызываться будет только в адаптере (репо и т.п.). Получается, что имплементация адаптера протекает в бизнес логику, то есть в противоположную сторону от направления зависимостей в гексагоне.
И не важно, что это интерфейс а не тип. Эти методы исключительно для работы с БД, о чем уровень логики не знает.
Второе - в решении, которое я сделал, транзакция и запросы к репо разделены, и могут быть реализованы на разных уровнях. В гексагоне я выполняю транзакцию на слое приложения, а сами методы репо вызываю внутри слоя доменов. При этом я волен как одним репо пользоваться и в транзакции и вне ее явно, так и в одной транзакции пользоваться несколькими репо без лишних манипуляций.
Это не говорит о том, что инвертированный подход (не репо внутри транзакции, а транзакция внутри репо) хуже, но для меня он выглядит более ограничительным по функционалу.
Ой. Нет репо внутри транзакции или транзакции внутри репо. Есть атомарная операция. В статье она реализуется через транзакцию БД, но ведь транзакция БД и атомарная бизнес-операция - не одно и то же.
Меня несколько смутило использование контекста бизнес-операции для обмена данными между интерфейсами репо (я говорю о реализации в статье, где Database реализует и CarRepository и Transactor). Отсюда - предложение из моего первого комментария.
Я выделил интерфейс
QueryExecutor interface {
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
}
Его имплементят sql.Tx и sql.DB, от него зависят репозитории, передается в конструктор
type CarRepository interface{
DB() QueryExecutor
}
type repo struct{
db QueryExecutor
}
func NewRepo(db QueryExecutor) CarRepository {
return &repo{db: db}
}
Так в транзакции можно вызывать NewRepo(tx) где tx типа sql.Tx
Сделал вот такой хелпер https://github.com/andrdru/sqltx
Также использовал замыкание. В интерфейс репозитория добавляю метод
TX(action func(txRepo CarRepository) error) error
который уже будет вызван в логике
Из недостатков - из репозитория торчит метод DB() QueryExecutor, который позволяет получить в замыкании sql.Tx.
Да, тоже интересный подход. Но тут получается, что на уровне приложения нужно во-первых явно получить объект бд или транзакции (пусть и скрытый за интерфейсом QueryExecutor), а потом сконструировать на его основе инстанс CarRepository, который либо работает в транзакции, либо без нее.
Подход жизнеспособный, но кмк немного ограничивающий.
В моем случае логика управления транзакцией отделена от репозиториев, и я могу (при необходимости) делать часть запросов вне транзакции, а часть - в транзакции, используя один и тот же репозиторий.
Плюс чисто технически я делаю инициализацию зависимостей и пробрасывание их через DI в момент старта приложения, поэтому при обработке запроса у меня уже есть все необходимые репо. Такой код получается немного чище, т.к. инициализация репо вынесена далеко от бизнес-логики, а в самой бизнес логике происходят вызовы методов репо, управление транзакцией ну и сама логика.
Если быть точнее, слой приложения гексагона управляет транзакцией, а внутри он вызывает методы доменного слоя. А уже домен внутри дергает репозитории (спрятанные за интерфейсами-портами), при этом не зная ничего про транзакцию.
Согласен, напрямую использовать не будет удобно. Для того хелпер: объект бд/транзакции скрыт за TX(), аналогично WithinTransaction(). Иницилизировать репозиторий придется только в случае, когда метод нужен внутри транзакции. В этом смысле реализация с контекстом возможно удобнее
carRepo.Tx(func(txRepo CarRepository)error{
err = txRepo.Method1()
if err != nil {
return err
}
customerTxRepo = NewCustomer(txRepo.DB())
return customerTxRepo.Method2()
})
Спасибо за статью. Применение паттернов и правил различных архитектур подразумевает улучшение читаемости, модульности и тестируемости кода.
Про последнее и будет вопрос. Как будут выглядеть юнит тесты? Смущает что большая часть реализации сценария лежит внутри замыкания
Это отличный вопрос, спасибо.
Мы делаем так: генерируем через mockery моки для всех интерфейсов (портов), а в тестах заряжаем моки данными. Непосредственно мок транзактора выглядит в тесте вот так:
transactor := new(mocks.Transactor)
transactor.On("WithinTransaction", any, any).
Return(
func(ctx context.Context, f func(context.Context) error) error {
return f(ctx)
},
)
В целом получается тестировать так:
порты есть двух видов - входящие и исходящие. Входящие это хендлеры http, grpc серверов, консумер очереди и т.п. Исходящие - работа с БД, продьюсер очереди, клиенты http и grpc.
Исходящие порты мокаются, mockery позволяет нам не тратить на моки время.
Моки подсовываются через DI в ядро гексагона.
Методы входящих портов тестируются в тестах, в итоге ядро гексагона полностью изолировано.
Подробнее можно посмотреть тут.
Спасибо за статью. Есть несколько спорных моентов, которые я бы хотел отметить.
Действительно, транзакцию можно представить как некую бизнес-сущность — ведь бизнес логика может требовать атомарного и неконкурентного выполнения операции
Разве не лучше обеспечивать эту транзакционность коду на уровне Application? К тому же, в ответе на комментарии вы писали:
В гексагоне я выполняю транзакцию на слое приложения, а сами методы репо вызываю внутри слоя доменов
Хотя, сюдя по вашему коду, транзакция у вас стратует в методе Transactor.WithinTransaction
, который вызывается прямо в домене. Поэтому не понятно о каком выполнении транзакции на слое приложения идёт речь.Моя мысль - убрать транзакцию из домена и пусть за неё отвечает код Application. Зачем вам операции над репозиториями в домене вне транзакции? Не очень вижу от этого плюсов, зато вижу переусложнение доменных методов вызовами Transactor.WithinTransaction
. К тому же потенциальные ошибки гарантированы в таком случае: велика вероятность прочитать старые данные из базы и проводить операции над нами и потерять консистентность.
Если быть точнее, слой приложения гексагона управляет транзакцией, а внутри он вызывает методы доменного слоя. А уже домен внутри дергает репозитории (спрятанные за интерфейсами-портами), при этом не зная ничего про транзакцию.
Тут тоже наблюдается противоречие: слой домена по факту управляет транзакцией, ведь именно в нём вызывается метод Transactor.WithinTransaction
.
Мне кажется, что то как организована передача транзакции в коде репозитория можно улучшить. Как предлагали в одном из комментариев.Передавать только один объект Conn
, который присутствует в стандартной библиотеке database/sql
. В этом случае не нужно приведений типов и можно передавать только один объект - соединение. Изначально выделить себе соединение и начать на нём транзакцию
func (db *Database) model(ctx context.Context, model ...interface{}) *orm.Query {
tx := extractTx(ctx)
if tx != nil {
return tx.ModelContext(ctx, model...)
}
return db.conn.ModelContext(ctx, model...)
}
Этот код выглядит оверхедным после предложения использовать объект Conn
. К тому же этот подход более универсальный. Если вы захотите применять блокировки на уровне базы postgresql, то у вас не будет зависимости в очередности применения транзакции и блокировки. У нас произошла подобная проблема, в статье я описал что у нас произошло и как мы её решили.
Ещё спорным является решение положить в Context
объект Transaction
. Вы сами писали:
который позволяет передавать утилитарные данные
И тут я с вами полностью согласен. Такие вещи как RequestID, данные запроса для хендлера(как делает роутер gorilla/mux) или телеметрия идеально подходят для хранения в контексте.
С моей точки зрения информация о транзакции отлично подходит под определение "утилитарных данных"
Объект транзакции это скорее "сервис-абстракция", он предоставляет свои методы, в данном случае, для выполнения SQL-запрсов. И уж точно он(объект транзакции) не является "утилитарными данными" :) К тому же вы не контролируете кто владеет транзакцией. Так код, что находится по дереву выполнения ниже может закрыть транзакцию, а она доступна всем по дереву выполнения выше из контекста, сущности выше могут попытаться выполнить SQL-запрос на уже закрытой транзакции. Кейс странный, но это просто ещё одно для возникновения ошибок. В своей статье я описал, как мы используем шаринг соединения используя Context
как ключ доступа к сущностям. За получение транзакции через Context
отвечает одна сущность. Решаем ту же проблему не складывая в Context
сервисы :) Так можно Context
ненароком превратить ServiceProvider
в рамках запроса :)
Последнее и очень спорное: Context
в домене. Объект Context в своём поле Value переносит неизвестную домену сущность interface{}
и получается домен начинает зависеть от неопределенной сущности, а значит нарушается целостность домена! К тому же, зачем домену как-то зависеть такой сущности как Context
, которая ему ни к чему?
Спасибо за вопросы, попробую ответить на каждый.
Хотя, сюдя по вашему коду, транзакция у вас стратует в методе
Transactor.WithinTransaction
, который вызывается прямо в домене.
Это было сделано из желания показать семантику вызова метода, не превращая статью в листинг кода. Поэтому я и написал комментарий, что на деле это делается не в слое домена, а в слое приложения. Я не хотел давать слишком много абстракций на случай, если читатель плохо знаком с концепцией гексагона.
На деле управление транзакцией и вызовы методов репозиторев
выглядят примерно так
Здесь транзакцией управляет непосредственно слой приложения, а домен внутри работает с репозиториями.
Может ли домен работать с портами адаптеров БД напрямую, или должна быть какая-то прокладка от уровня приложения? Я считаю, что работать напрямую с портом можно, и не видел утверждения обратного в концепции гексагона. Если не согласны - приведите ссылку на первоисточник, где утверждается обратное.
Передавать только один объект
Conn
, который присутствует в стандартной библиотекеdatabase/sql
Только передавать не sql.Conn
, а общий интерфейс между sql.Conn
и sql.Tx
, потому что создание транзакции происходит до упаковки в контекст, а не после.
В моем случае я использую go-pg, которая не использует database/sql
, а реализует собственный драйвер, поэтому я использую соответствующий интерфейс из библиотеки go-pg. Смысл тот же - это интерфейс, в котором есть методы исполнения запроса, которые есть и в коннекте, и в транзакции.
Если вы захотите применять блокировки на уровне базы postgresql, то у вас не будет зависимости в очередности применения транзакции и блокировки.
У меня нет таких зависимостей. Поскольку в контекст передается именно объект транзакции (а не коннект к бд), я всегда явно работаю с определенной транзакцией без жонглирования пулом коннектов.
Проблема, описанная в вашей статье, как мне кажется от того, что вы используете отдельную транзакцию для блокировок, и отдельную для остальной логики. Мне абсолютно непонятно, для чего так делать, когда блокировки как раз и работают в рамках одной транзакции. Я делаю блокировки в рамках одной транзакции силами инструментов БД (блокировки БД), и не имею никаких проблем. Операций записи где нужны блокировки мало по сравнению с операциями чтения, где они не нужны (привет, CQRS), поэтому БД отлично справляется. Если перестанет справляться - будем оптимизировать.
В целом поднимать за один запрос несколько транзакций БД кажется потенциальным выстрелом в ногу, что ваша статья, кажется, подтверждает.
Ещё спорным является решение положить в
Context
объектTransaction
Решение действительно спорное. В моем случае оно позволяет реализовать очень компактные и чистые с точки зрения изоляции транзакции, поэтому я на него пошел. Впрочем, идея так передавать транзакции не моя, и в целом она используется в индустрии. Но утверждать, что все смогут с этим смириться, не готов.
К тому же вы не контролируете кто владеет транзакцией. Так код, что находится по дереву выполнения ниже может закрыть транзакцию, а она доступна всем по дереву выполнения выше из контекста, сущности выше могут попытаться выполнить SQL-запрос на уже закрытой транзакции.
Нет, транзакцией владеет только метод WithinTransaction
. Выше по стеку в контексте в принципе нет транзакции, а ниже (внутри замыкания) доступен интерфейс для осуществления запросов, а не управления транзакцией.
Да, можно выполнить COMMIT
через Exec
. Но давайте быть серьезными, это уже откровенный саботаж проекта, и для борьбы с этим есть подходящие инструменты - code review и процесс собеседований, в рамках которых саботажники отсеиваются.
Context
в домене. Объект Context в своём поле Value переносит неизвестную домену сущностьinterface{}
и получается домен начинает зависеть от неопределенной сущности, а значит нарушается целостность домена!
Нет, не начинает. Домен в принципе не имеет доступа к этой сущности, к ней доступ имеет только адаптер БД, т.к. ключ неэкспортируемый.
Можно за уши притянуть то, что для правильной работы транзакций нужно пропагировать контекст от метода-замыкания до вызовов методов порта (репо). Да, нужно. Но так как мы и так это делаем как для graceful shutdown, так для телеметрии, это не является проблемой. Это конечно накладывает некоторые ограничения, но это явно описано в godoc интерфейса Transactor
, и в целом является меньшей болью из всех возможных.
Надеюсь смог достаточно полно ответить на вопросы. Еще раз повторюсь - передо мной не стояло задачи универсального механизма транзакций для любой абстрактной ACID-совместимой БД. Я делал решение именно под Postgres и именно с учетом возможностей go-pg, но получилось действительно универсальное решение, разве что я не закладывал работу с несколькими бд одновременно или несколько транзакций в одном запросе одновременно, потому что первого не требовалось, а второе считаю неправильным подходом.
Передача транзакции в контексте действительно спорная, однако в случае гексогона дает решает больше проблем, чем создает, и отвечает на больше вопросов, чем задает. Дискуссию об использовании контекста для тех или иных данных я оставляю пуристам, я же решил инженерную задачу, и как мне кажется, решил с минимально возможной болью.
Еще можно было бы докинуть поддержку savepoints, для пущей красоты. Nested транзакции тоже существуют, для операций над аггрегатами
Никогда не использовал в продуктивном коде ни то, ни другое, по причине ненадобности и кажущегося значительного усложнения кода и его читабельности.
Технически это несложно реализовать, тем не менее. Но современные архитектурные практики склоняются к тому, чтобы работать с каждым агрегатом в отдельной транзакции, а между ними коммуницировать через очередь или вроде того.
Опыт работы над несколькими агрегатами в одной транзакции показывает, что идея с брокером действительно лучше.
Илья, спасибо! Прям ровно то, что я искал! С меня причитается! ))
Чистые транзакции в гексагональном Go