Как избежать гонок и рейсов в базе данных
Что проверяют
Отличаете ли вы гонку в памяти процесса от гонки в базе. sync.Mutex защищает переменную внутри одного экземпляра сервиса, а их обычно несколько, и все пишут в одну таблицу. Следом почти всегда спрашивают, как два пода не спишут один баланс дважды.
Как отвечать
Начните с типичного сценария: прочитали строку, посчитали в Go, записали обратно. Между чтением и записью другой запрос делает то же самое, и одно обновление теряется (lost update). Способы, от простого к тяжёлому:
- атомарный запрос:
UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1, проверка и запись в одном операторе, поRowsAffected()видно, прошло ли списание; - пессимистичная блокировка:
SELECT ... FOR UPDATEвнутри транзакции, конкурент ждёт доCOMMIT; - оптимистичная блокировка: колонка
versionиUPDATE ... WHERE id = $1 AND version = $2. Ноль затронутых строк значит, что вас опередили, повторяем; - уровни изоляции: в PostgreSQL по умолчанию Read Committed. На Repeatable Read и Serializable база сама ловит конфликт и возвращает ошибку сериализации (SQLSTATE
40001), транзакцию нужно повторить; - ограничения:
UNIQUEплюсINSERT ... ON CONFLICTнадёжнее проверки «есть ли уже такая запись»; - advisory lock:
pg_advisory_xact_lock(key), когда сериализовать нужно операцию, а не строку.
В Go транзакция открывается через db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}), и все запросы внутри идут через tx, а не через db.
Где спотыкаются
Предлагают мьютекс или канал. Это работает, пока экземпляр один, со вторым подом гонка возвращается. Детектор go test -race тоже ловит только гонки в памяти, до базы он не дотягивается.
Включают Serializable и не пишут повтор. Ошибки 40001 под нагрузкой нормальны, без цикла повтора пользователь получает 500.
Внутри транзакции вызывают db.Exec вместо tx.Exec. Запрос уходит по другому соединению из пула, мимо транзакции и её блокировок, а FOR UPDATE вне транзакции отпускает строку сразу после оператора.