Транзакции и блокировки в Django: что ревьюить
На ревью ORM-кода в Django легко пропустить проблемы с транзакциями и блокировками, которые стреляют только под нагрузкой. Разбираем конкретные паттерны, формулировки замечаний и критерии, по которым можно отловить беду до продакшена.

Почему транзакционный код проходит ревью незамеченным
Типичный pull request в Django-проекте: модель сохраняется, рядом создаётся связанная запись, отправляется уведомление. Тесты зелёные, линтер молчит, ревьюер смотрит на бизнес-логику и жмёт Approve. Через месяц под нагрузкой возникают дедлоки, потерянные записи и «фантомные» уведомления о событиях, которых не было. Проблема не в Django — проблема в том, что на ревью никто не проверял границы транзакций и поведение блокировок.
Ниже — набор конкретных точек, на которые стоит смотреть при ревью Django-кода, работающего с базой. Всё написано для Django 4.2+ и PostgreSQL 14+, хотя большинство замечаний актуально и для MySQL/InnoDB. Тонкости уровней изоляции и внутреннее устройство менеджера блокировок СУБД — отдельная тема, здесь рассматриваем только то, что видно из Python-кода.
Границы транзакций: где начинается и где заканчивается
ATOMIC_REQUESTS и его ловушка
Настройка ATOMIC_REQUESTS = True в конфигурации базы данных оборачивает каждое представление в транзакцию. Звучит безопасно, но на ревью стоит задавать вопрос: а нужно ли держать транзакцию открытой всё время, пока view работает? Если внутри view есть HTTP-запрос к стороннему сервису, отправка письма или запись в очередь — транзакция висит открытой, удерживая строки заблокированными, пока внешний вызов не вернётся.
Формулировка замечания на ревью: «View использует ATOMIC_REQUESTS, но внутри есть вызов payment_gateway.charge(). Пока шлюз отвечает, транзакция держит блокировки на таблице orders. Вынеси внешний вызов за пределы транзакции или переключи view на ручное управление через transaction.atomic().»
Вложенные atomic() и savepoints
Вложенный transaction.atomic() в Django создаёт savepoint, а не вложенную транзакцию. Это значит, что откат внутреннего блока не отменяет внешнюю транзакцию, но исключение, не перехваченное между уровнями, отменит всё.
Что искать на ревью:
- Перехватывается ли
IntegrityErrorилиDatabaseErrorснаружи вложенногоatomic()? Если перехват внутри — savepoint уже помечен как сломанный, и любой следующий запрос вызоветInFailedSqlTransaction. - Есть ли бизнес-логика между двумя вложенными
atomic(), которая зависит от результата первого? Откат savepoint не откатит Python-переменные — состояние в памяти и в базе разойдётся.
on_commit: побочные эффекты после фиксации
transaction.on_commit() регистрирует колбэк, который выполнится после успешного коммита. Проблема: если on_commit вызван внутри вложенного atomic(), колбэк сработает только после коммита внешней транзакции, а не savepoint. На ревью это выглядит так: разработчик ставит отправку уведомления в on_commit внутри вложенного блока, думая, что уведомление уйдёт сразу после сохранения записи. На деле оно уйдёт позже — или не уйдёт вовсе, если внешняя транзакция откатится.
Формулировка замечания: «on_commit внутри вложенного atomic(). Колбэк привязан к внешней транзакции. Если внешний блок откатится, уведомление не уйдёт, но код выше уже сформировал ответ клиенту. Перенеси on_commit на уровень внешнего atomic() или убедись, что откат внешнего блока — штатный сценарий.»
Блокировки: select_for_update и его окрестности
Когда select_for_update нужен, а когда — нет
select_for_update() берёт строковую блокировку (FOR UPDATE) в PostgreSQL. Это правильный инструмент, когда нужно гарантировать, что между чтением и записью никто не изменит строку. Но на ревью чаще всего встречаются две крайности:
- select_for_update отсутствует — код читает баланс, вычисляет новый, сохраняет. Между чтением и записью другой запрос может сделать то же самое — классическая гонка (lost update).
- select_for_update стоит везде «на всякий случай» — каждый запрос к таблице берёт эксклюзивную блокировку, пропускная способность падает, и под нагрузкой начинаются таймауты.
Критерий: select_for_update нужен, если между чтением строки и её обновлением есть бизнес-логика, результат которой зависит от прочитанных данных. Если вы просто делаете UPDATE ... SET status = 'done' без предварительного чтения — ORM-метод queryset.update() атомарен сам по себе, блокировка избыточна.
Параметры, которые часто забывают
| Параметр | Что делает | Когда нужен |
|---|---|---|
nowait=True | Вместо ожидания блокировки немедленно выбрасывает DatabaseError | Когда лучше вернуть 409 Conflict, чем держать воркер |
skip_locked=True | Пропускает заблокированные строки | Очереди задач: взять свободную запись, не ждать чужую |
of=('self',) | Блокирует только указанную таблицу при JOIN | Запрос с select_related: не нужно блокировать справочники |
no_key=True (Django 4.2+) | FOR NO KEY UPDATE — не блокирует вставку в дочерние FK-таблицы | Обновление родительской строки, когда дочерние вставляются параллельно |
Формулировка замечания: «select_for_update() без nowait и без skip_locked на таблице tasks. Воркеры будут блокировать друг друга. Если это очередь — используй skip_locked. Если конкуренция редка — добавь nowait и обработай DatabaseError.»
Порядок блокировок и дедлоки
Дедлок возникает, когда две транзакции берут блокировки в разном порядке. Django ORM не защищает от этого автоматически. На ревью ищите ситуацию, когда в одном месте кода блокируется сначала Order, потом OrderItem, а в другом — наоборот.
Если две функции блокируют одни и те же таблицы — порядок блокировки должен быть одинаковым во всей кодовой базе. Это не рекомендация, а необходимое условие отсутствия дедлоков.
Практическое правило: договоритесь о каноническом порядке таблиц (например, алфавитном) и проверяйте его на ревью. Это проще, чем разбирать pg_stat_activity в три часа ночи.
F-выражения вместо чтения-вычисления-записи
Паттерн «прочитали значение в Python, посчитали, записали обратно» — источник гонок даже внутри транзакции с уровнем изоляции Read Committed (по умолчанию в PostgreSQL). Два параллельных запроса прочитают одно значение, оба прибавят единицу и запишут одно и то же число — вместо двух инкрементов получится один.
F()-выражение переносит вычисление на сторону базы:
# Django 4.2+ / PostgreSQL 14+
from django.db.models import F
# Атомарный инкремент без select_for_update
Account.objects.filter(pk=account_id).update(balance=F('balance') - amount)
На ревью замечание формулируется так: «Баланс читается в переменную, вычитается сумма, вызывается save(). Между read и write другой запрос может изменить баланс. Замени на F-выражение или добавь select_for_update.»
F-выражение не требует блокировки для простых арифметических операций, но не поможет, когда нужна условная логика: «списать, только если баланс достаточен». В этом случае — select_for_update или SQL-constraint с проверкой на уровне базы.
Чек-лист для ревью
Распечатайте или добавьте в шаблон PR-ревью:
- Есть ли явный
transaction.atomic()вокруг группы связанных записей? Если нет — могут ли записи остаться рассогласованными при ошибке? - Нет ли внешних вызовов (HTTP, очередь, файловая система) внутри транзакции?
- Используется ли
on_commit— и на правильном ли уровне вложенности? - Где read-modify-write — есть ли
select_for_updateилиF()? - Если
select_for_update— указаны лиnowait/skip_locked/ofпо ситуации? - Блокируются ли таблицы в одном и том же порядке во всех путях исполнения?
- Нет ли
select_for_updateвнеatomic()? Django выбросит ошибку, но в тестах сTransactionTestCaseэто может не всплыть. - Перехватывается ли
IntegrityErrorснаружиatomic(), а не внутри?
Где подход ломается
Всё описанное выше работает в рамках одной базы данных. Как только появляется вторая база (реплика, шардирование, внешний сервис с собственным состоянием) — transaction.atomic() защищает только одно соединение. Распределённые транзакции Django ORM не поддерживает, и ревью тут не поможет: нужна другая архитектура (саги, идемпотентные операции, outbox-паттерн). Это отдельная тема.
Также стоит помнить, что уровень изоляции Read Committed, стоящий в PostgreSQL по умолчанию, не защищает от всех аномалий. Если бизнес-логика требует, чтобы два последовательных SELECT в одной транзакции видели одни и те же данные, — нужен Repeatable Read. Но смена уровня изоляции — решение уровня архитектуры, а не отдельного PR.
Savepoint — именованная точка внутри транзакции, к которой можно откатиться без отмены всей транзакции. В Django каждый вложенный вызов transaction.atomic() создаёт savepoint через SQL-команду SAVEPOINT.
Что делать дальше
Добавьте чек-лист выше в шаблон code review вашей команды. Настройте в CI проверку на select_for_update вне atomic() — достаточно grep-правила или кастомного flake8-плагина. Включите логирование медленных запросов и блокировок в PostgreSQL (log_lock_waits = on, deadlock_timeout — значение подберите под свой профиль нагрузки). И периодически смотрите pg_stat_activity на staging под нагрузочным тестом: то, что не видно на ревью, видно в ожидающих блокировках.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: Django documentation — Database transactions, PostgreSQL documentation — Explicit Locking
Частые вопросы
Нужно ли всегда оборачивать код в transaction.atomic()?
Что произойдёт, если вызвать select_for_update() без atomic()?
Как обнаружить дедлоки до продакшена?
А если у меня другая версия Django или другая СУБД?
select_for_update с nowait вернёт ошибку — как её обработать, не сломав UX?
Можно ли автоматизировать проверку транзакционных проблем в CI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.