> Как избежать гонок и рейсов в базе данных (Go)

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

Компании: Wildberries

Стек: Go

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

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

Гонки (race conditions) и рейсы (deadlocks) в БД возникают при конкурентном доступе к данным. Основные инструменты борьбы: транзакции с правильным уровнем изоляции (READ COMMITTED, REPEATABLE READ, SERIALIZABLE), оптимистичная блокировка через version/updated_at, пессимистичная через SELECT ... FOR UPDATE, уникальные индексы и идемпотентные операции. В Go важно также контролировать пул соединений и таймауты, чтобы избежать взаимных блокировок на уровне приложения.

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

Гонки - это когда две транзакции читают и пишут одни и те же данные без должной синхронизации, что приводит к потерянным обновлениям, грязным чтениям или фантомным записям. Рейсы (deadlocks) - ситуация, когда две или более транзакции ждут ресурсы, удерживаемые друг другом, и ни одна не может завершиться.

Основные стратегии:

  • Уровни изоляции: READ UNCOMMITTED допускает грязные чтения, READ COMMITTED защищает от них, REPEATABLE READ предотвращает неповторяющиеся чтения, SERIALIZABLE полностью сериализует транзакции. Чем выше уровень, тем больше блокировок и риск deadlock, но меньше гонок.
  • Оптимистичная блокировка: читаем данные, выполняем логику, при UPDATE проверяем version или updated_at. Если версия изменилась - повторяем операцию или возвращаем ошибку. Подходит для редких конфликтов.
  • Пессимистичная блокировка: SELECT ... FOR UPDATE блокирует строки на время транзакции. Надёжно, но увеличивает вероятность deadlock и снижает конкурентность.
  • Уникальные индексы: предотвращают дублирование записей на уровне БД, что особенно важно для операций типа "создать, если не существует".
  • Идемпотентность: повторное выполнение операции даёт тот же результат, что снижает влияние гонок.
  • Правильный порядок блокировок: всегда блокируем ресурсы в одном и том же порядке, чтобы избежать deadlock.
  • Таймауты и retry: в Go используем context.WithTimeout и повторяем транзакции при ошибках вида "deadlock detected" или "lock wait timeout".

На практике

В Go с PostgreSQL или MySQL типичный подход:

  1. Для операций, где конфликты редки - оптимистичная блокировка. В таблице добавляем поле version int NOT NULL DEFAULT 0. При UPDATE: UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2. Если RowsAffected == 0 - конфликт, повторяем.
  2. Для операций, где потеря данных критична (например, списание средств) - пессимистичная блокировка: BEGIN; SELECT balance FROM accounts WHERE id = $1 FOR UPDATE; ... UPDATE ...; COMMIT;.
  3. Для создания записей с уникальными полями - уникальный индекс + обработка ошибки duplicate key (код 23505 в PostgreSQL).
  4. Всегда устанавливаем таймаут на транзакцию: ctx, cancel := context.WithTimeout(ctx, 5*time.Second).
  5. Настраиваем пул соединений: db.SetMaxOpenConns(50), db.SetMaxIdleConns(10), db.SetConnMaxLifetime(time.Hour). Слишком маленький пул - источник deadlock на уровне приложения, слишком большой - нагрузка на БД.
  6. Используем retry с экспоненциальной задержкой для transient errors (например, github.com/jackc/pgx/v5/pgconn даёт PgError с кодом 40001 для serialization failure).

Пример кода

GO
// Оптимистичная блокировка
func UpdateBalance(ctx context.Context, db *sql.DB, accountID int64, delta int64) error {
for attempt := 0; attempt < 3; attempt++ {
var balance int64
var version int
err := db.QueryRowContext(ctx,
"SELECT balance, version FROM accounts WHERE id = $1", accountID,
).Scan(&balance, &version)
if err != nil {
return err
}
newBalance := balance + delta
if newBalance < 0 {
return errors.New("insufficient funds")
}
res, err := db.ExecContext(ctx,
"UPDATE accounts SET balance = $1, version = version + 1 WHERE id = $2 AND version = $3",
newBalance, accountID, version,
)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 1 {
return nil
}
// конфликт - повторяем
}
return errors.New("too many conflicts")
}
// Пессимистичная блокировка с таймаутом
func Transfer(ctx context.Context, db *sql.DB, fromID, toID int64, amount int64) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// всегда блокируем в одном порядке (например, по id)
if fromID > toID {
fromID, toID = toID, fromID
}
var fromBalance int64
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", fromID,
).Scan(&fromBalance)
if err != nil {
return err
}
var toBalance int64
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", toID,
).Scan(&toBalance)
if err != nil {
return err
}
if fromBalance < amount {
return errors.New("insufficient funds")
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
return err
}
return tx.Commit()
}

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

Начни с определения проблемы: гонки - это несогласованные чтения/записи, рейсы - взаимные блокировки. Затем перечисли уровни изоляции и их trade-off. Объясни разницу между оптимистичной и пессимистичной блокировкой, приведи примеры, когда что использовать. Упомяни уникальные индексы и идемпотентность. В контексте Go обязательно скажи про context, таймауты, пул соединений и retry. Покажи понимание, что deadlock может возникнуть и на уровне приложения, если пул соединений слишком мал. Закончи практическим примером с кодом.

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

  • Понимание уровней изоляции и их практических последствий.
  • Умение выбирать стратегию блокировки под конкретный сценарий.
  • Знание особенностей Go: context, database/sql, обработка ошибок.
  • Понимание причин deadlock и способов их предотвращения.
  • Умение писать безопасный конкурентный код с учётом транзакций.

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

  • Использование SERIALIZABLE везде без необходимости - это убивает конкурентность и провоцирует deadlock.
  • Игнорирование таймаутов - транзакция может висеть бесконечно, блокируя другие.
  • Неправильный порядок блокировок - классический источник deadlock.
  • Отсутствие retry при serialization failure - оптимистичная блокировка требует повторных попыток.
  • Слишком маленький пул соединений - приложение ждёт соединение, удерживая другие транзакции.
  • Забывают про уникальные индексы для операций "create if not exists" - гонка приводит к дублям.
  • Проверка баланса и обновление не в одной транзакции - данные могут измениться между запросами.

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

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