Проблема Race Condition в Django и её решение с помощью select_for_update

Автор: Javohir Abdullayev · · Django

Проблема Race Condition в Django и её решение с помощью select_for_update

Введение: Неожиданные потери баланса и состояние гонки (Race Condition)

Каждый backend-разработчик за свою карьеру хотя бы раз сталкивается со скрытыми и трудноуловимыми ошибками. Одной из самых опасных и сложных в обнаружении является Race Condition (состояние гонки). В небольших проектах и локальной среде кажется, что всё работает безупречно. Однако когда на сайт одновременно заходят сотни пользователей и обращаются к одному и тому же ресурсу (например, к балансу счёта или покупке билетов), возникают серьёзные финансовые и логические ошибки.

В этой статье мы разберём классическую проблему, встречающуюся в реальных системах электронной коммерции и платежей, её причины, а также то, как правильно решить её с помощью select_for_update() в Django ORM.

Проблема в реальном проекте (Case Study)

Представьте, что вы разработали систему управления внутренним балансом кошелька пользователей. У каждого пользователя на счету 100 000 сум. Пользователь одновременно из двух разных вкладок браузера или через Telegram-бота нажимает кнопку покупки товара стоимостью 80 000 сум два раза подряд.

Обычно начинающие разработчики пишут код следующим образом:

def withdraw_money(user_id, amount):\n    wallet = Wallet.objects.get(user_id=user_id)\n    if wallet.balance >= amount:\n        wallet.balance -= amount\n        wallet.save()\n        return True\n    return False

Почему этот код опасен?

Если два запроса приходят на сервер практически одновременно (с разницей в несколько миллисекунд), оба процесса считывают из базы данных баланс 100 000 сум. Оба запроса считают условие 100 000 >= 80 000 истинным, вычитают 80 000 из баланса и сохраняют в базу значение 20 000. В результате пользователь получает товары на общую сумму 160 000 сум, но с его баланса списывается всего 80 000 сум! Компания несёт убыток в размере 60 000 сум.

Решение: Транзакции в базе данных и select_for_update

Самый надёжный способ решить эту проблему — блокировка строк на уровне базы данных (row-level locking). Django позволяет очень удобно реализовать это с помощью transaction.atomic() и метода select_for_update().

Пример исправленного кода

from django.db import transaction\n\ndef safe_withdraw_money(user_id, amount):\n    with transaction.atomic():\n        wallet = Wallet.objects.select_for_update().get(user_id=user_id)\n        \n        if wallet.balance >= amount:\n            wallet.balance -= amount\n            wallet.save()\n            return True\n        else:\n            return False

Как это работает?

  • transaction.atomic(): Оборачивает все операции в единую транзакцию. Если на каком-либо этапе произойдёт ошибка, все изменения будут отменены (rollback).
  • select_for_update(): Отправляет на уровне SQL запрос SELECT ... FOR UPDATE. Это накладывает блокировку (lock) именно на эту запись в базе данных PostgreSQL или MySQL.
  • Когда поступает второй запрос, он ожидает завершения (commit) первой транзакции. После завершения первой транзакции второй запрос считывает уже обновлённый баланс (20 000 сум) и отклоняется из-за недостаточности средств.

Дополнительная защита с помощью F-выражений

Для простых вычислений существует ещё один отличный инструмент — F-выражения Django F(). Они обновляют значение напрямую в базе данных, а не в памяти приложения:

from django.db.models import F\n\nWallet.objects.filter(id=wallet.id, balance__gte=amount).update(balance=F('balance') - amount)

Такой подход выполняет проверку и списание одним SQL-запросом и работает исключительно быстро для простых операций.

Важные рекомендации и меры предосторожности

  1. Риск взаимной блокировки (Deadlock): При одновременной блокировке нескольких объектов всегда блокируйте их в одинаковом порядке (например, сортируя по ID).
  2. Использование параметра nowait: Если вы не хотите долгого ожидания в очереди, можно указать select_for_update(nowait=True), чтобы немедленно вернуть ошибку при наличии блокировки.
  3. Используйте только там, где это необходимо: Блокировка при каждом обычном запросе на чтение снижает производительность системы, поэтому применяйте её только в финансовых и критически важных транзакциях.

Заключение

Ошибки Race Condition — одна из самых больших головных болей при росте числа пользователей проекта. Правильно применяя транзакции и метод select_for_update(), предоставляемый Django ORM, вы можете на 100% гарантировать финансовую безопасность и целостность данных вашего проекта.

Теги: #Django #Backend #PostgreSQL #Ma'lumotlar bazasi