> Как избежать гонок и рейсов в базе данных (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 типичный подход:
- Для операций, где конфликты редки - оптимистичная блокировка. В таблице добавляем поле
version int NOT NULL DEFAULT 0. При UPDATE:UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2. Если RowsAffected == 0 - конфликт, повторяем. - Для операций, где потеря данных критична (например, списание средств) - пессимистичная блокировка:
BEGIN; SELECT balance FROM accounts WHERE id = $1 FOR UPDATE; ... UPDATE ...; COMMIT;. - Для создания записей с уникальными полями - уникальный индекс + обработка ошибки duplicate key (код 23505 в PostgreSQL).
- Всегда устанавливаем таймаут на транзакцию:
ctx, cancel := context.WithTimeout(ctx, 5*time.Second). - Настраиваем пул соединений:
db.SetMaxOpenConns(50),db.SetMaxIdleConns(10),db.SetConnMaxLifetime(time.Hour). Слишком маленький пул - источник deadlock на уровне приложения, слишком большой - нагрузка на БД. - Используем 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 int64var version interr := db.QueryRowContext(ctx,"SELECT balance, version FROM accounts WHERE id = $1", accountID,).Scan(&balance, &version)if err != nil {return err}newBalance := balance + deltaif 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 int64err = tx.QueryRowContext(ctx,"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", fromID,).Scan(&fromBalance)if err != nil {return err}var toBalance int64err = 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" - гонка приводит к дублям.
- Проверка баланса и обновление не в одной транзакции - данные могут измениться между запросами.
> Похожие задачи по Go
Как индекс определяет, какую запись отдать при поиске по таблице
Сколько букв нужно для поиска в индексе
Для чего нужен брокер сообщений Kafka
Какие группы паттернов проектирования существуют
> Похожие задачи по backend
Как индекс определяет, какую запись отдать при поиске по таблице
Сколько букв нужно для поиска в индексе
Для чего нужен брокер сообщений Kafka
Какие группы паттернов проектирования существуют
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью