EnglishРусский Map

Разработка линтера транзакций для Go

title
Разработка линтера транзакций для Go
type
summary
summary
История о том, как léon h создал transactioncheck после того, как выкатил в прод баг с утечкой транзакции
tags
golang, static-analysis, databases
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 для статического поиска таких утечек. Подход следующий:

  1. Обходить AST в поисках вызовов метода Transaction на интерфейсах репозиториев
  2. Захватывать параметр транзакции (tx) из колбэка
  3. Внутри тела колбэка помечать любое использование внешнего репозитория вместо 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.