Проблема Race Condition в Django: реальный кейс и решение
Автор: Javohir Abdullayev · · Django

Введение: Как маленькая ошибка привела к большим убыткам?
Представьте, что вы управляете платформой электронной коммерции (e-commerce) или сервисом продажи билетов. В системе осталась всего 1 единица акционного товара. В одну и ту же секунду два разных покупателя нажимают кнопку «Купить». Что происходит в итоге? С карт обоих пользователей списываются деньги, а количество товара в базе данных вместо 0 становится -1. В результате у вас один товар и два недовольных клиента.
В программировании эта ситуация называется Race Condition (состояние гонки). В этом кейсе мы разберем, как данная проблема возникла в реальном проекте, почему обычные проверки в Python не способны ее предотвратить и как профессионально устранить ее с помощью Django.
Анатомия проблемы: цикл Read-Modify-Write
Обычно начинающие разработчики пишут логику проверки и списания баланса следующим образом:
def transfer_funds(account_id, amount):
account = Account.objects.get(id=account_id)
if account.balance >= amount:
account.balance -= amount
account.save()
return True
return FalseЭтот код отлично работает на локальном компьютере или при низкой нагрузке. Однако на реальном сервере (например, при работе Gunicorn или Uvicorn с несколькими воркерами), если одновременно поступают два запроса, разворачивается следующая хронология событий:
- Запрос A: считывает баланс из базы данных (баланс = 100 000 сум).
- Запрос B: одновременно считывает тот же счет (баланс все еще 100 000 сум).
- Запрос A: проверяет списание 80 000 сум (100 000 >= 80 000 — верно) и сохраняет новый баланс 20 000.
- Запрос B: также проверяет списание 80 000 сум на основе своих устаревших данных (100 000 сум), считает операцию успешной и перезаписывает баланс значением 20 000 сум.
В результате со счета было потрачено 160 000 сум, однако в базе данных баланс остался равным 20 000 сум. Для владельца проекта это прямой финансовый убыток.
Решение 1: Атомарное обновление с помощью F()-объектов Django
В простейших случаях (например, счетчики или простые операции вычитания) не следует полагаться на значение в оперативной памяти Python — обновление нужно делегировать непосредственно базе данных. Это можно реализовать с помощью выражений F() в Django:
from django.db.models import F
Account.objects.filter(id=account_id, balance__gte=amount).update(
balance=F('balance') - amount
)На уровне SQL выполняется команда: UPDATE account SET balance = balance - 80000 WHERE id = 1 AND balance >= 80000. Если параллельно приходят два запроса, СУБД при обработке второго запроса обнаружит, что условие больше не выполняется, и обновит 0 строк. Для простых сценариев это крайне эффективный подход.
Решение 2: Пессимистическая блокировка (select_for_update)
Однако далеко не вся бизнес-логика укладывается в одну команду update(). Зачастую перед списанием необходимо проверить аккаунт, вызвать внешнее платежное API, создать счет-фактуру и только затем изменить баланс. В таких комплексных сценариях применяется метод пессимистической блокировки (Pessimistic Locking).
В Django эту задачу решают метод select_for_update() и атомарные транзакции (transaction.atomic):
from django.db import transaction
from django.core.exceptions import ValidationError
def secure_withdraw(account_id, amount):
with transaction.atomic():
account = Account.objects.select_for_update().get(id=account_id)
if account.balance < amount:
raise ValidationError('Mablag yetarli emas!')
account.balance -= amount
account.save()
# Boshqa muhim biznes amallar
TransactionHistory.objects.create(
account=account,
amount=amount,
action_type='withdraw'
)
return accountКак работает этот код?
Метод select_for_update() на уровне SQL отправляет запрос SELECT ... FOR UPDATE. Соответствующая строка блокируется (lock) в базе данных PostgreSQL или MySQL. Любой другой параллельный запрос, пытающийся прочитать или изменить эту строку, будет вынужден ожидать завершения первой транзакции (commit или rollback). Таким образом, второй запрос всегда видит актуальный баланс, зафиксированный первым запросом.
Опасность Deadlock (взаимной блокировки) и защита от нее
Использование блокировок влечет за собой новый риск: Deadlock (взаимная блокировка). Например, если Пользователь 1 переводит деньги со счета A на счет B, а в это же время Пользователь 2 переводит со счета B на счет A, обе транзакции могут заблокировать строки друг друга и зависнуть навсегда.
Для предотвращения взаимных блокировок необходимо следовать 3 ключевым правилам:
- Всегда блокируйте ресурсы в одном порядке: например, всегда сначала блокируйте объект с меньшим ID, а затем — с большим ID.
- Используйте параметр nowait:
select_for_update(nowait=True)позволяет избежать бесконечного ожидания в очереди и быстро вернуть ошибку. - Делайте транзакции максимально короткими: избегайте тяжелых HTTP-запросов и долгих вычислений внутри блока транзакции.
Заключение
Race condition — это ошибка проектирования, которая может проявляться нечасто, но обходится бизнесу очень дорого. Если вы разрабатываете платежные системы, сервисы бронирования билетов или складского учета, не полагайтесь на простые условные операторы. Грамотное использование выражений F() и метода select_for_update() в Django сделает ваш проект надежным и устойчивым даже при экстремальных нагрузках.
Теги: #Django #Python #PostgreSQL #Backend #Database