Как улучшить реализацию функционала перевода денег между пользователями?
Что проверяют
Видите ли вы гонки и частичные сбои, а не только «добавим микросервисы и Redis». Главное здесь два параллельных перевода с одного счёта и повтор запроса после таймаута. Следом обычно спрашивают, что будет, если клиент отправит один и тот же запрос дважды.
Как отвечать
Каркас в порядке важности:
- атомарность: списание и зачисление в одной транзакции. В Django это
transaction.atomic(), в SQLAlchemywith session.begin():; - гонки: проверка баланса и списание должны быть одним защищённым действием. Либо
SELECT ... FOR UPDATE(select_for_update()в Django), либо условный апдейтUPDATE accounts SET balance = balance - :amount WHERE id = :id AND balance >= :amountс проверкой числа изменённых строк; - дедлоки: два счёта блокировать в фиксированном порядке, например по возрастанию
id; - идемпотентность: клиент присылает ключ идемпотентности, на него уникальный индекс в таблице переводов. Повтор возвращает уже созданный результат, а не второй перевод;
- деньги:
Decimalили целые копейки, никогдаfloat, в базеNUMERIC; - учёт: вместо одного поля баланса журнал проводок по двойной записи. Баланс это сумма проводок или кеш, пересчитываемый из них, отсюда аудит и сверка;
- внешние эффекты: уведомления и события в Kafka или RabbitMQ отправлять после коммита через transactional outbox, иначе событие уйдёт, а транзакция откатится;
- ограничение в базе:
CHECK (balance >= 0)как последний рубеж.
Кеш балансов в Redis для самой операции вреден: источник правды должен быть один.
Где спотыкаются
Проверяют if account.balance >= amount в Python и потом сохраняют объект. Под нагрузкой два запроса проходят проверку одновременно, и счёт уходит в минус.
Сразу предлагают разнести переводы по микросервисам. Транзакция между двумя базами превращается в сагу с компенсациями, и простая задача становится распределённой без причины.
Не думают про повторы: мобильный клиент по таймауту шлёт запрос второй раз, и без ключа идемпотентности пользователь платит дважды.