Как устроен кошелек у каждого сервиса
Что проверяют
Как вы проектируете хранение денег: точность, конкурентные списания, повторные запросы, аудит. Формулировка размытая, поэтому сначала уточните, о чём речь: о внутреннем балансе пользователя в продукте или о криптокошельке. Чаще имеют в виду первое, и следом спрашивают, что будет, если два списания придут одновременно.
Как отвечать
Каркас для внутреннего кошелька:
- деньги: целые числа в минимальных единицах (
int64в копейках) илиnumericв базе.float64для денег не годится, для дробных сумм в Go берутshopspring/decimal; - журнал проводок: баланс не правится напрямую, каждое движение пишется в неизменяемый журнал, лучше по двойной записи. Текущий баланс хранится отдельно и обновляется в той же транзакции;
- конкурентность: атомарный
UPDATE wallets SET balance = balance - $1 WHERE id = $2 AND balance >= $1с проверкой числа затронутых строк, либоSELECT ... FOR UPDATE. При переводе между кошельками блокировки берут в порядке возрастания id, иначе дедлок; - идемпотентность: клиент присылает ключ операции, в базе на него уникальный индекс. Повтор после таймаута возвращает прежний результат, а не списывает второй раз;
- холды: для оплаты сумму сначала резервируют, потом подтверждают или отменяют;
- связь с другими сервисами: события о движениях уходят через transactional outbox, сложные сценарии собираются сагой с компенсациями, а не распределённой транзакцией.
На Go это pgx или database/sql с одной транзакцией на сценарий, context с таймаутом и повтор при ошибке сериализации (40001), если выбран уровень SERIALIZABLE.
Где спотыкаются
Хранят баланс во float64. Сумма 0.1 и 0.2 в двоичной плавающей точке не равна 0.3, и на тысячах операций расхождение всплывает при сверке.
Защищают баланс через sync.Mutex. Он работает внутри одного процесса, а на двух репликах сервиса гонка возвращается. Блокировка должна жить в базе.
Читают баланс, проверяют его в коде и потом записывают новое значение. Между чтением и записью успевает пройти второе списание, и баланс уходит в минус.