Каким механизмом предотвращается двойное списание при конкурентных запросах
Что проверяют
Понимаете ли вы, что защищать нужно данные в базе, а не код в одном процессе. Интервьюер ждёт идемпотентности и атомарного условия на стороне базы, а sync.Mutex в ответе вызывает уточнение: а если сервис запущен в трёх репликах?
Как отвечать
Двойное списание случается по двум причинам, и закрывать надо обе.
Первая: одна и та же операция пришла дважды, клиент повторил запрос по таймауту или пользователь дважды нажал кнопку. Защита это ключ идемпотентности. Клиент шлёт Idempotency-Key, сервер пишет ключ в таблицу с уникальным индексом в той же транзакции, что и списание. Повтор упирается в нарушение уникальности (код 23505 в PostgreSQL), и сервер возвращает сохранённый результат первой операции.
Вторая: два разных списания конкурируют за один баланс. Варианты:
- атомарный условный апдейт:
UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1и проверкаRowsAffected(). Ноль строк значит, что денег не хватило. В PostgreSQL даже на Read Committed второй апдейт дождётся блокировки строки и перепроверит условие на свежей версии; - пессимистичная блокировка:
SELECT ... FOR UPDATEв транзакции, затем проверка и запись; - оптимистичная: колонка
versionи условиеWHERE version = $n, при конфликте повтор; - Serializable:
sql.TxOptions{Isolation: sql.LevelSerializable}вBeginTxи повтор транзакции при ошибке40001.
Для денег надёжнее всего журнал проводок: баланс считается из записей, и каждая запись однозначно привязана к операции.
Где спотыкаются
Предлагают sync.Mutex или atomic.AddInt64. Это защищает только память одного процесса, вторая реплика такую защиту просто не видит.
Делают «прочитать, потом списать»: SELECT balance, сравнение в Go, затем UPDATE. Между чтением и записью проходит второй запрос, и оба видят достаточный баланс.
Сохраняют ключ идемпотентности отдельно от списания. Падение между двумя шагами оставляет либо списание без ключа, либо ключ без списания.