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

Сценарий, который приходит в постмортеме: два запроса почти одновременно читают баланс кошелька (1000 рублей), каждый проверяет, что хватает на списание 800, и каждый списывает. На выходе баланс уходит в минус, хотя весь код был внутри transaction.atomic(). Разработчик уверен, что транзакция должна была это предотвратить. Она не должна — и это не баг Django, а неверное понимание того, что делает atomic и что делает уровень изоляции базы.
Материал про PostgreSQL 16 и Django 5.x. Разбираем, где проходит граница ответственности между atomic и настройками БД, и как на практике закрывать race condition, из-за которого баланс уходит в минус. Оптимистичные блокировки и распределённые транзакции между несколькими БД — отдельные темы, здесь их не трогаем.
Что atomic гарантирует, а что нет
transaction.atomic() в Django делает ровно одну вещь: оборачивает набор запросов в транзакцию БД, которая либо коммитится целиком, либо откатывается целиком при исключении. Это буква A и D из ACID — атомарность и долговечность. Всё.
Изоляцию (буква I) atomic не настраивает. Уровень изоляции задаётся на стороне базы и определяет, что одна транзакция видит из другой, пока обе выполняются. По умолчанию Django с PostgreSQL работает на уровне READ COMMITTED — это дефолт самого PostgreSQL, Django его не меняет.
На READ COMMITTED каждый SELECT внутри транзакции видит данные, закоммиченные на момент начала этого запроса. Между двумя вашими SELECT в одной транзакции другая транзакция может закоммитить изменение, и второй SELECT увидит уже новое значение. Именно это ломает наивную схему «прочитал баланс — проверил — списал».
atomic отвечает на вопрос «докатится ли всё до конца или откатится?». На вопрос «что я увижу, пока рядом бежит чужая транзакция?» отвечает уровень изоляции. Это два разных механизма, и первый не заменяет второй.Как выглядит гонка в коде
# Django 5.x, PostgreSQL 16. READ COMMITTED — уровень по умолчанию.
from django.db import transaction
def withdraw(wallet_id, amount):
with transaction.atomic():
wallet = Wallet.objects.get(pk=wallet_id) # прочитали 1000
if wallet.balance >= amount:
wallet.balance -= amount # посчитали 200 в памяти
wallet.save() # записали 200
Два потока входят в withdraw одновременно. Оба читают баланс 1000, оба проходят проверку, оба пишут 200. Транзакции не конфликтуют, потому что каждая писала «своё» финальное значение — БД не считает это ошибкой на READ COMMITTED. Итог: списали 1600, а баланс показывает 200.
Механизм: почему READ COMMITTED это допускает
На READ COMMITTED PostgreSQL гарантирует, что вы не прочитаете незакоммиченные данные чужой транзакции. Но он не гарантирует, что значение, которое вы прочитали, не изменится к моменту записи. Между вашим SELECT и UPDATE есть окно, в котором соседняя транзакция успевает вклиниться. Классическая аномалия — lost update, потерянное обновление.
Есть три рабочих способа это закрыть. Разберём каждый с ценой.
Способ 1. select_for_update — пессимистичная блокировка
select_for_update() добавляет к SELECT конструкцию FOR UPDATE: строка блокируется до конца транзакции, второй запрос на ту же строку ждёт.
from django.db import transaction
def withdraw(wallet_id, amount):
with transaction.atomic():
# блокируем строку кошелька до конца транзакции
wallet = Wallet.objects.select_for_update().get(pk=wallet_id)
if wallet.balance >= amount:
wallet.balance -= amount
wallet.save()
Второй поток на строке select_for_update().get() встанет в ожидание, пока первый не закоммитит транзакцию. Прочитает уже 200 и проверку не пройдёт. select_for_update() обязательно вызывается внутри atomic — иначе Django бросит TransactionManagementError, потому что блокировка без транзакции бессмысленна.
Цена: строка заблокирована, конкурирующие запросы к ней сериализуются. На горячей строке (общий кошелёк, счётчик остатков популярного товара) это точка контеншена. Плюс блокировка держится до конца транзакции — если внутри atomic висит медленный внешний вызов, вы держите лок всё это время.
Способ 2. Атомарный UPDATE через F()
Если операция сводится к «прибавить/отнять», не читайте значение в Python вообще. Считайте прямо в БД одним UPDATE с условием.
from django.db.models import F
def withdraw(wallet_id, amount):
# один UPDATE ... WHERE balance >= amount, без предварительного SELECT
updated = Wallet.objects.filter(
pk=wallet_id, balance__gte=amount
).update(balance=F('balance') - amount)
if updated == 0:
raise InsufficientFunds() # строк не изменилось — денег не хватило
F('balance') - amount превращается в SET balance = balance - amount — вычисление идёт на стороне БД, значение не проходит через Python. Условие balance__gte=amount в том же запросе. Даже на READ COMMITTED PostgreSQL берёт блокировку строки на время UPDATE, и второй запрос увидит уже новое значение. updated возвращает число изменённых строк — если 0, значит условие не выполнилось.
Цена: работает только для операций, выражаемых в SQL. Сложную бизнес-логику с ветвлениями сюда не уложить. И update() не вызывает save(), сигналы pre_save/post_save не сработают.
Способ 3. Повысить уровень изоляции до SERIALIZABLE
На SERIALIZABLE PostgreSQL отслеживает конфликты сериализации и откатывает одну из транзакций с ошибкой, если результат нельзя было бы получить последовательным выполнением. Уровень задаётся в настройках соединения через OPTIONS в DATABASES либо через connection.isolation_level. Точный синтаксис под ваш адаптер (psycopg 3) сверьте в документации Django по настройке БД — он менялся между psycopg2 и psycopg 3.
Цена самая тяжёлая: SERIALIZABLE бросает SerializationFailure (в Django — подкласс OperationalError), и ваш код обязан ловить её и повторять транзакцию. Без ретраев вы просто перенесёте ошибку с «баланс в минусе» на «случайные 500 под нагрузкой».
Что выбирать
| Подход | Когда | Чем платите |
|---|---|---|
select_for_update | Сложная логика между чтением и записью, нужна блокировка строки | Контеншен на горячей строке, лок до конца транзакции |
F() + update() | Инкремент/декремент, счётчики, остатки, баланс | Только SQL-выражения, сигналы не вызываются |
| SERIALIZABLE | Много таблиц в одной инвариантной операции | Обязательные ретраи на SerializationFailure |
Для списания баланса в 90% случаев правильный ответ — способ 2. Один запрос, нет лишнего SELECT, нет удерживаемого лока, атомарность на уровне БД. К select_for_update переходите, когда между чтением и записью есть ветвления, которые в SQL не выразить.
Где это ломается на проде
- ATOMIC_REQUESTS без понимания. Флаг
ATOMIC_REQUESTS = Trueв настройках БД оборачивает каждый HTTP-запрос в одну транзакцию. Удобно, но это значит, что долгий внешний вызов внутри вью держит транзакцию (и все взятые блокировки) открытой всё время обработки запроса. - Вложенные atomic — это savepoint, а не отдельная транзакция. Внутренний
atomicсоздаёт savepoint. Откат внутреннего блока откатывает до savepoint, но внешняя транзакция продолжается. Если ждёте, что внутренний блок «отменит всё» — это не так. - on_commit и откат. Колбэки
transaction.on_commit()выполняются только после успешного коммита. Если транзакция откатилась — задача в очередь не уйдёт. Не полагайтесь на порядок, если внутри вложенные savepoint'ы. - Ловля исключения внутри atomic. Если вы поймали
IntegrityErrorвнутри блокаatomicи не пробросили дальше, транзакция уже помечена как сломанная. Последующие запросы упадут с ошибкой «current transaction is aborted». Ловите такие ошибки снаружи блока или через вложенныйatomic.
Что делать дальше
Проверьте текущий уровень изоляции своей БД: если это дефолтный PostgreSQL, вы на READ COMMITTED, и любая логика «прочитал — проверил — записал» в конкурентной среде под подозрением. Найдите такие места grep'ом по .save() после .get() в денежных и складских операциях. Под нагрузкой мерьте не только время запроса, но и число ожиданий на блокировках — в PostgreSQL это видно через представление pg_stat_activity по состоянию wait_event. Если внедряете SERIALIZABLE — сначала напишите обёртку с ретраями и протестируйте её под параллельной нагрузкой, а не на одном запросе.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Django documentation — Database transactions, PostgreSQL 16 documentation — Transaction Isolation
Частые вопросы
Django по умолчанию использует READ COMMITTED или что-то другое?
А если у меня MySQL, а не PostgreSQL?
select_for_update не сработал — получаю TransactionManagementError. Почему?
Почему после IntegrityError все следующие запросы падают с 'transaction is aborted'?
F() и update() не вызывают сигналы pre_save/post_save — это проблема?
Стоит ли просто включить SERIALIZABLE везде и не думать о гонках?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.