Проверять чужой код — отдельный навык, и он почти не пересекается с умением писать свой. Человек может свободно читать Python и всё равно пропустить в пул-реквесте ошибку, из-за которой со счёта уедут реальные деньги. Ниже — разбор эндпоинта перевода средств между двумя счетами. В нём спрятаны три проблемы, и каждая встречается в рабочих проектах куда чаще, чем хочется.
Что делает код: принимает sender_id, receiver_id и amount, достаёт из базы оба счёта, проверяет, что они существуют, сравнивает баланс отправителя с суммой, списывает у одного, начисляет другому и делает commit. Синтаксис в порядке. Всё остальное — нет.
Первая: сумму никто не проверяет
Единственное условие — хватает ли денег. Про знак суммы речи не идёт. А теперь смотрим на две строки, которые меняют балансы: у отправителя вычитается amount, у получателя прибавляется.
Передаём amount = -500. Вычитание минуса превращается в сложение: у отправителя денег становится больше, у получателя — меньше. Отправитель фактически «переводит» себе деньги из чужого кармана. Ноль проходит ту же проверку и создаёт пустые транзакции, которые засоряют историю операций.
Лечится скучно: отклонять сумму меньше или равную нулю на входе, до обращения к базе. Валидация схемы запроса тут дешевле и надёжнее любой логики внутри обработчика.
Вторая: деньги в float
Тип amount — float. Для точных денежных значений это неподходящий выбор: 0.1 в двоичной плавающей точке не представляется точно, и на цепочке вычислений накапливается ошибка округления. Сумма, которая на бумаге сходится, в базе может разойтись на копейки — и найти потом, где именно, будет неприятным занятием.
Для денег берут фиксированную точность: Decimal или целые минорные единицы — копейки, центы. То есть хранят 1050 вместо 10.50, а точку ставят только на выводе.
Третья: проверка баланса и списание не защищены от гонки
Эту замечают реже всего, потому что в одной строке она не видна. Она проявляется, только когда два запроса приходят одновременно.
На счету 100. Почти в один момент приходят два перевода по 80. Запрос A читает баланс: 100. Запрос B читает тот же баланс: 100. Оба проходят проверку. Оба списывают.
Ушло 160, на счёте минус 60. Это классическая гонка на устаревших данных: проверка и изменение — две разные операции, и между ними успевает вклиниться второй процесс.
В продакшене нужна защита на уровне транзакции базы. Либо блокировка строки при чтении счетов, либо атомарный условный UPDATE вида «списать, если баланс всё ещё не меньше суммы», где проверка и изменение — один неделимый шаг. Схема «два отдельных SELECT, а потом commit» для денег не годится.
Чем закрывать гонку
| Подход | Как работает | Когда подходит | Чего не решает |
|---|---|---|---|
| Блокировка строки при чтении | Второй запрос ждёт, пока первый закоммитит изменение | Операции с несколькими объектами в одной транзакции | Блокировка висит до конца транзакции, при разном порядке захвата строк легко получить взаимную блокировку |
| Атомарный условный UPDATE | Условие по балансу и списание выполняются одним запросом | Денежные списания, счётчики, резервы | Нужно проверять, сколько строк реально изменилось: ноль означает отказ, и это надо обработать |
| Проверка в коде приложения без блокировки | Прочитали, посчитали, записали | Черновики, отчёты, тестовые данные | От параллельных запросов не защищает вообще |
Чек-лист для похожего ревью
- Границы входных значений: отрицательные числа, ноль, максимум, пустая строка, слишком длинное значение.
- Тип денежного поля: float или фиксированная точность.
- Разделены ли проверка и изменение — или объединены в одну атомарную операцию.
- Что произойдёт, если этот обработчик вызовут дважды одновременно с одного и того же счёта.
- Поведение при ошибке: если commit упал, в каком состоянии остались балансы.
- Куда логируется операция — при разборе спорной транзакции без этого не обойтись.
Почему третью ошибку пропускают чаще остальных
Первые две видны при чтении строки: глаз цепляется за float и за отсутствие проверки знака. Гонка так не читается — она требует смоделировать в голове два параллельных потока и понять, где именно они пересекутся. Это отдельное усилие, и на уставшем ревью его обычно не делают.
Помогает привычка задавать один вопрос к каждому изменяющему запросу: что случится, если между чтением и записью влезет кто-то ещё. Ответ «ничего» бывает только у кода, который работает с локальными данными.
Ревью — это не поиск красивых имён переменных. Начните с двух вопросов: какие значения вообще может принять вход и что будет, если этот код вызовут второй раз, пока первый ещё не закончил. На эндпоинте перевода денег таких вопросов хватает, чтобы поймать все три бага до того, как они доберутся до прода.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.