> Была ли важна атомарность изменений в хранилище и отправки сообщений (Go)

Уровень: senior · Роль: backend · Категория: Технические вопросы

Компании: BetBoom

Стек: Go

> Пример ответа

Короткий ответ

Да, атомарность критична, когда хранилище и отправка сообщений должны быть согласованы. Без неё возможны расхождения: сообщение отправлено, но данные не сохранены, или наоборот. Это классическая проблема распределённых систем - dual-write. Решается через transactional outbox, двухфазный коммит или event sourcing. Выбор зависит от требований к консистентности и допустимых компромиссов.

Подробное объяснение

Проблема возникает при паттерне "записать в БД и отправить в брокер" в рамках одной бизнес-операции. Если эти два действия не атомарны, система может оказаться в несогласованном состоянии:

  • Сообщение отправлено, но запись не сохранена - получатель обработает событие, которого нет в источнике истины.
  • Запись сохранена, но сообщение не отправлено - downstream-сервисы не узнают об изменении, данные разъедутся.

Атомарность здесь означает, что либо оба действия выполняются, либо ни одно. В распределённой системе невозможно добиться атомарности через обычную транзакцию БД, потому что брокер сообщений не участвует в ней.

Основные подходы:

  1. Transactional outbox - запись в БД и вставка события в outbox-таблицу в одной локальной транзакции. Отдельный процесс (relay) читает outbox и отправляет сообщения в брокер. Гарантирует at-least-once доставку, требует идемпотентности у потребителя.

  2. Two-phase commit (2PC) - координатор управляет транзакцией через БД и брокер. Теоретически даёт атомарность, но на практике редко используется из-за блокировок, сложности и ограниченной поддержки брокерами.

  3. Event sourcing - хранилище само является журналом событий, отправка сообщений - побочный эффект. Атомарность достигается за счёт того, что событие - единственный источник истины.

  4. Saga / компенсация - если атомарность невозможна, применяют компенсирующие действия при частичном успехе.

Важно понимать trade-off: полная атомарность в распределённой системе стоит дорого. Часто достаточно гарантии "событие будет доставлено" (outbox) плюс идемпотентность потребителя.

На практике

Для senior-позиции важно не просто назвать паттерн, а объяснить, как он реализуется в Go-сервисе.

Типичная реализация outbox:

  • В транзакции с бизнес-данными пишем в таблицу outbox (id, aggregate_id, event_type, payload, status).
  • Фоновый воркер (например, горутина с time.Ticker или отдельный сервис) периодически читает новые записи.
  • Отправляет их в Kafka/RabbitMQ, помечает как отправленные.
  • При сбое отправки запись остаётся в outbox, воркер ретраит.

Ключевые моменты:

  • Идемпотентность - потребитель должен уметь обрабатывать дубликаты (по event_id).
  • Порядок событий - если важен порядок, нужна сортировка по aggregate_id и created_at.
  • Очистка outbox - старые записи нужно удалять, иначе таблица растёт.
  • Мониторинг - метрики на размер outbox, количество ретраев, задержку доставки.

Альтернатива - использовать готовые решения: Debezium + CDC, если БД поддерживает, или библиотеки типа go-outbox.

Пример кода

Упрощённый пример outbox-воркера на Go:

GO
type OutboxRecord struct {
ID int64
EventType string
Payload []byte
CreatedAt time.Time
}
func ProcessOutbox(ctx context.Context, db *sql.DB, producer MessageProducer, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
records, err := fetchPending(ctx, db, 100)
if err != nil {
log.Printf("fetch outbox: %v", err)
continue
}
for _, rec := range records {
if err := producer.Send(ctx, rec.EventType, rec.Payload); err != nil {
log.Printf("send event %d: %v", rec.ID, err)
continue
}
if err := markSent(ctx, db, rec.ID); err != nil {
log.Printf("mark sent %d: %v", rec.ID, err)
}
}
}
}
}
func fetchPending(ctx context.Context, db *sql.DB, limit int) ([]OutboxRecord, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, event_type, payload, created_at
FROM outbox
WHERE status = 'pending'
ORDER BY id
LIMIT $1`, limit)
// ...
}

Важно: отправка и отметка sent не атомарны. Если процесс упадёт между ними, сообщение отправится повторно - это нормально при at-least-once.

Как отвечать на собеседовании

Начните с прямой формулировки проблемы: dual-write. Затем перечислите варианты решения, но не перечисляйте всё подряд - сфокусируйтесь на outbox как основном практическом решении. Объясните, почему 2PC редко применяется. Упомяните trade-off между строгой атомарностью и производительностью.

Хорошо показать понимание последствий: без атомарности система в конечном счёте придёт к несогласованности, и это не вопрос "если", а "когда". Поэтому выбор - не "нужна ли атомарность", а "какую гарантию консистентности мы можем себе позволить".

Если спросят про альтернативы - упомяните saga и компенсации, но подчеркните, что это уже не атомарность, а управление ошибками.

Что проверяет интервьюер

  • Понимание распределённых транзакций и их ограничений.
  • Знание паттернов консистентности: outbox, 2PC, saga.
  • Умение оценивать trade-off между гарантиями и сложностью.
  • Практический опыт: как реализовать outbox, какие подводные камни (дубликаты, порядок, очистка).
  • Способность объяснить, почему "просто отправить после сохранения" - плохое решение.

Типичные ошибки

  • Утверждение, что "можно просто обернуть в транзакцию" - это не работает с брокером.
  • Игнорирование проблемы дубликатов при at-least-once.
  • Предложение 2PC как универсального решения без упоминания его минусов.
  • Отсутствие плана очистки outbox - таблица растёт бесконечно.
  • Непонимание, что outbox даёт at-least-once, а не exactly-once.
  • Смешивание атомарности с идемпотентностью - это разные вещи.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью