Гонки при параллельной записи в Django: разбор и ревью
Два запроса читают одну строку, оба прибавляют единицу — а счётчик вырос на единицу вместо двух. Разбираем, где Django теряет обновления и что искать на ревью.

Классический баг, который не воспроизводится в разработке и стабильно всплывает в проде: пользователь дважды нажал «купить», два воркера Gunicorn обработали запросы почти одновременно, и остаток на складе ушёл в минус. Или счётчик просмотров, который под нагрузкой считает вдвое меньше реальных обращений. Причина одна — потерянное обновление (lost update): два процесса прочитали одно и то же значение, каждый прибавил своё и записал обратно, затерев чужой результат. Разбираем на коде Django 5.x и PostgreSQL 16, где это происходит и как формулировать замечания на ревью.
Как выглядит гонка в коде
Вот типичный фрагмент, который проходит ревью, работает в тестах и ломается под параллельной нагрузкой:
# Django 5.x. Уменьшение остатка на складе — с гонкой
def reserve_item(product_id):
product = Product.objects.get(pk=product_id)
if product.stock > 0:
product.stock -= 1 # читаем в Python, вычитаем в Python
product.save()
return product.stock
Проблема в разрыве между чтением (get) и записью (save). Между ними процесс может уступить управление другому запросу. Оба прочитали stock = 5, оба записали 4. Одна продажа потерялась. При save() Django по умолчанию пишет все поля модели значением, которое лежит в объекте Python, — то есть буквально затирает строку целиком.
Что происходит на уровне транзакций
Даже если обернуть блок в transaction.atomic(), гонка не исчезнет. Уровень изоляции по умолчанию в PostgreSQL — Read Committed. Он не блокирует чтение и не гарантирует, что прочитанное значение не изменится к моменту записи. Транзакция защищает от частично применённых изменений, но не от логической гонки «прочитал–посчитал–записал».
Транзакция и защита от гонки — разные вещи. atomic() отвечает за «всё или ничего», а не за «никто не влез между моим чтением и моей записью».
Четыре способа закрыть гонку
У каждого своя цена — по количеству запросов, риску дедлоков и читаемости.
1. Атомарное обновление через F-выражение
Самый дешёвый способ для инкремента и декремента. Вычисление уходит на сторону базы, промежуточного значения в Python нет вообще:
from django.db.models import F
# Атомарно на уровне SQL: UPDATE ... SET stock = stock - 1
updated = Product.objects.filter(
pk=product_id, stock__gt=0
).update(stock=F('stock') - 1)
if updated == 0:
# ни одна строка не подошла: остатка нет или товар удалён
raise OutOfStock()
Условие stock__gt=0 в filter и проверка update() == 0 — ключевая деталь. База сама решает, подошла строка под условие или нет, атомарно. Никакой отдельной проверки if в Python быть не должно — иначе гонка возвращается.
2. Пессимистичная блокировка: select_for_update
Когда логика сложнее инкремента (например, надо принять решение по нескольким полям), блокируем строку на чтение:
from django.db import transaction
with transaction.atomic():
product = (
Product.objects
.select_for_update()
.get(pk=product_id)
)
if product.stock > 0:
product.stock -= 1
product.save(update_fields=['stock'])
select_for_update() ставит блокировку FOR UPDATE на строку до конца транзакции. Второй запрос на ту же строку встанет в очередь и продолжит только после коммита первого. Важно: вызов работает только внутри atomic(), иначе Django выбросит TransactionManagementError. И обратите внимание на update_fields=['stock'] — без него save() перепишет все поля объекта, включая те, что могли устареть.
3. Оптимистичная блокировка через версию
Блокировка не берётся заранее. Вместо этого при записи проверяем, что строка не менялась с момента чтения. Нужно поле версии или тот же stock в условии:
product = Product.objects.get(pk=product_id)
if product.stock > 0:
updated = Product.objects.filter(
pk=product_id, stock=product.stock # версия = прочитанное значение
).update(stock=product.stock - 1)
if updated == 0:
# кто-то опередил — читаем заново и повторяем
raise ConcurrentModification()
Здесь нет блокировок и очередей, но вызывающий код обязан обрабатывать конфликт и повторять операцию. Подходит, когда конкуренция за одну строку редкая, а держать блокировку дорого.
4. Повышение уровня изоляции
Serializable-изоляция заставит PostgreSQL откатывать транзакции, которые нарушили сериализуемость, с ошибкой serialization failure. Способ мощный, но перекладывает на приложение обязанность ловить откаты и повторять транзакцию целиком. Для точечной гонки это обычно избыточно — начинайте с F-выражения или select_for_update.
Что выбирать
| Подход | Когда брать | Цена |
|---|---|---|
| F-выражение | Инкремент/декремент, счётчики, остатки | Один UPDATE, но нельзя использовать значение сразу после — оно устарело в объекте |
| select_for_update | Решение по нескольким полям, сложная логика | Блокировка держит строку, риск очередей и дедлоков при захвате нескольких строк |
| Оптимистичная версия | Редкая конкуренция за строку | Приложение обязано повторять при конфликте |
| Serializable | Сложные инварианты между таблицами | Откаты транзакций, retry-логика на весь блок |
Практическое правило: если задача сводится к «прибавить/отнять число» — F-выражение. Если внутри транзакции принимается решение на основе прочитанных данных — select_for_update. Всё остальное — по необходимости.
Что искать на ревью
Формулировки замечаний, которые сразу указывают на проблему, а не на «качество кода вообще»:
- Чтение и запись одного поля в разных операциях. Паттерн
obj = get(), потомobj.field += 1, потомobj.save()— потенциальная гонка. Замечание: «здесь read-modify-write без блокировки, под параллельной записью потеряем обновление, замени на F() или select_for_update». - Проверка условия в Python перед записью.
if product.stock > 0:ничего не гарантирует к моментуsave(). Условие должно быть вfilter()базы. - save() без update_fields после select_for_update. Модель могла быть загружена целиком до захвата блокировки; полный
save()перепишет соседние поля устаревшими значениями. - select_for_update вне atomic. Либо ошибка на рантайме, либо (при autocommit) блокировка снимается сразу и смысла в ней нет.
- Захват нескольких строк select_for_update без единого порядка. Классический источник дедлоков: транзакция A блокирует строку 1 и ждёт 2, B — наоборот. Лечится сортировкой захвата по ключу, например через
order_by('pk'). - Использование результата F-выражения сразу после update. После
update(stock=F('stock') - 1)объект в памяти не обновлён. Нуженrefresh_from_db(), если значение требуется дальше.
Как воспроизвести гонку локально
В юнит-тестах на SQLite гонку почти не поймать: файловая база сериализует запись. Для проверки нужен PostgreSQL и параллелизм. Дайте нагрузку в два-три параллельных процесса на один и тот же product_id и сравните итоговый остаток с ожидаемым. Инструменты вроде locust или простого ab/hey на конкретный эндпоинт покажут расхождение. Мерить нужно на реальной СУБД проекта, при DEBUG=False и с тем же уровнем изоляции, что в проде.
Что делать дальше
Пройдитесь по местам, где меняются счётчики, остатки, балансы и статусы, — это первые кандидаты на потерянные обновления. Переведите инкременты на F(), сложную логику — под select_for_update с update_fields. Настройте мониторинг дедлоков: PostgreSQL пишет их в лог по умолчанию, включите сбор этих сообщений. Порядок захвата блокировок при работе с несколькими строками — отдельная тема; здесь достаточно запомнить, что он должен быть единым во всём коде.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Django documentation — Database access optimization: select_for_update() и F() expressions, PostgreSQL 16 Documentation — Transaction Isolation
Частые вопросы
У меня MySQL, а не PostgreSQL — приёмы те же?
Тесты проходят, но в проде остаток уходит в минус. Почему?
select_for_update повесит всё приложение под нагрузкой?
F-выражение защищает от гонки, но мне нужно новое значение сразу после — как быть?
Обернул код в transaction.atomic — этого достаточно?
Как понять, что у меня уже есть дедлоки в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.