Разработка линтера транзакций для Go
- title
- Разработка линтера транзакций для Go
- type
- summary
- summary
- История о том, как léon h создал transactioncheck после того, как выкатил в прод баг с утечкой транзакции
- tags
- golang, static-analysis, databases
- sources
- go-transaction-linter
- created
- 2026-04-15
- updated
- 2026-04-15
- lang
- ru
- translation_of
- go-transaction-linter
- source_updated
- 2026-04-15
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Léon h выпустил баг, из-за которого чтение из базы данных шло в обход транзакции и тихо повреждало данные. Код компилировался, тесты проходили, но допустить такую ошибку снова было слишком просто. Поэтому он потратил два дня на написание собственного линтера.
Баг
Транзакции на колбэках часто встречаются в кодовых базах на Go, использующих паттерн репозитория:
return s.repo.Transaction(ctx, func(tx models.Repo) error {
user, err := s.repo.GetUser(ctx, userID) // Bug: uses s.repo, not tx
return tx.SaveUser(ctx, user)
})
Вызов GetUser использует s.repo вместо параметра tx, поэтому выполняется вне транзакции. Чтение не входит в атомарную операцию. Под нагрузкой другой процесс может изменить пользователя в промежутке между чтением и записью, и поверх запишутся устаревшие данные.
Этот баг коварен: всё чисто компилируется, модульные тесты проходят (в изоляции нет конкурентности), а сбои недетерминированы. Заметить его удаётся, только когда данные уже испортились в production.
Линтер
transactioncheck использует go-analysis-framework для статического поиска таких утечек. Подход следующий:
- Обходить AST в поисках вызовов метода
Transactionна интерфейсах репозиториев - Захватывать параметр транзакции (
tx) из колбэка - Внутри тела колбэка помечать любое использование внешнего репозитория вместо
tx
Два паттерна нарушений:
- Прямые вызовы:
s.repo.GetUser(...)внутри колбэка - Косвенная передача:
helperFunction(ctx, s.repo, ...), куда следовало передатьtx
Остроумная деталь - отслеживание параметров. Type checker в Go присваивает каждой переменной уникальный types.Object. Два идентификатора, указывающие на одну и ту же переменную, ссылаются на один и тот же объект, поэтому проверка равенства надёжно работает даже при затенении имён (shadowing).
Рекурсивный анализ
Линтер не останавливается на границе колбэка. Если колбэк вызывает вспомогательную функцию и передаёт ей tx, линтер рекурсивно заходит внутрь этой функции и проверяет нарушения уже там. Чтобы избежать бесконечных циклов, он отслеживает уже посещённые функции.
Это позволяет ловить такие ошибки:
s.repo.Transaction(ctx, func(tx models.Repo) error {
return s.processOrder(ctx, tx, orderID) // processOrder internally uses s.repo
})
Развёртывание
Вместо встраивания в golangci-lint команда запускает его отдельно через mise:
[tasks.transactioncheck]
run = 'bin/transactioncheck ./...'
При первом же запуске линтер нашёл в кодовой базе несколько реальных нарушений. Два дня работы обеспечили постоянную защиту от целого класса багов, которые иначе проявлялись бы исключительно как инциденты в production.